Files
Behavision/server/internal/api/handlers_cameras.go
Suriyakumarvijayanayagam 9062d2fc51 A shop filter that did not filter handed back the whole tenant
GET /api/cameras read only site_id, while every other filtered endpoint
takes both spellings through siteParam. So ?site=chennai was not a
filter at all but an unknown query parameter, silently ignored, and the
caller got every camera in the tenant believing it had one shop's.

Found by using it: a setup script saw another shop's cameras, concluded
three shops already had theirs and created none; then a delete aimed at
a test shop removed the live Coimbatore entrance camera, which had to be
restored. This is exactly the hazard already recorded for site vs
site_id - the note existed, the handler was simply missed.

One line to fix, and a test that asserts the whole class rather than
this one route: both spellings must narrow, and only an absent filter
may return more than one shop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:28:02 +05:30

298 lines
10 KiB
Go

package api
import (
"errors"
"net/http"
"strings"
"time"
)
// Cameras, onboarded from head office instead of from the shop floor.
//
// The shop PC remains the thing that CONNECTS to a camera - it is on the same
// LAN and nothing else can be - so these routes write desired state that the
// agent pulls and applies. Two audiences, two shapes: a tenant never receives
// a camera password, and an agent receives one only for its own site.
// A snapshot is refreshed every minute or so, so a link outliving that is
// pointless; short enough that one in a screenshot is worthless by the time
// anyone reads it.
const snapshotTTL = 5 * time.Minute
func (s *Server) handleCameras(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
// siteParam, not Query().Get("site_id"): every other filtered endpoint
// takes both spellings, and this one took only the longer. `?site=chennai`
// was therefore not a filter but an unknown parameter, silently ignored -
// so a caller asking for one shop's cameras was handed the whole tenant's.
// Measured: it made a script skip creating cameras for three shops because
// another shop's already existed, and deleted a camera from the wrong shop.
siteID := siteParam(r)
if siteID != "" {
var ok bool
if siteID, ok = s.resolveSiteFilter(w, r, siteID); !ok {
return
}
}
cams, err := s.Store.Cameras(r.Context(), p.ClientID, siteID)
if err != nil {
s.serverError(w, "cameras", err)
return
}
if cams == nil {
cams = []Camera{}
}
s.attachSnapshots(cams)
writeJSON(w, http.StatusOK, cams)
}
// attachSnapshots swaps each camera's object key for a signed link.
//
// A snapshot is a frame of a shop floor, so it gets the same treatment as a
// face: a short-lived signed URL, never a stored one. Absence is data - most
// deployments store no images at all, and a camera that is merely new has no
// frame yet.
func (s *Server) attachSnapshots(cams []Camera) {
for i := range cams {
key := cams[i].Snapshot.Key
cams[i].Snapshot.Key = ""
switch {
case key == "" && cams[i].SnapshotAt != "":
// Held by this server, because the deployment has no object
// storage. Served from an endpoint rather than a signed link:
// there is no third party to delegate to, the bytes are in our own
// database, and an unauthenticated URL to somebody's shop floor
// would be a new way in for no gain.
cams[i].Snapshot = Image{
Available: true,
URL: "/api/cameras/" + cams[i].ID + "/snapshot.jpg",
ExpiresIn: int(snapshotTTL.Seconds()),
// Says out loud that this URL needs the session's bearer.
// Clients used to infer it from the URL being relative, which
// is true today and stops being true the first time object
// storage is served from this same host.
Auth: true,
}
case key == "":
cams[i].Snapshot.Reason = "No picture from this camera yet."
case s.Blob == nil:
// A key from a bucket this server can no longer reach. Distinct
// from "no picture yet": one is waiting, the other is misconfigured.
cams[i].Snapshot.Reason = "This system is not storing images."
default:
url, err := s.Blob.PresignGet(key, snapshotTTL)
if err != nil {
s.logf("ERROR presign snapshot: %v", err)
cams[i].Snapshot.Reason = "That picture could not be loaded."
continue
}
cams[i].Snapshot = Image{Available: true, URL: url,
ExpiresIn: int(snapshotTTL.Seconds())}
}
}
}
func (s *Server) handleCreateCamera(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot change camera settings.")
return
}
siteID, ok := s.resolveSite(w, r, r.PathValue("site"))
if !ok {
return
}
var in CameraInput
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
id := ""
if in.CameraID != nil {
id = cameraSlug(*in.CameraID)
}
if id == "" && in.Label != nil {
id = cameraSlug(*in.Label)
}
if id == "" {
badRequest(w, "give the camera a name, such as Entrance")
return
}
if in.Label == nil || trim(*in.Label) == "" {
in.Label = &id
}
if msg, ok := cameraProblem(in); !ok {
badRequest(w, msg)
return
}
s.saveCamera(w, r, p.ClientID, siteID, id, in, http.StatusCreated)
}
func (s *Server) handleUpdateCamera(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot change camera settings.")
return
}
id, ok := s.resolveCamera(w, r, r.PathValue("id"))
if !ok {
return
}
existing, err := s.Store.CameraByID(r.Context(), p.ClientID, id)
if err != nil {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
var in CameraInput
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
// The camera id is what visits are recorded against. Renaming it would
// orphan every visit already attributed to the old name, so the label is
// the thing an operator may change.
in.CameraID = nil
s.saveCamera(w, r, p.ClientID, existing.SiteID, existing.CameraID, in, http.StatusOK)
}
func (s *Server) saveCamera(w http.ResponseWriter, r *http.Request,
clientID, siteID, cameraID string, in CameraInput, code int) {
p := PrincipalFrom(r.Context())
cam, err := s.Store.SaveCamera(r.Context(), clientID, siteID, cameraID, in)
if err != nil {
if errors.Is(err, ErrNoSecrets) {
// A camera saved with its password silently dropped is a camera
// that will not connect, and the operator could not tell that from
// a wrong password.
writeErr(w, http.StatusServiceUnavailable, "no_secret_key", err.Error())
return
}
if strings.Contains(err.Error(), "no rows") {
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
return
}
s.serverError(w, "save camera", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: clientID, ActorID: p.UserID, ActorKind: "user",
Action: "camera.save", Entity: "camera", EntityID: cam.ID,
Detail: map[string]any{"camera_id": cam.CameraID, "site_id": siteID},
})
// Through the slice, not around it. `attachSnapshots([]Camera{cam})` would
// decorate a COPY and then serialise the untouched original, so a created
// camera came back with an empty snapshot object and no reason - the one
// field whose whole job is to say why there is no picture.
cams := []Camera{cam}
s.attachSnapshots(cams)
writeJSON(w, code, cams[0])
}
func (s *Server) handleDeleteCamera(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot change camera settings.")
return
}
id, ok := s.resolveCamera(w, r, r.PathValue("id"))
if !ok {
return
}
cam, err := s.Store.DeleteCamera(r.Context(), p.ClientID, id)
if err != nil {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "camera.delete", Entity: "camera", EntityID: cam.ID,
Detail: map[string]any{"camera_id": cam.CameraID},
})
w.WriteHeader(http.StatusNoContent)
}
// ------------------------------------------------------------------ agent
// handleAgentCameras is the shop PC asking what it should be running.
//
// Authenticated by the agent's own token, and scoped to that agent's site by
// the credential rather than by anything in the request - a site id a caller
// could set would hand one shop another shop's camera passwords.
func (s *Server) handleAgentCameras(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
cams, err := s.Store.AgentCameras(r.Context(), ap.SiteID)
if err != nil {
s.serverError(w, "agent cameras", err)
return
}
if cams == nil {
cams = []AgentCamera{}
}
writeJSON(w, http.StatusOK, map[string]any{"cameras": cams})
}
// handleAgentCameraReport records what the shop PC observes and adopts any
// camera it is running that head office does not know about.
func (s *Server) handleAgentCameraReport(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
var rep AgentCameraReport
if err := decode(w, r, &rep); err != nil {
badRequest(w, err.Error())
return
}
for i := range rep.Adopt {
// Slugged here as well as on the operator path. An agent is trusted to
// report its own site, not to choose an identifier that could collide
// with a topic segment or a path.
rep.Adopt[i].CameraID = cameraSlug(rep.Adopt[i].CameraID)
}
// ClientID, not Client. AgentPrincipal carries both the tenant's uuid and
// its human slug, and the slug is the one that reads correctly in a log
// line - which is exactly why it gets used by mistake in a query that wants
// the uuid.
if err := s.Store.ApplyAgentReport(r.Context(), ap.ClientID, ap.SiteID, rep); err != nil {
s.serverError(w, "agent camera report", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------- helpers
// cameraSlug normalises the id the engine will know this camera by.
//
// It ends up in an object key, a URL path and a topic segment, so it is
// restricted to characters that cannot change what any of those mean.
func cameraSlug(s string) string {
var b strings.Builder
lastDash := true
for _, r := range strings.ToLower(trim(s)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastDash = false
case !lastDash:
b.WriteByte('-')
lastDash = true
}
}
return clip(strings.Trim(b.String(), "-"), 64)
}
// cameraProblem rejects a camera that cannot possibly connect, with the
// sentence an operator needs rather than a validation code.
func cameraProblem(in CameraInput) (string, bool) {
if in.Host == nil || trim(*in.Host) == "" {
return "the camera needs an address on the shop's network, such as 192.168.0.138", false
}
if in.Port != nil && (*in.Port < 1 || *in.Port > 65535) {
return "the port must be between 1 and 65535 - RTSP cameras are usually 554", false
}
if in.MaxWidth != nil && *in.MaxWidth < 320 {
return "frames narrower than 320 pixels are too small to recognise a face in", false
}
return "", true
}