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
187 lines
8.0 KiB
Go
187 lines
8.0 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// Face images held by this server, for a deployment with no object storage.
|
|
//
|
|
// Where a bucket IS configured nothing here is used: the agent keeps asking for
|
|
// a presigned URL and the reader keeps getting a signed link, which never puts
|
|
// a photograph through this process at all and is the right route at estate
|
|
// scale. This is the fallback that stops "no S3 account" from meaning "no
|
|
// customer photo, ever", which is what every local install and every
|
|
// self-hosted customer got - including on the mobile arrivals feed, whose whole
|
|
// job is to put a face in front of somebody.
|
|
//
|
|
// Migration 011 carries the argument for why this is bounded and therefore safe
|
|
// to keep in Postgres when per-visit images are not: one row survives per
|
|
// customer, so it grows with the customer base and not with footfall.
|
|
|
|
// maxFaceBytes caps one upload. The engine writes ~20 KB crops; 2 MB is
|
|
// generous for a large one and small enough that a misbehaving agent cannot use
|
|
// this as free storage.
|
|
const maxFaceBytes = 2 << 20
|
|
|
|
// faceMaxAge is how long a client may reuse a face it has already fetched.
|
|
// The image for a given key never changes - a newer view gets a new key - so
|
|
// this is only bounded to keep a signed-out device from holding one for ever.
|
|
const faceMaxAge = 5 * time.Minute
|
|
|
|
// handlePutFace takes one face crop from a shop PC.
|
|
//
|
|
// The client and site come from the agent's own credential and are never read
|
|
// off the request, so a shop PC physically cannot file an image under another
|
|
// tenant - the same rule every other agent-authenticated write here follows.
|
|
//
|
|
// The response is a KEY, which the agent then puts on the queued visit exactly
|
|
// as it does with a bucket object. That symmetry is deliberate: the two storage
|
|
// routes differ in one hop and in nothing else, so the ingest path, the read
|
|
// path and erasure all stay single implementations.
|
|
func (s *Server) handlePutFace(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxFaceBytes+1))
|
|
if err != nil || len(body) > maxFaceBytes {
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "too_large",
|
|
fmt.Sprintf("A face image must be under %d KB.", maxFaceBytes/1024))
|
|
return
|
|
}
|
|
if len(body) == 0 {
|
|
badRequest(w, "the image is empty")
|
|
return
|
|
}
|
|
// Checked against the bytes, never the Content-Type header. This endpoint
|
|
// stores what it is handed and serves it back to a browser, so the one
|
|
// thing it must not become is a way to park arbitrary content under a URL
|
|
// this server will serve.
|
|
if !isJPEG(body) {
|
|
badRequest(w, "a face image must be a JPEG")
|
|
return
|
|
}
|
|
|
|
key, err := s.Store.PutVisitFace(r.Context(), ap.ClientID, ap.SiteID, body)
|
|
if err != nil {
|
|
s.serverError(w, "store face", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"key": key})
|
|
}
|
|
|
|
// handleGetFace serves one back to a signed-in person.
|
|
//
|
|
// Session-authenticated rather than a signed link, and that is the same call
|
|
// camera snapshots already made: there is no third party to delegate to - the
|
|
// bytes are in our own database - and minting an unauthenticated URL so that a
|
|
// plain <img src> could load it would add a way to reach a photograph of
|
|
// somebody's customer with no session at all.
|
|
//
|
|
// The consequence is a real one and clients must handle it: a browser <img>
|
|
// cannot send an Authorization header, so the web app fetches this and hands
|
|
// over an object URL. A mobile image view can attach the header directly. The
|
|
// `auth` flag on every Image says which kind of URL it is holding.
|
|
//
|
|
// No audit row is written here. Every read of a face is recorded where the LINK
|
|
// is handed out - the arrivals page writes one row per page, the customer
|
|
// record one per look - and the two paths must not disagree about what counts
|
|
// as a read. Recording the byte fetch as well would double-count the DB
|
|
// deployment and leave the bucket deployment, whose bytes never touch this
|
|
// server, counted once.
|
|
func (s *Server) handleGetFace(w http.ResponseWriter, r *http.Request) {
|
|
p := PrincipalFrom(r.Context())
|
|
img, err := s.Store.VisitFace(r.Context(), p.ClientID, faceKey(r.PathValue("id")))
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, "no_image", "There is no photo here.")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(img)))
|
|
w.Header().Set("Cache-Control", "private, max-age="+
|
|
strconv.Itoa(int(faceMaxAge.Seconds())))
|
|
// A photograph of a customer must not travel to a third party in a Referer
|
|
// header if this URL is ever rendered inside a page that links out.
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
if _, err := w.Write(img); err != nil && !errors.Is(err, http.ErrHandlerTimeout) {
|
|
s.logf("WARN write face: %v", err)
|
|
}
|
|
}
|
|
|
|
// faceKey rebuilds the stored key from the id in the path.
|
|
//
|
|
// The route is `/api/faces/{id}.jpg` so a client can hand the URL to an image
|
|
// view that decides what to do by extension, and the `.jpg` is presentation
|
|
// rather than part of the key.
|
|
func faceKey(id string) string {
|
|
if n := len(id); n > 4 && id[n-4:] == ".jpg" {
|
|
id = id[:n-4]
|
|
}
|
|
return dbKeyPrefix + id
|
|
}
|
|
|
|
// dbKeyPrefix mirrors store.DBKeyPrefix. Duplicated rather than imported
|
|
// because this package must not depend on the concrete store - the whole point
|
|
// of the Store interface - and it is a wire constant that changing on one side
|
|
// alone would break loudly and immediately in the tests either way.
|
|
const dbKeyPrefix = "db:"
|
|
|
|
// isDBKey reports whether an image key names a row here rather than an object
|
|
// in a bucket.
|
|
func isDBKey(key string) bool {
|
|
return len(key) > len(dbKeyPrefix) && key[:len(dbKeyPrefix)] == dbKeyPrefix
|
|
}
|
|
|
|
// faceURL is the path a client fetches for a stored face.
|
|
func faceURL(key string) string {
|
|
return "/api/faces/" + key[len(dbKeyPrefix):] + ".jpg"
|
|
}
|
|
|
|
// imageFor turns one stored image key into the Image a client receives.
|
|
//
|
|
// ONE function decides this, for every surface: the arrivals feed, the live
|
|
// stream, the customer record. There are now two places an image can live and
|
|
// four distinct reasons there may not be one, and the failure this avoids is
|
|
// the one the shops screen already hit once - two surfaces computing the same
|
|
// fact separately and disagreeing about it in front of a user.
|
|
//
|
|
// A missing photo is DATA, not an error. Images are off by default across the
|
|
// whole product, so on most deployments every arrival legitimately has none; a
|
|
// client that renders a failure state would show a screen of red for a system
|
|
// working exactly as configured. The two absences are told apart because a shop
|
|
// can act on one and not the other.
|
|
func (s *Server) imageFor(key string) Image {
|
|
switch {
|
|
case key == "" && s.Blob == nil:
|
|
return Image{Reason: "This system is not storing customer photos."}
|
|
case key == "":
|
|
return Image{Reason: "No photo was captured for this visit."}
|
|
|
|
case isDBKey(key):
|
|
// Held by this server. A relative URL that needs the caller's session -
|
|
// see handleGetFace for why it is not a signed link - so it carries no
|
|
// expiry: it is valid for exactly as long as the session is.
|
|
return Image{Available: true, URL: faceURL(key), Auth: true}
|
|
|
|
case s.Blob == nil:
|
|
// A bucket key on a server with no bucket. Only reachable if object
|
|
// storage was configured once and has since been removed, and it is
|
|
// worth its own sentence: the photo exists somewhere and this
|
|
// deployment can no longer reach it, which is a configuration problem
|
|
// rather than a customer with no picture.
|
|
return Image{Reason: "This server can no longer reach its image storage."}
|
|
|
|
default:
|
|
url, err := s.Blob.PresignGet(key, viewTTL)
|
|
if err != nil {
|
|
// Logged, never fatal. The visit is the number the customer pays
|
|
// for; the photo is decoration on top of it. Same rule the agent
|
|
// follows when an upload fails.
|
|
s.logf("ERROR presign image: %v", err)
|
|
return Image{Reason: "That photo could not be loaded."}
|
|
}
|
|
return Image{Available: true, URL: url, ExpiresIn: int(viewTTL.Seconds())}
|
|
}
|
|
}
|