Two changes, and the second was found by verifying the first. ## Watching a camera from the app, in another building Snapshots answer "is that camera working". They do not answer "what is happening in my shop right now", which is what somebody who opens the app away from the counter is asking. Head office's browser already had that answer - LiveHub plus cameras.Live, where the shop PC asks outbound whether anybody is watching and pushes JPEG frames for as long as somebody is - and the app could not reach it. cloud.CameraLive opens that feed and the app's own loopback relay re-emits it as multipart MJPEG. That is the trick: frames arrive base64 over SSE, an <img> cannot render that, and an <img> renders MJPEG natively - so a tile is an ordinary <img> pointed at loopback whether the camera is in this room or another city. - Reconnecting happens in the relay, not the page. The server caps one push at five minutes, so doing it here means the <img> never sees the stream end. - The headers are flushed before the first frame. Go writes them on the first body write, so without that the whole response waits for the shop PC to start pushing. Measured against production: 30 seconds and not even a Content-Type, which surfaces as the request timing out. - One camera at a time. Watching makes a shop PC upload, so a grid that went live at once would put an estate's worth of cameras on the wire because somebody opened a page. - live.mjpeg is behind the same per-run token as the engine routes, and a wrong token is a 404 that never reaches head office at all. - CameraLive uses its own HTTP client: the shared one's 30s timeout covers the whole response and would sever a working view every thirty seconds - the trap that made the server set WriteTimeout to zero for its own SSE endpoint. ## A camera read "Connected" for 34 minutes after the shop PC went blind Which is why the verification above looked like a failure: head office registered the viewer and no frame ever came. reportWith returns early when the engine is unreachable - correctly, it has nothing to say - so the last state it sent stays in the database looking current. Measured live: cam2 and entrance both reading Connected, in green, with last_seen_at 34 minutes old, while the heartbeat from the same PC said cameras_up 0 of 0. Two surfaces reading two stored fields and disagreeing. false could not be the answer. It means "this camera is not connecting", which sends an installer to check cabling on a camera that was working perfectly the last time anybody could ask it. So there are four states and one function: connected reported recently, and working not_connecting reported recently, and the stream will not open waiting no shop PC has ever reported this camera stale reported once, and not lately - Connected is CLEARED when stale or waiting. A stale true left in place stays available to every client reading the field directly, and leaves two fields on one object disagreeing - how the shops screen once came out labelled Working, in green, above "2 of 3 cameras not connecting". - Computed in scanCamera, so every camera anybody reads passes through it. A state computed per handler is one a handler forgets, and this had already reached three screens. - CameraStaleAfter is 5 minutes: five missed reports, not one. Same reasoning as three missed heartbeats - an indicator that cries wolf gets ignored. - An unparseable last_seen_at is stale. It should be impossible, which is why it must not fall through to the state that says everything is fine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
297 lines
11 KiB
Go
297 lines
11 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/loyaly/behavision-server/internal/api"
|
|
)
|
|
|
|
// ErrNoSecrets is the API package's sentinel, aliased rather than redeclared.
|
|
//
|
|
// Two variables with the same text would compare unequal under errors.Is, so
|
|
// the handler's check would silently fall through to a 500 - the failure this
|
|
// error exists to replace with a sentence an operator can act on.
|
|
var ErrNoSecrets = api.ErrNoSecrets
|
|
|
|
const cameraCols = `
|
|
c.id::text, c.site_id::text, si.name, c.camera_id, c.label,
|
|
c.host, c.port, c.path, c.username, (c.password_enc IS NOT NULL),
|
|
c.max_width, c.tuning, c.enabled, c.revision,
|
|
c.connected, c.last_seen_at, c.snapshot_key, c.snapshot_at,
|
|
c.check_kind, c.check_requested_at, c.check_started_at, c.check_finished_at,
|
|
c.check_seconds, c.check_result, c.check_image_key`
|
|
|
|
func scanCamera(row pgx.Row) (api.Camera, error) {
|
|
var c api.Camera
|
|
var lastSeen, snapAt *time.Time
|
|
var snapKey string
|
|
var checkKind *string
|
|
var reqAt, startAt, finAt *time.Time
|
|
var checkSeconds int
|
|
var checkResult []byte
|
|
var checkImage string
|
|
if err := row.Scan(&c.ID, &c.SiteID, &c.Site, &c.CameraID, &c.Label,
|
|
&c.Host, &c.Port, &c.Path, &c.Username, &c.HasPassword,
|
|
&c.MaxWidth, &c.Tuning, &c.Enabled, &c.Revision,
|
|
&c.Connected, &lastSeen, &snapKey, &snapAt,
|
|
&checkKind, &reqAt, &startAt, &finAt,
|
|
&checkSeconds, &checkResult, &checkImage); err != nil {
|
|
return c, err
|
|
}
|
|
kind := ""
|
|
if checkKind != nil {
|
|
kind = *checkKind
|
|
}
|
|
// Carried on the camera rather than fetched separately: "is this camera set
|
|
// up" and "has anyone proved it works" are the same question to the person
|
|
// asking, and two requests to answer it is two chances for the screen to
|
|
// show a camera and its verdict from different moments.
|
|
c.Check = checkOf(kind, reqAt, startAt, finAt, checkSeconds, checkResult, checkImage)
|
|
if lastSeen != nil {
|
|
c.LastSeenAt = lastSeen.UTC().Format(time.RFC3339)
|
|
}
|
|
if snapAt != nil {
|
|
c.SnapshotAt = snapAt.UTC().Format(time.RFC3339)
|
|
}
|
|
// The KEY travels in ImageKey, which is json:"-", and the handler swaps it
|
|
// for a signed link. Same rule as an arrival's face.
|
|
c.Snapshot.Key = snapKey
|
|
// Here rather than in a handler, because every camera anybody reads comes
|
|
// through this function and a state computed per caller is a state one
|
|
// caller forgets - which is how a stale `connected` reached three screens
|
|
// at once.
|
|
c.CameraState(time.Now())
|
|
return c, nil
|
|
}
|
|
|
|
// Cameras lists a tenant's cameras, optionally for one site.
|
|
//
|
|
// Never returns a password, and structurally cannot: the column is not in the
|
|
// select list at all, only whether it is set.
|
|
func (s *Store) Cameras(ctx context.Context, clientID, siteID string) ([]api.Camera, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT `+cameraCols+`
|
|
FROM site_cameras c
|
|
JOIN sites si ON si.id = c.site_id
|
|
WHERE c.client_id = $1 AND c.deleted_at IS NULL
|
|
AND ($2 = '' OR c.site_id = $2::uuid)
|
|
ORDER BY si.name, c.camera_id`, clientID, siteID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []api.Camera
|
|
for rows.Next() {
|
|
c, err := scanCamera(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// SaveCamera creates or updates one camera and bumps its revision.
|
|
//
|
|
// The revision bump is what makes the agent's reconcile cheap: it compares one
|
|
// integer instead of diffing every field, so a sync on an unchanged site costs
|
|
// a single query and no engine calls.
|
|
func (s *Store) SaveCamera(ctx context.Context, clientID, siteID, cameraID string,
|
|
in api.CameraInput) (api.Camera, error) {
|
|
|
|
var out api.Camera
|
|
if in.Password != nil && *in.Password != "" && s.secrets == nil {
|
|
return out, ErrNoSecrets
|
|
}
|
|
|
|
// Site must belong to this tenant. Checked in SQL rather than trusted from
|
|
// the path: a site id is caller-supplied and this would otherwise write a
|
|
// camera into somebody else's shop.
|
|
var owns bool
|
|
if err := s.pool.QueryRow(ctx,
|
|
`SELECT EXISTS (SELECT 1 FROM sites WHERE id = $1::uuid AND client_id = $2::uuid)`,
|
|
siteID, clientID).Scan(&owns); err != nil {
|
|
return out, err
|
|
}
|
|
if !owns {
|
|
return out, pgx.ErrNoRows
|
|
}
|
|
|
|
var sealed []byte
|
|
if in.Password != nil && *in.Password != "" {
|
|
// Sealed with the SITE id as additional data, so a row copied between
|
|
// sites in the database does not decrypt into a working credential.
|
|
b, err := s.secrets.SealString(*in.Password, siteID)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
sealed = b
|
|
}
|
|
|
|
// COALESCE on every field: a nil pointer means "leave this alone". An
|
|
// operator editing a label must not blank the password, and the form does
|
|
// not send one because the API never gave it back.
|
|
row := s.pool.QueryRow(ctx, `
|
|
INSERT INTO site_cameras (client_id, site_id, camera_id, label, host, port,
|
|
path, username, password_enc, max_width, tuning, enabled)
|
|
VALUES ($1::uuid, $2::uuid, $3,
|
|
COALESCE($4, ''), COALESCE($5, ''), COALESCE($6, 554),
|
|
COALESCE($7, '/'), COALESCE($8, ''), $9,
|
|
COALESCE($10, 1280), COALESCE($11, '{}'::jsonb), COALESCE($12, true))
|
|
ON CONFLICT (site_id, camera_id) DO UPDATE SET
|
|
label = COALESCE($4, site_cameras.label),
|
|
host = COALESCE($5, site_cameras.host),
|
|
port = COALESCE($6, site_cameras.port),
|
|
path = COALESCE($7, site_cameras.path),
|
|
username = COALESCE($8, site_cameras.username),
|
|
password_enc = COALESCE($9, site_cameras.password_enc),
|
|
max_width = COALESCE($10, site_cameras.max_width),
|
|
tuning = COALESCE($11, site_cameras.tuning),
|
|
enabled = COALESCE($12, site_cameras.enabled),
|
|
revision = site_cameras.revision + 1,
|
|
updated_at = now(),
|
|
-- Re-saving a deleted camera revives it. An operator adding back a
|
|
-- camera they removed should get their camera, not a unique-key
|
|
-- error about a row they cannot see.
|
|
deleted_at = NULL
|
|
RETURNING id`, clientID, siteID, cameraID,
|
|
in.Label, in.Host, in.Port, in.Path, in.Username, sealed,
|
|
in.MaxWidth, in.Tuning, in.Enabled)
|
|
|
|
var id string
|
|
if err := row.Scan(&id); err != nil {
|
|
return out, err
|
|
}
|
|
return s.CameraByID(ctx, clientID, id)
|
|
}
|
|
|
|
func (s *Store) CameraByID(ctx context.Context, clientID, id string) (api.Camera, error) {
|
|
return scanCamera(s.pool.QueryRow(ctx, `
|
|
SELECT `+cameraCols+`
|
|
FROM site_cameras c
|
|
JOIN sites si ON si.id = c.site_id
|
|
WHERE c.client_id = $1 AND c.id = $2::uuid AND c.deleted_at IS NULL`,
|
|
clientID, id))
|
|
}
|
|
|
|
// DeleteCamera tombstones a camera.
|
|
//
|
|
// A tombstone rather than a DELETE, because the agent adopts cameras it finds
|
|
// configured on the shop PC. A hard delete here would be undone on the next
|
|
// sync by the very camera the operator just removed - and they would have no
|
|
// idea why it kept coming back.
|
|
func (s *Store) DeleteCamera(ctx context.Context, clientID, id string) (api.Camera, error) {
|
|
cam, err := s.CameraByID(ctx, clientID, id)
|
|
if err != nil {
|
|
return cam, err
|
|
}
|
|
_, err = s.pool.Exec(ctx, `
|
|
UPDATE site_cameras
|
|
SET deleted_at = now(), revision = revision + 1, updated_at = now()
|
|
WHERE client_id = $1 AND id = $2::uuid`, clientID, id)
|
|
return cam, err
|
|
}
|
|
|
|
// AgentCameras is the desired configuration for one site, WITH passwords.
|
|
//
|
|
// The only route that decrypts them, and it is reachable only with that site's
|
|
// own agent token. Deleted cameras are included, flagged: the agent cannot
|
|
// distinguish "head office removed this" from "head office has not seen this
|
|
// yet" by absence, and would re-adopt what was just deleted.
|
|
func (s *Store) AgentCameras(ctx context.Context, siteID string) ([]api.AgentCamera, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id::text, camera_id, label, host, port, path, username, password_enc,
|
|
max_width, tuning, enabled, revision, (deleted_at IS NOT NULL)
|
|
FROM site_cameras
|
|
WHERE site_id = $1::uuid
|
|
ORDER BY camera_id`, siteID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []api.AgentCamera
|
|
for rows.Next() {
|
|
var c api.AgentCamera
|
|
var sealed []byte
|
|
if err := rows.Scan(&c.ID, &c.CameraID, &c.Label, &c.Host, &c.Port, &c.Path,
|
|
&c.Username, &sealed, &c.MaxWidth, &c.Tuning, &c.Enabled,
|
|
&c.Revision, &c.Deleted); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(sealed) > 0 && s.secrets != nil {
|
|
// A password that will not decrypt is sent as empty rather than
|
|
// failing the whole sync: one unreadable camera must not stop the
|
|
// other three being configured. The agent reports the connection
|
|
// failure, which is the symptom an operator can actually act on.
|
|
if pw, err := s.secrets.OpenString(sealed, siteID); err == nil {
|
|
c.Password = pw
|
|
} else {
|
|
s.auditFailed("camera password decrypt", err)
|
|
}
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ApplyAgentReport records what a shop PC observes, and adopts any camera it
|
|
// is running that head office does not know about.
|
|
//
|
|
// Adoption is what makes turning this on safe. Every existing site already has
|
|
// cameras configured locally - including the office camera this was tested with
|
|
// - and a reconcile that only pushed downwards would delete all of them on
|
|
// first sync.
|
|
func (s *Store) ApplyAgentReport(ctx context.Context, clientID, siteID string,
|
|
rep api.AgentCameraReport) error {
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck
|
|
|
|
for _, cam := range rep.Adopt {
|
|
var sealed []byte
|
|
if cam.Password != "" && s.secrets != nil {
|
|
if b, err := s.secrets.SealString(cam.Password, siteID); err == nil {
|
|
sealed = b
|
|
}
|
|
}
|
|
// DO NOTHING on conflict, deliberately. Adoption must never overwrite
|
|
// head office's configuration with what the shop PC happens to hold -
|
|
// that would make an edit here silently revert on the next sync. It
|
|
// only fills in cameras nobody has configured centrally, tombstones
|
|
// included, so a deleted camera stays deleted.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO site_cameras (client_id, site_id, camera_id, label, host,
|
|
port, path, username, password_enc,
|
|
max_width, tuning, enabled)
|
|
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10,
|
|
COALESCE($11, '{}'::jsonb), $12)
|
|
ON CONFLICT (site_id, camera_id) DO NOTHING`,
|
|
clientID, siteID, cam.CameraID, cam.Label, cam.Host, cam.Port,
|
|
cam.Path, cam.Username, sealed, cam.MaxWidth, cam.Tuning,
|
|
cam.Enabled); err != nil {
|
|
return fmt.Errorf("adopt %q: %w", cam.CameraID, err)
|
|
}
|
|
}
|
|
|
|
for _, st := range rep.State {
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE site_cameras
|
|
SET connected = $3, last_seen_at = now(),
|
|
snapshot_key = CASE WHEN $4 = '' THEN snapshot_key ELSE $4 END,
|
|
snapshot_at = CASE WHEN $4 = '' THEN snapshot_at ELSE now() END
|
|
WHERE site_id = $1::uuid AND camera_id = $2`,
|
|
siteID, st.CameraID, st.Connected, st.SnapshotKey); err != nil {
|
|
return fmt.Errorf("state %q: %w", st.CameraID, err)
|
|
}
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|