Files
Behavision/server/internal/api/handlers_images.go
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 11:14:18 +05:30

181 lines
6.4 KiB
Go

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 := r.PathValue("id")
if !looksLikeUUID(id) {
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
return
}
if s.Blob == nil {
writeErr(w, http.StatusNotFound, "images_disabled",
"This server does not store images.")
return
}
key, err := s.Store.VisitorImageKey(r.Context(), p.ClientID, id)
if err != nil || key == "" {
writeErr(w, http.StatusNotFound, "no_image",
"There is no photo for this customer.")
return
}
url, err := s.Blob.PresignGet(key, viewTTL)
if err != nil {
s.serverError(w, "presign read", err)
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.
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, map[string]any{
"url": url, "expires_in": int(viewTTL.Seconds()),
})
}
// 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 := r.PathValue("id")
if !looksLikeUUID(id) {
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
return
}
keys, err := s.Store.VisitorImageKeys(r.Context(), p.ClientID, id)
if err != nil {
s.serverError(w, "list images for erasure", err)
return
}
if s.Blob != nil {
for _, key := range keys {
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)
}