package api import ( "errors" "fmt" "io" "net/http" "strconv" "time" ) // Face images held by this server, for a deployment with no object storage. // // Where a bucket IS configured nothing here is used: the agent keeps asking for // a presigned URL and the reader keeps getting a signed link, which never puts // a photograph through this process at all and is the right route at estate // scale. This is the fallback that stops "no S3 account" from meaning "no // customer photo, ever", which is what every local install and every // self-hosted customer got - including on the mobile arrivals feed, whose whole // job is to put a face in front of somebody. // // Migration 011 carries the argument for why this is bounded and therefore safe // to keep in Postgres when per-visit images are not: one row survives per // customer, so it grows with the customer base and not with footfall. // maxFaceBytes caps one upload. The engine writes ~20 KB crops; 2 MB is // generous for a large one and small enough that a misbehaving agent cannot use // this as free storage. const maxFaceBytes = 2 << 20 // faceMaxAge is how long a client may reuse a face it has already fetched. // The image for a given key never changes - a newer view gets a new key - so // this is only bounded to keep a signed-out device from holding one for ever. const faceMaxAge = 5 * time.Minute // handlePutFace takes one face crop from a shop PC. // // The client and site come from the agent's own credential and are never read // off the request, so a shop PC physically cannot file an image under another // tenant - the same rule every other agent-authenticated write here follows. // // The response is a KEY, which the agent then puts on the queued visit exactly // as it does with a bucket object. That symmetry is deliberate: the two storage // routes differ in one hop and in nothing else, so the ingest path, the read // path and erasure all stay single implementations. func (s *Server) handlePutFace(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxFaceBytes+1)) if err != nil || len(body) > maxFaceBytes { writeErr(w, http.StatusRequestEntityTooLarge, "too_large", fmt.Sprintf("A face image must be under %d KB.", maxFaceBytes/1024)) return } if len(body) == 0 { badRequest(w, "the image is empty") return } // Checked against the bytes, never the Content-Type header. This endpoint // stores what it is handed and serves it back to a browser, 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 face image must be a JPEG") return } key, err := s.Store.PutVisitFace(r.Context(), ap.ClientID, ap.SiteID, body) if err != nil { s.serverError(w, "store face", err) return } writeJSON(w, http.StatusCreated, map[string]any{"key": key}) } // handleGetFace serves one back to a signed-in person. // // Session-authenticated rather than a signed link, and that is the same call // camera snapshots already made: there is no third party to delegate to - the // bytes are in our own database - and minting an unauthenticated URL so that a // plain could load it would add a way to reach a photograph of // somebody's customer with no session at all. // // The consequence is a real one and clients must handle it: a browser // cannot send an Authorization header, so the web app fetches this and hands // over an object URL. A mobile image view can attach the header directly. The // `auth` flag on every Image says which kind of URL it is holding. // // No audit row is written here. Every read of a face is recorded where the LINK // is handed out - the arrivals page writes one row per page, the customer // record one per look - and the two paths must not disagree about what counts // as a read. Recording the byte fetch as well would double-count the DB // deployment and leave the bucket deployment, whose bytes never touch this // server, counted once. func (s *Server) handleGetFace(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) img, err := s.Store.VisitFace(r.Context(), p.ClientID, faceKey(r.PathValue("id"))) if err != nil { writeErr(w, http.StatusNotFound, "no_image", "There is no photo here.") 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(faceMaxAge.Seconds()))) // A photograph of a customer must not travel to a third party in a Referer // header if this URL is ever rendered inside a page that links out. w.Header().Set("Referrer-Policy", "no-referrer") if _, err := w.Write(img); err != nil && !errors.Is(err, http.ErrHandlerTimeout) { s.logf("WARN write face: %v", err) } } // faceKey rebuilds the stored key from the id in the path. // // The route is `/api/faces/{id}.jpg` so a client can hand the URL to an image // view that decides what to do by extension, and the `.jpg` is presentation // rather than part of the key. func faceKey(id string) string { if n := len(id); n > 4 && id[n-4:] == ".jpg" { id = id[:n-4] } return dbKeyPrefix + id } // dbKeyPrefix mirrors store.DBKeyPrefix. Duplicated rather than imported // because this package must not depend on the concrete store - the whole point // of the Store interface - and it is a wire constant that changing on one side // alone would break loudly and immediately in the tests either way. const dbKeyPrefix = "db:" // isDBKey reports whether an image key names a row here rather than an object // in a bucket. func isDBKey(key string) bool { return len(key) > len(dbKeyPrefix) && key[:len(dbKeyPrefix)] == dbKeyPrefix } // faceURL is the path a client fetches for a stored face. func faceURL(key string) string { return "/api/faces/" + key[len(dbKeyPrefix):] + ".jpg" } // imageFor turns one stored image key into the Image a client receives. // // ONE function decides this, for every surface: the arrivals feed, the live // stream, the customer record. There are now two places an image can live and // four distinct reasons there may not be one, and the failure this avoids is // the one the shops screen already hit once - two surfaces computing the same // fact separately and disagreeing about it in front of a user. // // A missing photo is DATA, not an error. Images are off by default across the // whole product, so on most deployments every arrival legitimately has none; a // client that renders a failure state would show a screen of red for a system // working exactly as configured. The two absences are told apart because a shop // can act on one and not the other. func (s *Server) imageFor(key string) Image { switch { case key == "" && s.Blob == nil: return Image{Reason: "This system is not storing customer photos."} case key == "": return Image{Reason: "No photo was captured for this visit."} case isDBKey(key): // Held by this server. A relative URL that needs the caller's session - // see handleGetFace for why it is not a signed link - so it carries no // expiry: it is valid for exactly as long as the session is. return Image{Available: true, URL: faceURL(key), Auth: true} case s.Blob == nil: // A bucket key on a server with no bucket. Only reachable if object // storage was configured once and has since been removed, and it is // worth its own sentence: the photo exists somewhere and this // deployment can no longer reach it, which is a configuration problem // rather than a customer with no picture. return Image{Reason: "This server can no longer reach its image storage."} default: url, err := s.Blob.PresignGet(key, viewTTL) if err != nil { // Logged, never fatal. The visit is the number the customer pays // for; the photo is decoration on top of it. Same rule the agent // follows when an upload fails. s.logf("ERROR presign image: %v", err) return Image{Reason: "That photo could not be loaded."} } return Image{Available: true, URL: url, ExpiresIn: int(viewTTL.Seconds())} } }