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())}
}
}