package api import ( "net/http" "strings" "time" "github.com/loyaly/behavision-server/internal/auth" ) // agentAuthed authenticates a store PC by its own API token. // // Deliberately a separate middleware from authed(): an agent has no user, no // role and no session, and folding it into the person path would mean one set // of permission checks answering two very different questions about who is // asking. func (s *Server) agentAuthed(next func(http.ResponseWriter, *http.Request, AgentPrincipal)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tok := auth.BearerToken(r) if tok == "" { unauthorized(w, "this endpoint is for a Behavision agent") return } ap, err := s.Store.AgentByToken(r.Context(), auth.HashToken(tok)) if err != nil { unauthorized(w, "this agent is not enrolled") return } next(w, r, ap) } } // handleUploadURL hands a store PC permission to write exactly one object. // // The shop PC never holds bucket credentials. That is not belt-and-braces: the // bucket is shared with another application and is world-readable at the bucket // level, so a full key on a machine that sits on a shop counter would expose // far more than this product's own data. A stolen PC gives up, at most, a few // minutes of write access to one key it was already going to write. func (s *Server) handleUploadURL(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) { if s.Blob == nil { // Not an error the agent should retry against: images are simply off // for this deployment, and it should carry on sending visits without // one rather than queueing failures. writeErr(w, http.StatusNotImplemented, "images_disabled", "This server is not configured to store images.") return } // The KEY is built here, from the credential the request authenticated // with. Accepting a caller-supplied key would let one site overwrite // another's images, which is the whole reason this endpoint exists instead // of a shared bucket password. key := s.Blob.Key(ap.Client, ap.Site, newObjectID(), s.now()) url, hdr, err := s.Blob.PresignPut(key, uploadTTL) if err != nil { s.serverError(w, "presign upload", err) return } // Lower-cased deliberately. SigV4 signs header names in lower case, and // this map is a wire contract that a non-Go client will copy literally - // http.Header's canonical "X-Amz-Acl" would send them looking for a // mismatch that only exists in Go's map keys. headers := map[string]string{} for k := range hdr { headers[strings.ToLower(k)] = hdr.Get(k) } writeJSON(w, http.StatusOK, UploadTarget{ Key: key, URL: url, Headers: headers, ExpiresIn: int(uploadTTL.Seconds()), }) } const ( // Long enough for a slow shop connection to finish a 30 KB JPEG, short // enough that a URL captured in a log is worthless by the time anyone // reads it. uploadTTL = 10 * time.Minute // Read URLs end up in browser history, screenshots and support tickets. viewTTL = 15 * time.Minute ) // handleVisitorImage returns a short-lived link to a customer's most recent // face image. // // A link that expires, never a stored URL: "delete my data" has to mean the // link stops working, not that we stop publishing it. func (s *Server) handleVisitorImage(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) id, ok := s.resolveVisitor(w, r, r.PathValue("id")) if !ok { return } key, err := s.Store.VisitorImageKey(r.Context(), p.ClientID, id) if err != nil { key = "" } // The same function every other surface uses. Two ways to answer "where is // this person's photo" would eventually answer differently, and the one // that mattered would be whichever the customer was looking at. img := s.imageFor(key) if !img.Available { // Absence, with the reason. `no_image` and `images_disabled` are // separate codes because the desktop and mobile clients act on them // differently: one is a customer with no picture yet, the other is a // deployment that stores none and should stop asking. code := "no_image" if key == "" && s.Blob == nil { code = "images_disabled" } writeErr(w, http.StatusNotFound, code, img.Reason) return } // Every read of a face image is worth a row. If a client asks "who looked // at my customers", an audit trail is the only answer that is not a guess. // Recorded HERE, where the link is handed out, for both storage routes - // the bucket's bytes never touch this server, so the fetch itself is not a // place both paths could be counted. s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "image.view", Entity: "visitor", EntityID: id, }) writeJSON(w, http.StatusOK, img) } // handleForgetVisitor is the erasure path. // // It destroys the biometric template and the face image outright, and keeps // only what is genuinely aggregate: the visit rows stay so a shop's past // footfall does not silently change, but they no longer point at a person, a // name or a picture. // // The images go FIRST. If the database transaction commits and the object // delete then fails, the keys are gone and nothing knows which files to remove // - the image outlives the erasure request with no record that it should not. func (s *Server) handleForgetVisitor(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if !p.CanManageSites() { writeErr(w, http.StatusForbidden, "forbidden", "Your account cannot delete customer records.") return } id, ok := s.resolveVisitor(w, r, r.PathValue("id")) if !ok { return } keys, err := s.Store.VisitorImageKeys(r.Context(), p.ClientID, id) if err != nil { s.serverError(w, "list images for erasure", err) return } // Images this server holds itself. Deleted before the row, for the same // reason the bucket objects are: if the database commits first and this // fails, the keys are gone and nothing knows which images to remove. if err := s.Store.DeleteVisitFaces(r.Context(), p.ClientID, keys); err != nil { s.logf("ERROR erasure %s: cannot delete stored faces: %v", id, err) writeErr(w, http.StatusBadGateway, "storage_error", "The photo could not be deleted, so nothing was erased. "+ "Please try again.") return } if s.Blob != nil { for _, key := range keys { if isDBKey(key) { continue // already gone, above } if err := s.Blob.Delete(r.Context(), key); err != nil { // Refuse the whole request. Reporting an erasure as done while // a face image is still in the bucket is the one outcome this // endpoint must never produce. s.logf("ERROR erasure %s: cannot delete %s: %v", id, key, err) writeErr(w, http.StatusBadGateway, "storage_error", "The photo could not be deleted, so nothing was erased. "+ "Please try again.") return } } } if err := s.Store.ForgetVisitor(r.Context(), p.ClientID, id); err != nil { if strings.Contains(err.Error(), "no such visitor") { writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.") return } s.serverError(w, "forget visitor", err) return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "visitor.forget", Entity: "visitor", EntityID: id, Detail: map[string]any{"images_deleted": len(keys)}, }) s.logf("erasure: visitor %s for client %s, %d image(s) deleted", id, p.ClientID, len(keys)) w.WriteHeader(http.StatusNoContent) }