From b326d54a18449d17eeedffcb269431ef8514fec8 Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Mon, 20 Jul 2026 18:28:56 -0700 Subject: [PATCH 1/3] more api surface, protect some nils, less disconnect events --- cameras.go | 42 ++++++++++++++++++++++++++++++++++++++-- cameras_internal_test.go | 5 +++++ events.go | 10 +++++++++- securityspy.go | 3 ++- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/cameras.go b/cameras.go index b8c6f95..9b286f2 100644 --- a/cameras.go +++ b/cameras.go @@ -22,11 +22,19 @@ import ( // All returns interfaces for every camera. func (c *Cameras) All() []*Camera { + if c == nil { + return nil + } + return c.cameras } // ByNum returns an interface for a single camera. func (c *Cameras) ByNum(number int) *Camera { + if c == nil { + return nil + } + for _, cam := range c.cameras { if cam.Number == number { return cam @@ -38,6 +46,10 @@ func (c *Cameras) ByNum(number int) *Camera { // ByName returns an interface for a single camera, using the name. func (c *Cameras) ByName(name string) *Camera { + if c == nil { + return nil + } + for _, cam := range c.cameras { if cam.Name == name { return cam @@ -59,7 +71,7 @@ func (c *Cameras) ByName(name string) *Camera { // media fragments flush while capture continues. Close() cancels an in-progress capture. // UseHTTP is not supported (returns ErrHTTPVideoUnsupported). func (c *Camera) StreamVideo(ops *VidOps, length time.Duration, maxsize int64) (io.ReadCloser, error) { - rtspURL, err := c.makeVideoURL(ops, c.makeRequestParams(ops)) + rtspURL, err := c.VideoURL(ops) if err != nil { return nil, err } @@ -80,7 +92,7 @@ func (c *Camera) SaveVideo(ops *VidOps, length time.Duration, maxsize int64, out return ErrPathExists } - rtspURL, err := c.makeVideoURL(ops, c.makeRequestParams(ops)) + rtspURL, err := c.VideoURL(ops) if err != nil { return err } @@ -97,6 +109,32 @@ func (c *Camera) SaveVideo(ops *VidOps, length time.Duration, maxsize int64, out return nil } +// VideoURL returns the RTSP(S) ++stream URL SaveVideo/StreamVideo would use. +// Credentials are included as userinfo; redact before logging if needed. +func (c *Camera) VideoURL(ops *VidOps) (string, error) { + return c.makeVideoURL(ops, c.makeRequestParams(ops)) +} + +// RedactedVideoURL is like VideoURL but replaces the password with "REDACTED". +func (c *Camera) RedactedVideoURL(ops *VidOps) (string, error) { + raw, err := c.VideoURL(ops) + if err != nil { + return "", err + } + + u, err := url.Parse(raw) + if err != nil { + return raw, nil //nolint:nilerr // best-effort redaction for logging + } + + if u.User != nil { + user := u.User.Username() + u.User = url.UserPassword(user, "REDACTED") + } + + return u.String(), nil +} + func (c *Camera) rtspclipOptions(length time.Duration, maxsize int64) rtspclip.Options { return rtspclip.Options{ Duration: length, diff --git a/cameras_internal_test.go b/cameras_internal_test.go index d403548..81ce96b 100644 --- a/cameras_internal_test.go +++ b/cameras_internal_test.go @@ -240,6 +240,11 @@ func TestMakeVideoURLUserinfoAndCodecs(t *testing.T) { cam.makeRequestParams(&VidOps{Height: 720})) require.NoError(t, err) require.Contains(t, raw, "stream?cameraNum=3&vcodec=h265&acodec=aac&height=720") + + redacted, err := cam.RedactedVideoURL(&VidOps{Height: 720, VCodec: "h265", ACodec: aacStr}) + require.NoError(t, err) + require.Contains(t, redacted, "rtsps://admin:REDACTED@ss.example:8001/stream?") + require.NotContains(t, redacted, "s3cret") } func TestMakeVideoURLPreservesBasePath(t *testing.T) { diff --git a/events.go b/events.go index 17d7427..33b2061 100644 --- a/events.go +++ b/events.go @@ -197,9 +197,12 @@ func (e *Events) custom(eventType EventType, eventID, cam int, msg string) { /* INTERFACE HELPER METHODS FOLLOW */ // eventStreamScanner connects to the securityspy event stream and fires events into a channel. +// EventStreamDisconnect is only emitted after a successful connect (not on every failed dial). // //nolint:cyclop // but it runs forever! func (e *Events) eventStreamScanner(ctx context.Context, retryInterval time.Duration) { + var wasConnected bool + for { if ctx.Err() != nil { return @@ -207,7 +210,10 @@ func (e *Events) eventStreamScanner(ctx context.Context, retryInterval time.Dura stream, err := e.eventStreamConnect(ctx) if err != nil { - e.custom(EventStreamDisconnect, -10000, -1, err.Error()) + if wasConnected { + e.custom(EventStreamDisconnect, -10000, -1, err.Error()) + wasConnected = false + } select { case <-ctx.Done(): @@ -217,6 +223,7 @@ func (e *Events) eventStreamScanner(ctx context.Context, retryInterval time.Dura } } + wasConnected = true scanner := bufio.NewScanner(stream) scanner.Split(scanLinesCR) @@ -245,6 +252,7 @@ func (e *Events) eventStreamScanner(ctx context.Context, retryInterval time.Dura } e.custom(EventStreamDisconnect, -10000, -1, msg) + wasConnected = false select { case <-ctx.Done(): diff --git a/securityspy.go b/securityspy.go index 1b91667..f1bf57b 100644 --- a/securityspy.go +++ b/securityspy.go @@ -34,8 +34,9 @@ func NewMust(config *server.Config) *Server { } // Assign all the sub-interface structs. - secspyServer := &Server{Config: config, Encoder: DefaultEncoder} + secspyServer := &Server{Config: config, Encoder: DefaultEncoder, Info: &ServerInfo{}} secspyServer.Files = &Files{server: secspyServer} + secspyServer.Cameras = &Cameras{server: secspyServer} secspyServer.Events = &Events{ server: secspyServer, eventBinds: make(map[EventType][]func(Event)), From d028061b252a6370d2879894daf705579e53084b Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Mon, 20 Jul 2026 18:35:01 -0700 Subject: [PATCH 2/3] lint it up --- .golangci.yml | 1 + cameras.go | 10 +++++----- events.go | 11 +---------- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index a0f2f40..8753653 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,6 +10,7 @@ linters: - exhaustive - nlreturn - gomodguard + - goconst settings: gocritic: enable-all: true diff --git a/cameras.go b/cameras.go index 9b286f2..960a38f 100644 --- a/cameras.go +++ b/cameras.go @@ -122,17 +122,17 @@ func (c *Camera) RedactedVideoURL(ops *VidOps) (string, error) { return "", err } - u, err := url.Parse(raw) + videoURL, err := url.Parse(raw) if err != nil { return raw, nil //nolint:nilerr // best-effort redaction for logging } - if u.User != nil { - user := u.User.Username() - u.User = url.UserPassword(user, "REDACTED") + if videoURL.User != nil { + user := videoURL.User.Username() + videoURL.User = url.UserPassword(user, "REDACTED") } - return u.String(), nil + return videoURL.String(), nil } func (c *Camera) rtspclipOptions(length time.Duration, maxsize int64) rtspclip.Options { diff --git a/events.go b/events.go index 33b2061..42139ea 100644 --- a/events.go +++ b/events.go @@ -197,12 +197,10 @@ func (e *Events) custom(eventType EventType, eventID, cam int, msg string) { /* INTERFACE HELPER METHODS FOLLOW */ // eventStreamScanner connects to the securityspy event stream and fires events into a channel. -// EventStreamDisconnect is only emitted after a successful connect (not on every failed dial). +// EventStreamDisconnect is only emitted after a live stream ends, not on failed dials. // //nolint:cyclop // but it runs forever! func (e *Events) eventStreamScanner(ctx context.Context, retryInterval time.Duration) { - var wasConnected bool - for { if ctx.Err() != nil { return @@ -210,11 +208,6 @@ func (e *Events) eventStreamScanner(ctx context.Context, retryInterval time.Dura stream, err := e.eventStreamConnect(ctx) if err != nil { - if wasConnected { - e.custom(EventStreamDisconnect, -10000, -1, err.Error()) - wasConnected = false - } - select { case <-ctx.Done(): return @@ -223,7 +216,6 @@ func (e *Events) eventStreamScanner(ctx context.Context, retryInterval time.Dura } } - wasConnected = true scanner := bufio.NewScanner(stream) scanner.Split(scanLinesCR) @@ -252,7 +244,6 @@ func (e *Events) eventStreamScanner(ctx context.Context, retryInterval time.Dura } e.custom(EventStreamDisconnect, -10000, -1, msg) - wasConnected = false select { case <-ctx.Done(): From 1ac114f1142e5590c6f7b7fce59814b4dea0227d Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Mon, 20 Jul 2026 18:38:55 -0700 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cameras.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cameras.go b/cameras.go index 960a38f..89a2e42 100644 --- a/cameras.go +++ b/cameras.go @@ -124,7 +124,7 @@ func (c *Camera) RedactedVideoURL(ops *VidOps) (string, error) { videoURL, err := url.Parse(raw) if err != nil { - return raw, nil //nolint:nilerr // best-effort redaction for logging + return "", fmt.Errorf("parsing video URL: %w", err) } if videoURL.User != nil {