package store import ( "context" "errors" "fmt" "strings" "github.com/jackc/pgx/v5" ) // Face images held by this server, for a deployment with no object storage. // // The bucket stays primary wherever one is configured: a presigned PUT never // passes the bytes through the API at all, which is what makes it the right // route at estate scale. This is the fallback that stops "no S3 account" from // meaning "no photograph of any customer, ever" - see migration 011 for why it // is bounded and therefore safe to keep here. // DBKeyPrefix marks an image key that names a row in this database rather than // an object in a bucket. // // One column, `visits.image_key`, names either. A prefix rather than a second // nullable column because every read already has the key in hand and can tell // which store to ask without a further lookup - and because a key that does not // say where it lives is a key some future caller will hand to the wrong one. const DBKeyPrefix = "db:" // ErrNoFace means there is no stored image under that key. Ordinary absence, // not a fault: most deployments store no faces at all. var ErrNoFace = errors.New("no such face image") // PutVisitFace stores one face crop and returns the key that names it. // // The client and site come from the AGENT'S credential, never from the request, // so a shop PC cannot file an image under another tenant. There is no visitor // id yet - the server has not matched the template at this point - so the row // is claimed later, by RecordVisit, and swept if that never happens. func (s *Store) PutVisitFace(ctx context.Context, clientID, siteID string, jpeg []byte) (string, error) { var id string err := s.pool.QueryRow(ctx, ` INSERT INTO visit_faces (client_id, site_id, image, bytes) VALUES ($1::uuid, $2::uuid, $3, $4) RETURNING id::text`, clientID, siteID, jpeg, len(jpeg)).Scan(&id) if err != nil { return "", fmt.Errorf("store face: %w", err) } return DBKeyPrefix + id, nil } // VisitFace reads one back, scoped to the tenant that is asking. // // The client id is in the WHERE clause and not merely checked afterwards: an // image key travels in an API response, and a caller who kept one from a // previous tenancy - or guessed one - must get nothing rather than a photograph // of somebody else's customer. func (s *Store) VisitFace(ctx context.Context, clientID, key string) ([]byte, error) { id, ok := strings.CutPrefix(key, DBKeyPrefix) if !ok || !looksLikeUUID(id) { return nil, ErrNoFace } var img []byte err := s.pool.QueryRow(ctx, ` SELECT image FROM visit_faces WHERE id = $1::uuid AND client_id = $2::uuid`, id, clientID).Scan(&img) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNoFace } if err != nil { return nil, fmt.Errorf("read face: %w", err) } return img, nil } // DeleteVisitFaces erases stored faces outright. // // Used by the erasure path, which must destroy the image rather than unlink it. // The rule the bucket path already follows applies unchanged: a face image that // survives an erasure request is the one outcome that endpoint must never // produce, so a failure here has to reach the caller. func (s *Store) DeleteVisitFaces(ctx context.Context, clientID string, keys []string) error { ids := make([]string, 0, len(keys)) for _, k := range keys { if id, ok := strings.CutPrefix(k, DBKeyPrefix); ok && looksLikeUUID(id) { ids = append(ids, id) } } if len(ids) == 0 { return nil } _, err := s.pool.Exec(ctx, ` DELETE FROM visit_faces WHERE client_id = $1::uuid AND id = ANY($2::uuid[])`, clientID, ids) if err != nil { return fmt.Errorf("delete faces: %w", err) } return nil } // pruneVisitorFaces keeps ONE stored face per visitor: the newest. // // This is what bounds the table to the customer base rather than to footfall, // and it is the whole reason face images may live in Postgres at all. It runs // inside RecordVisit's transaction, right after the visit is linked to a // person, so the superseded row and the key that named it disappear together. // // ONE statement, and that is not tidiness. The first version read the old keys // with `UPDATE visits SET image_key = ” ... RETURNING image_key` - which // returns the value AFTER the update, so every key came back as the empty // string it had just been set to, the delete list was always empty, and the // table grew with footfall exactly as if the prune did not exist. The visits // looked right; only the row count gave it away. A CTE cannot have that bug: // `doomed` reads the pre-image, and both the update and the delete are driven // from it. // // The old key is blanked rather than marked deleted. `image_deleted_at` means // an erasure was performed and is what an auditor reads; borrowing it to mean // "we kept a better photo" would put ordinary housekeeping into the record of // legal requests. func pruneVisitorFaces(ctx context.Context, tx pgx.Tx, clientID, visitorID, keepVisitID string) error { _, err := tx.Exec(ctx, ` WITH doomed AS ( SELECT v.id, v.image_key FROM visits v WHERE v.client_id = $1::uuid AND v.visitor_id = $2::uuid AND v.id <> $3::uuid AND v.image_key LIKE 'db:%' ), cleared AS ( UPDATE visits SET image_key = '' WHERE id IN (SELECT id FROM doomed) ) DELETE FROM visit_faces f WHERE f.client_id = $1::uuid -- Joined on the text form deliberately: the alternative is casting a -- substring of a stored key to uuid, which throws on a malformed row -- and would take an ordinary visit down with it. AND 'db:' || f.id::text IN (SELECT image_key FROM doomed)`, clientID, visitorID, keepVisitID) if err != nil { return fmt.Errorf("prune faces: %w", err) } return nil } // SweepOrphanFaces removes images no visit ever claimed. // // An agent uploads a face before the server has decided who it is, so a row is // briefly unreferenced by design. It stays that way for good if the visit that // would have claimed it never arrives - a dropped queue, a corrupt entry - and // that is one stored photograph of a real person that nothing points at and // nothing would ever delete. Erasure could not reach it either: it is found // through the visitor, and this row has none. func (s *Store) SweepOrphanFaces(ctx context.Context, olderThan string) (int, error) { tag, err := s.pool.Exec(ctx, ` DELETE FROM visit_faces f WHERE f.captured_at < now() - $1::interval AND NOT EXISTS ( SELECT 1 FROM visits v WHERE v.image_key = 'db:' || f.id::text)`, olderThan) if err != nil { return 0, fmt.Errorf("sweep faces: %w", err) } return int(tag.RowsAffected()), nil } func looksLikeUUID(s string) bool { if len(s) != 36 { return false } for i, c := range s { switch i { case 8, 13, 18, 23: if c != '-' { return false } default: isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') if !isHex { return false } } } return true }