package api import ( "errors" "fmt" "io" "net/http" "strconv" "time" ) // maxSnapshotBytes caps what a shop PC may store per camera. // // A camera frame downscaled to 1280 px is ~100 KB; 2 MB is generous for a // 4K still and small enough that a misbehaving or compromised agent cannot use // this endpoint as free storage. One row per camera means it cannot accumulate // either - the cap is about a single request, the primary key about the total. const maxSnapshotBytes = 2 << 20 // snapshotMaxAge is how long a browser may reuse a camera picture. The agent // refreshes them every 60 s, so anything longer shows a stale shop floor and // anything shorter re-fetches a picture that has not changed. const snapshotMaxAge = 30 * time.Second // handlePutSnapshot stores the latest frame from one of this site's cameras. // // This is the path for a deployment with NO object storage. Where a bucket is // configured the agent keeps using the presigned-URL route, which never puts a // picture through this process at all; both exist because they are right for // different deployments, not because one supersedes the other. // // The body is the JPEG itself rather than JSON with base64: it avoids a third // of the bytes and a decode step, and there is exactly one thing being sent. func (s *Server) handlePutSnapshot(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) { cameraID := r.PathValue("camera") if cameraID == "" { writeErr(w, http.StatusNotFound, "not_found", "No such camera.") return } // http.MaxBytesReader, not a Content-Length check: a length header is // whatever the client says it is, and this has to bound what is actually // read into memory. body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxSnapshotBytes+1)) if err != nil { writeErr(w, http.StatusRequestEntityTooLarge, "too_large", fmt.Sprintf("A snapshot must be under %d KB.", maxSnapshotBytes/1024)) return } if len(body) > maxSnapshotBytes { writeErr(w, http.StatusRequestEntityTooLarge, "too_large", fmt.Sprintf("A snapshot must be under %d KB.", maxSnapshotBytes/1024)) return } // Checked against the bytes, not the Content-Type header. This endpoint // stores whatever it is given and hands it back to a browser later, so the // one thing it must not become is a way to park arbitrary content under a // URL this server will serve. if !isJPEG(body) { badRequest(w, "a snapshot must be a JPEG") return } switch err := s.Store.PutCameraSnapshot(r.Context(), ap.ClientID, ap.SiteID, cameraID, body); { case err == nil: w.WriteHeader(http.StatusNoContent) case errors.Is(err, ErrNoSnapshot): // Head office has not adopted this camera yet. Not the agent's fault // and not worth retrying: the next sync adopts it. writeErr(w, http.StatusNotFound, "not_found", "That camera is not set up at head office yet.") default: s.serverError(w, "store snapshot", err) } } // handleGetSnapshot serves a camera's stored picture to a signed-in user. // // Deliberately NOT a signed link like the bucket path: there is no third party // to delegate to here, the bytes are in this server's own database, and minting // a URL that works without a session in order to serve them would be adding an // unauthenticated path to reach a picture of somebody's shop floor for no gain. func (s *Server) handleGetSnapshot(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) id := r.PathValue("id") if !looksLikeUUID(id) { writeErr(w, http.StatusNotFound, "not_found", "No such camera.") return } img, at, err := s.Store.CameraSnapshot(r.Context(), p.ClientID, id) if errors.Is(err, ErrNoSnapshot) { writeErr(w, http.StatusNotFound, "no_image", "No picture from this camera yet.") return } if err != nil { s.serverError(w, "read snapshot", err) return } w.Header().Set("Content-Type", "image/jpeg") w.Header().Set("Content-Length", strconv.Itoa(len(img))) w.Header().Set("Cache-Control", "private, max-age="+ strconv.Itoa(int(snapshotMaxAge.Seconds()))) w.Header().Set("Last-Modified", at.UTC().Format(http.TimeFormat)) // A picture of a shop floor is not something to hand to another origin's // script, and nothing here needs to. w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusOK) _, _ = w.Write(img) } // isJPEG checks the magic bytes: SOI marker at the front, EOI at the back. func isJPEG(b []byte) bool { if len(b) < 4 { return false } return b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF }