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 }