Files
Behavision/server/internal/api/handlers_images.go
Suriyakumarvijayanayagam 3f9fb33b24 Accounts people can create, and photos on a server with no bucket
A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.

Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.

Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.

Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.

Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.

Found by running it, not by tests:

  * UPDATE ... RETURNING gives the value AFTER the update, so the prune
    read back empty keys, deleted nothing, and the table grew with
    footfall exactly as if it were not there. The fake agreed with either
    version; only the live Postgres test caught it.
  * Trusting only the auth flag broke every shop card, because Sites.jsx
    rebuilt a partial snapshot object and dropped it. A relative URL is
    now sufficient on its own.
  * ago() renders a future time as "just now", so a code valid for a week
    read "expires just now".

Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.

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

199 lines
7.5 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
}
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 := 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
}
// 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)
}