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
298 lines
10 KiB
Go
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
|
|
}
|