Camera pictures without an object-storage bucket

Head office shows a camera's latest frame rather than live video, for a
reason that has not changed: the engine serves MJPEG on 127.0.0.1 on a PC
behind a shop's router with no inbound route, and relaying it needs
WebRTC/TURN. Pointing a browser straight at the shop PC is not the escape
either - the engine's API is Basic-authenticated with a credential it
generates locally and never sends anywhere, and shipping that to the
cloud so a web page could use it would put the key to the biometric API
and the live face feed in the server's database.

But that picture only worked if you had an S3 bucket. Without one,
attachSnapshots reported "This system is not storing images" for every
camera forever - on the two screens whose whole job is to show the
camera. Making them picture-led turned a missing feature into a wall of
empty tiles, on every local install and any self-hosted customer who does
not want a bucket.

migrations/009 adds camera_snapshots and the agent falls back to
PUT /api/agent/cameras/{camera}/snapshot when the presigned route answers
images_disabled - chosen by sentinel, never by matching the message, since
it picks between two routes. One row per camera is what makes this safe in
the database when face images are not: the key IS the camera, so storage
is (cameras x ~100 KB) and does not grow with footfall.

The read is session-authenticated rather than a signed link, which an
<img> cannot use - hence Shot.jsx and useAuthedImage, keyed on the URL
string rather than the snapshot object so a poll does not re-fetch 90 KB
per camera every few seconds, and revoking the object URL on cleanup.

Verified against the real office camera with no bucket configured: 90,587
bytes stored in Postgres, served as image/jpeg to a signed-in user, 401
without a session, rendered on both the Cameras and Shops cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
2026-09-04 12:53:18 +05:30
parent c7024b57ca
commit e0ceb14589
19 changed files with 757 additions and 49 deletions

View File

@@ -29,7 +29,14 @@ type Engine interface {
type Cloud interface {
Desired(ctx context.Context) ([]Desired, error)
Report(ctx context.Context, rep Report) error
// UploadSnapshot puts a JPEG in object storage and names it. Used for the
// placement check's proof picture, which is transient.
UploadSnapshot(ctx context.Context, jpeg []byte) (key string, err error)
// PutSnapshot gets a camera's latest frame to head office by whichever
// route this deployment has - the bucket, or the server itself when there
// is none. An empty key means the server already stored it, so the state
// report has nothing to carry.
PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (key string, err error)
}
// Local is a camera as the engine holds it.
@@ -252,7 +259,9 @@ func (s *Syncer) reportWith(ctx context.Context, adopt []Desired) {
// camera is down matters far more than having a picture of it,
// and the picture is the part most likely to fail.
if jpeg, err := s.Engine.Snapshot(ctx, c.ID); err == nil && len(jpeg) > 0 {
if key, err := s.Cloud.UploadSnapshot(ctx, jpeg); err == nil {
// An empty key is not a failure: it means this deployment has
// no object storage and the server stored the picture itself.
if key, err := s.Cloud.PutSnapshot(ctx, c.ID, jpeg); err == nil {
st.SnapshotKey = key
} else {
s.logf("camera %s: snapshot upload failed: %v", c.ID, err)

View File

@@ -59,6 +59,10 @@ type fakeCloud struct {
reports []Report
uploads int
uploadErr error
// direct records the cameras whose picture went to the server itself
// rather than to object storage.
direct []string
directOnly bool
}
func (f *fakeCloud) Desired(context.Context) ([]Desired, error) {
@@ -68,6 +72,20 @@ func (f *fakeCloud) Report(_ context.Context, r Report) error {
f.reports = append(f.reports, r)
return nil
}
// PutSnapshot mirrors the real client: the bucket when there is one, the
// server itself when there is not.
func (f *fakeCloud) PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (string, error) {
if f.directOnly {
if f.uploadErr != nil {
return "", f.uploadErr
}
f.direct = append(f.direct, cameraID)
return "", nil
}
return f.UploadSnapshot(ctx, jpeg)
}
func (f *fakeCloud) UploadSnapshot(context.Context, []byte) (string, error) {
if f.uploadErr != nil {
return "", f.uploadErr
@@ -239,3 +257,28 @@ func TestAnEngineThatIsNotRunningIsNotAnError(t *testing.T) {
t.Fatal("reported state it could not have observed")
}
}
// A deployment with no object storage must still get its picture to head
// office. Before this, the camera screen said "This system is not storing
// images" for every camera, forever - on the one screen whose entire job is to
// show the camera.
func TestASnapshotStillReachesHeadOfficeWithNoObjectStorage(t *testing.T) {
e := newEngine(Local{ID: "entrance", Connected: true})
c := &fakeCloud{directOnly: true}
syncer(e, c).Once(context.Background())
if len(c.direct) != 1 || c.direct[0] != "entrance" {
t.Fatalf("the picture did not reach the server: %v", c.direct)
}
if len(c.reports) == 0 || len(c.reports[0].State) != 1 {
t.Fatalf("no state was reported: %+v", c.reports)
}
// Empty, and that is the point: there is no object to name. A key here
// would have head office try to presign a bucket it does not have.
if key := c.reports[0].State[0].SnapshotKey; key != "" {
t.Fatalf("the direct route reported an object key %q", key)
}
if !c.reports[0].State[0].Connected {
t.Error("connected state was lost")
}
}

View File

@@ -4,12 +4,15 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/loyaly/behavision-agent/pkg/bridge"
)
// EngineClient talks to the recognition engine on this PC's loopback.
@@ -192,6 +195,64 @@ func (c *CloudClient) UploadSnapshot(ctx context.Context, jpeg []byte) (string,
return c.Upload(ctx, jpeg)
}
// PutSnapshot gets a camera's latest frame to head office by whichever route
// that deployment has.
//
// The bucket first: a presigned PUT goes straight to object storage and never
// passes through the API, which is what makes it the right route at estate
// scale. When there is no bucket the picture goes to the server itself, which
// stores one row per camera. Without this second route head office reported
// "This system is not storing images" for every camera forever, on the screen
// whose entire job is to show the camera.
//
// The returned key is empty for the direct route - there is no object to name -
// and the server records the picture as it stores it, so the state report has
// nothing to carry.
func (c *CloudClient) PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (string, error) {
if c.Upload != nil {
key, err := c.Upload(ctx, jpeg)
if err == nil {
return key, nil
}
// A bucket that is configured here but disabled at the server is the
// ordinary case on a self-hosted install: fall through rather than
// giving up, and let the direct route decide.
if !isImagesDisabled(err) {
return "", err
}
}
return "", c.putSnapshotDirect(ctx, cameraID, jpeg)
}
func (c *CloudClient) putSnapshotDirect(ctx context.Context, cameraID string, jpeg []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
c.Base+"/api/agent/cameras/"+url.PathEscape(cameraID)+"/snapshot",
bytes.NewReader(jpeg))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "image/jpeg")
resp, err := c.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("head office: %s", resp.Status)
}
return nil
}
// isImagesDisabled recognises the server saying it has no object storage.
//
// A sentinel, not a string match on the message: this decides whether to take a
// completely different route, and getting it wrong from prose that somebody
// later rewords would silently stop every camera picture in the estate.
func isImagesDisabled(err error) bool {
return errors.Is(err, bridge.ErrImagesOff)
}
// ---------------------------------------------------------------- probing
// Test opens the candidate stream once, without saving it.