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
181 lines
6.4 KiB
Go
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)
|
|
}
|