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
518 lines
23 KiB
Go
518 lines
23 KiB
Go
// Package api is the request/response half of the server: staff signing in
|
|
// from the desktop app, reports, the in-store customer form, and a fresh PC
|
|
// collecting its broker credentials.
|
|
//
|
|
// The MQTT consumer in `ingest` is the other half and shares nothing with this
|
|
// but the database. That separation is deliberate — an agent is authenticated
|
|
// by the broker and identified by its topic, a person is authenticated by a
|
|
// password and identified by a session. Merging the two would mean one code
|
|
// path deciding two very different questions about who is asking.
|
|
//
|
|
// Every handler here derives the tenant from the SESSION, never from the
|
|
// request body. A client_id parameter that the caller can set is a cross-tenant
|
|
// read waiting for someone to try it.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/loyaly/behavision-server/internal/auth"
|
|
)
|
|
|
|
// Store is what the API needs from the database. Declared here, implemented in
|
|
// package store, so handlers can be tested against a fake with no Postgres —
|
|
// the same pattern ingest already uses.
|
|
type Store interface {
|
|
// --- identity ---
|
|
UserByEmail(ctx context.Context, email string) (UserRecord, error)
|
|
TouchUserLogin(ctx context.Context, userID string) error
|
|
CreateSession(ctx context.Context, s NewSession) error
|
|
SessionByAccess(ctx context.Context, hash []byte) (auth.Principal, time.Time, error)
|
|
SessionByRefresh(ctx context.Context, hash []byte) (auth.Principal, time.Time, error)
|
|
RotateSession(ctx context.Context, sessionID string, s NewSession) error
|
|
RevokeSession(ctx context.Context, sessionID string) error
|
|
// Which devices are signed in, and signing one of them out. This is what
|
|
// an opaque-token session table buys over a JWT, and until these existed
|
|
// the product paid the cost of that choice without the benefit.
|
|
UserSessions(ctx context.Context, userID string) ([]DeviceSession, error)
|
|
RevokeUserSession(ctx context.Context, userID, sessionID string) error
|
|
RevokeOtherSessions(ctx context.Context, userID, keepSessionID string) (int, error)
|
|
|
|
// --- team and invitations ---
|
|
// Registration is by invitation: the code carries the address and the role
|
|
// so neither can be chosen by whoever redeems it.
|
|
CreateInvitation(ctx context.Context, in NewInvitation) (Invitation, error)
|
|
PendingInvitations(ctx context.Context, clientID string) ([]Invitation, error)
|
|
RevokeInvitation(ctx context.Context, clientID, id string) error
|
|
InvitationByCode(ctx context.Context, hash []byte) (InvitationPreview, error)
|
|
// RedeemInvitation spends the code and creates the account in ONE
|
|
// transaction: a spent invitation with no user behind it is unusable, and a
|
|
// user with the invitation still open is a second account waiting for
|
|
// whoever else was forwarded the code.
|
|
RedeemInvitation(ctx context.Context, hash []byte, fullName, passwordHash string) (UserRecord, error)
|
|
Team(ctx context.Context, clientID string) ([]TeamMember, error)
|
|
UpdateTeamMember(ctx context.Context, clientID, userID string, up TeamUpdate) (TeamMember, error)
|
|
|
|
// --- reports ---
|
|
Footfall(ctx context.Context, q ReportQuery) ([]FootfallPoint, Totals, error)
|
|
Conversion(ctx context.Context, q ReportQuery) (SalesReport, error)
|
|
SiteHealth(ctx context.Context, clientID string) ([]SiteHealth, error)
|
|
|
|
// --- people ---
|
|
SearchVisitors(ctx context.Context, clientID, query string, limit int) ([]Customer, error)
|
|
VisitorHistory(ctx context.Context, clientID, visitorID string, limit int) ([]VisitRow, error)
|
|
// Arrivals is the live feed: who walked in, newest window or from a cursor.
|
|
Arrivals(ctx context.Context, q ArrivalQuery) ([]Arrival, error)
|
|
SaveProfile(ctx context.Context, clientID string, p Profile, actor string) error
|
|
RecordPurchase(ctx context.Context, clientID string, p PurchaseInput, actor string) error
|
|
|
|
// --- cameras ---
|
|
Cameras(ctx context.Context, clientID, siteID string) ([]Camera, error)
|
|
CameraByID(ctx context.Context, clientID, id string) (Camera, error)
|
|
SaveCamera(ctx context.Context, clientID, siteID, cameraID string, in CameraInput) (Camera, error)
|
|
DeleteCamera(ctx context.Context, clientID, id string) (Camera, error)
|
|
// AgentCameras is the only path that decrypts a camera password, and it is
|
|
// reachable only with that site's own agent token.
|
|
AgentCameras(ctx context.Context, siteID string) ([]AgentCamera, error)
|
|
ApplyAgentReport(ctx context.Context, clientID, siteID string, rep AgentCameraReport) error
|
|
// CameraRef resolves one of a TENANT's cameras to its site and the name the
|
|
// engine knows it by. Used to prove ownership before anything is streamed.
|
|
CameraRef(ctx context.Context, clientID, cameraID string) (siteID, engineID string, err error)
|
|
// CameraRefBySite is the same question asked by an agent, which is
|
|
// authenticated for a site rather than a tenant.
|
|
CameraRefBySite(ctx context.Context, siteID, cameraID string) (site, engineID string, err error)
|
|
// SiteCameraIDs lists a site's camera uuids, for the agent's live poll.
|
|
SiteCameraIDs(ctx context.Context, siteID string) ([]string, error)
|
|
// Camera pictures held by this server, for deployments with no object
|
|
// storage. Where a bucket is configured neither of these is called.
|
|
PutCameraSnapshot(ctx context.Context, clientID, siteID, cameraID string, jpeg []byte) error
|
|
CameraSnapshot(ctx context.Context, clientID, cameraID string) ([]byte, time.Time, error)
|
|
|
|
// --- claiming a shop PC ---
|
|
IssueEnrolmentCode(ctx context.Context, clientID, siteID, actorID,
|
|
label string, ttl time.Duration) (EnrolmentCode, error)
|
|
|
|
// --- proving a camera works ---
|
|
RequestCheck(ctx context.Context, clientID, id, kind string, seconds int) error
|
|
ClaimChecks(ctx context.Context, siteID string) ([]AgentCheckJob, error)
|
|
RecordCheckResult(ctx context.Context, siteID string, res AgentCheckResult) error
|
|
ReleaseStaleChecks(ctx context.Context, olderThan time.Duration) error
|
|
|
|
// --- platform administration ---
|
|
CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error)
|
|
ListClients(ctx context.Context) ([]ClientRow, error)
|
|
|
|
// --- enrolment ---
|
|
RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error)
|
|
SetAgentAPIToken(ctx context.Context, agentID string, hash []byte) error
|
|
AgentByToken(ctx context.Context, hash []byte) (AgentPrincipal, error)
|
|
|
|
// --- images ---
|
|
// Face images held by this server, for a deployment with no object
|
|
// storage. Where a bucket is configured none of these three is called.
|
|
PutVisitFace(ctx context.Context, clientID, siteID string, jpeg []byte) (string, error)
|
|
VisitFace(ctx context.Context, clientID, key string) ([]byte, error)
|
|
DeleteVisitFaces(ctx context.Context, clientID string, keys []string) error
|
|
VisitorImageKey(ctx context.Context, clientID, visitorID string) (string, error)
|
|
VisitorImageKeys(ctx context.Context, clientID, visitorID string) ([]string, error)
|
|
ForgetVisitor(ctx context.Context, clientID, visitorID string) error
|
|
|
|
Audit(ctx context.Context, e AuditEntry)
|
|
}
|
|
|
|
type Server struct {
|
|
Store Store
|
|
Log *log.Logger
|
|
Bootstrap BootstrapConfig
|
|
// Now is injectable so expiry logic is testable without sleeping.
|
|
Now func() time.Time
|
|
// Throttle limits failed sign-ins per account, IPThrottle per source
|
|
// address. Built on first use so a zero-value Server is still safe: an
|
|
// unlimited login endpoint reached by forgetting one field is not a failure
|
|
// mode worth leaving open.
|
|
Throttle *Throttle
|
|
IPThrottle *Throttle
|
|
// Blob is object storage. Nil means this deployment stores no images,
|
|
// which is a supported configuration and the default: the product shipped
|
|
// without images on purpose, and turning them on changes what the database
|
|
// is under data-protection law.
|
|
Blob BlobStore
|
|
// Assistant answers questions in plain language by calling the same
|
|
// business questions the screens ask. Nil means this deployment has no
|
|
// API key, which is supported: the UI hides the panel.
|
|
Assistant Assistant
|
|
// Live relays camera frames from a shop PC to whoever is watching, on
|
|
// demand. Created on first use.
|
|
Live *LiveHub
|
|
liveOnce sync.Once
|
|
// Hub wakes live arrival streams when the MQTT consumer records a visit.
|
|
// Nil is supported and means the streams fall back to their slow tick -
|
|
// a server assembled without one is slower, not broken.
|
|
Hub *Hub
|
|
|
|
once sync.Once
|
|
}
|
|
|
|
func (s *Server) throttles() (perUser, perIP *Throttle) {
|
|
s.once.Do(func() {
|
|
if s.Throttle == nil {
|
|
s.Throttle = NewThrottle(10, 15*time.Minute)
|
|
}
|
|
if s.IPThrottle == nil {
|
|
// Far looser than the per-account limit, and deliberately so. A
|
|
// whole shop sits behind one NAT address, so a per-IP limit tight
|
|
// enough to stop a targeted attack locks out every member of staff
|
|
// because one of them fumbled their password. The per-ACCOUNT limit
|
|
// is what actually stops somebody working through a password list;
|
|
// this is only a backstop against spraying one guess across many
|
|
// addresses.
|
|
s.IPThrottle = NewThrottle(60, 15*time.Minute)
|
|
}
|
|
})
|
|
return s.Throttle, s.IPThrottle
|
|
}
|
|
|
|
// BootstrapConfig is what a newly enrolled PC is told about the estate. It
|
|
// comes from the server's own environment, never from the request: an agent
|
|
// asking where to connect must not be able to influence the answer.
|
|
type BootstrapConfig struct {
|
|
MQTTURL string
|
|
CACert string
|
|
Models []ModelRef
|
|
}
|
|
|
|
type ModelRef struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
SHA256 string `json:"sha256"`
|
|
Bytes int64 `json:"bytes"`
|
|
}
|
|
|
|
func (s *Server) now() time.Time {
|
|
if s.Now != nil {
|
|
return s.Now()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
func (s *Server) logf(format string, v ...any) {
|
|
if s.Log != nil {
|
|
s.Log.Printf(format, v...)
|
|
}
|
|
}
|
|
|
|
// Routes returns the mux. Patterns use method-qualified paths so a GET to a
|
|
// write endpoint is a 405 rather than falling through to the catch-all as a
|
|
// confusing 404.
|
|
func (s *Server) Routes() *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
|
mux.HandleFunc("POST /api/auth/refresh", s.handleRefresh)
|
|
mux.HandleFunc("POST /api/auth/logout", s.authed(s.handleLogout))
|
|
mux.HandleFunc("GET /api/auth/me", s.authed(s.handleMe))
|
|
// Registration. Unauthenticated for the same reason agent enrolment is:
|
|
// whoever is doing this has no account yet, and requiring one first would
|
|
// mean shipping a password to everybody who needs one.
|
|
mux.HandleFunc("GET /api/auth/invitation", s.handleInvitationPreview)
|
|
mux.HandleFunc("POST /api/auth/register", s.handleRegister)
|
|
// Devices. A person may list and revoke their own sessions; removing a
|
|
// colleague's access is a different question, answered by deactivating them
|
|
// on the team endpoint below.
|
|
mux.HandleFunc("GET /api/auth/sessions", s.authed(s.handleSessions))
|
|
mux.HandleFunc("DELETE /api/auth/sessions/{id}", s.authed(s.handleRevokeSession))
|
|
mux.HandleFunc("POST /api/auth/sessions/revoke-others",
|
|
s.authed(s.handleRevokeOtherSessions))
|
|
|
|
// --- the people who work here ---
|
|
mux.HandleFunc("GET /api/team", s.authed(s.handleTeam))
|
|
mux.HandleFunc("PATCH /api/team/{id}", s.authed(s.handleUpdateTeamMember))
|
|
mux.HandleFunc("GET /api/team/invitations", s.authed(s.handleInvitations))
|
|
mux.HandleFunc("POST /api/team/invitations", s.authed(s.handleInvite))
|
|
mux.HandleFunc("DELETE /api/team/invitations/{id}",
|
|
s.authed(s.handleRevokeInvitation))
|
|
|
|
mux.HandleFunc("GET /api/reports/footfall", s.authed(s.handleFootfall))
|
|
mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion))
|
|
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
|
|
|
|
// Cameras, onboarded from head office. The shop PC still does the
|
|
// connecting - it is the only thing on the camera's network - so these
|
|
// write desired state that its agent pulls and applies.
|
|
mux.HandleFunc("GET /api/cameras", s.authed(s.handleCameras))
|
|
mux.HandleFunc("POST /api/sites/{site}/cameras", s.authed(s.handleCreateCamera))
|
|
mux.HandleFunc("PATCH /api/cameras/{id}", s.authed(s.handleUpdateCamera))
|
|
mux.HandleFunc("DELETE /api/cameras/{id}", s.authed(s.handleDeleteCamera))
|
|
mux.HandleFunc("GET /api/cameras/{id}/snapshot.jpg", s.authed(s.handleGetSnapshot))
|
|
mux.HandleFunc("GET /api/cameras/{id}/live", s.authed(s.handleWatchLive))
|
|
// Prove a camera works: "connection" asks whether the shop PC can open the
|
|
// stream, "placement" asks whether somebody walking past produces a view
|
|
// good enough to recognise. Two questions, because a camera passes the
|
|
// first and fails the second all the time - that is the Office1 case.
|
|
mux.HandleFunc("POST /api/cameras/{id}/check", s.authed(s.handleRequestCheck))
|
|
// The end-to-end answer for one shop, assembled from what head office
|
|
// already knows - so it works even when the shop PC is off, which is one of
|
|
// the things it reports.
|
|
mux.HandleFunc("GET /api/sites/{site}/check", s.authed(s.handleSiteCheck))
|
|
mux.HandleFunc("POST /api/sites/{site}/enrolment-code",
|
|
s.authed(s.handleIssueEnrolmentCode))
|
|
|
|
// The assistant. Every tool it calls runs as the signed-in user, so it can
|
|
// only ever see what the person asking could already see.
|
|
mux.HandleFunc("POST /api/assistant", s.authed(s.handleAssistant))
|
|
|
|
// The live feed. `visitors` searches a customer list by name; `visits`
|
|
// answers the question a shop screen or a mobile app actually asks - who
|
|
// came through the door just now - and carries each person's photo with
|
|
// them so rendering four simultaneous arrivals is one request, not nine.
|
|
mux.HandleFunc("GET /api/visits", s.authed(s.handleArrivals))
|
|
mux.HandleFunc("GET /api/visits/stream", s.authed(s.handleArrivalStream))
|
|
|
|
mux.HandleFunc("GET /api/visitors", s.authed(s.handleVisitors))
|
|
mux.HandleFunc("GET /api/visitors/{id}/history", s.authed(s.handleVisitorHistory))
|
|
mux.HandleFunc("PUT /api/visitors/{id}/profile", s.authed(s.handleSaveProfile))
|
|
mux.HandleFunc("POST /api/purchases", s.authed(s.handlePurchase))
|
|
|
|
// Platform administration. Not public registration: an open endpoint that
|
|
// mints tenants is a far larger thing to secure than one behind an account
|
|
// that already exists. The `provision` CLI remains the bootstrap path,
|
|
// because creating the first admin cannot require being signed in as one.
|
|
mux.HandleFunc("GET /api/admin/clients", s.adminOnly(s.handleListClients))
|
|
mux.HandleFunc("POST /api/admin/clients", s.adminOnly(s.handleCreateClient))
|
|
|
|
// Not session-authenticated: this is how a PC with no credentials gets
|
|
// some. The enrolment token is the credential.
|
|
mux.HandleFunc("POST /api/agent/enrol", s.handleEnrol)
|
|
// Authenticated by the agent's own API token, not a user session.
|
|
mux.HandleFunc("POST /api/agent/upload-url", s.agentAuthed(s.handleUploadURL))
|
|
// The fallback the agent takes when upload-url answers images_disabled.
|
|
mux.HandleFunc("POST /api/agent/faces", s.agentAuthed(s.handlePutFace))
|
|
// What this shop PC should be running, and what it reports back.
|
|
mux.HandleFunc("GET /api/agent/cameras", s.agentAuthed(s.handleAgentCameras))
|
|
mux.HandleFunc("POST /api/agent/cameras", s.agentAuthed(s.handleAgentCameraReport))
|
|
mux.HandleFunc("PUT /api/agent/cameras/{camera}/snapshot",
|
|
s.agentAuthed(s.handlePutSnapshot))
|
|
mux.HandleFunc("GET /api/agent/live", s.agentAuthed(s.handleAgentLiveWanted))
|
|
mux.HandleFunc("POST /api/agent/cameras/{camera}/live",
|
|
s.agentAuthed(s.handleAgentPushLive))
|
|
mux.HandleFunc("GET /api/agent/checks", s.agentAuthed(s.handleAgentChecks))
|
|
mux.HandleFunc("POST /api/agent/checks", s.agentAuthed(s.handleAgentCheckResult))
|
|
|
|
mux.HandleFunc("GET /api/visitors/{id}/image", s.authed(s.handleVisitorImage))
|
|
// The bytes of a face this server holds itself. Session-authenticated
|
|
// rather than a signed link: there is no third party to delegate to, and an
|
|
// unauthenticated URL would be a way to reach a customer's photograph with
|
|
// no session at all.
|
|
mux.HandleFunc("GET /api/faces/{id}", s.authed(s.handleGetFace))
|
|
// The erasure path. Destroys the template and the photo; keeps the
|
|
// anonymous visit counts, which are legitimate aggregate data.
|
|
mux.HandleFunc("DELETE /api/visitors/{id}", s.authed(s.handleForgetVisitor))
|
|
|
|
return mux
|
|
}
|
|
|
|
// ---------------------------------------------------------------- plumbing
|
|
|
|
type ctxKey int
|
|
|
|
const principalKey ctxKey = 1
|
|
|
|
// PrincipalFrom returns the authenticated caller. Handlers behind authed() can
|
|
// rely on it being present.
|
|
func PrincipalFrom(ctx context.Context) auth.Principal {
|
|
p, _ := ctx.Value(principalKey).(auth.Principal)
|
|
return p
|
|
}
|
|
|
|
func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
tok := auth.BearerToken(r)
|
|
if tok == "" {
|
|
unauthorized(w, "sign in to continue")
|
|
return
|
|
}
|
|
p, expires, err := s.Store.SessionByAccess(r.Context(), auth.HashToken(tok))
|
|
if err != nil {
|
|
// One message for "no such session" and "revoked": telling the
|
|
// difference is only useful to someone probing tokens.
|
|
unauthorized(w, "sign in to continue")
|
|
return
|
|
}
|
|
if s.now().After(expires) {
|
|
// A distinct code so the client refreshes silently instead of
|
|
// throwing the user back to a login form every twelve hours.
|
|
writeErr(w, http.StatusUnauthorized, "token_expired",
|
|
"your session needs refreshing")
|
|
return
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), principalKey, p)))
|
|
}
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
if err := json.NewEncoder(w).Encode(body); err != nil {
|
|
// Nothing useful left to do: the status line is already sent.
|
|
return
|
|
}
|
|
}
|
|
|
|
// writeCompactJSON writes JSON with no trailing newline.
|
|
//
|
|
// Encoder.Encode appends one, which inside an SSE `data:` line closes the event
|
|
// a frame early. Everywhere else that newline is invisible; here it is a
|
|
// protocol bug, so the stream gets its own writer rather than a comment on the
|
|
// shared one asking people to remember.
|
|
func writeCompactJSON(w io.Writer, body any) error {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = w.Write(b)
|
|
return err
|
|
}
|
|
|
|
// writeErr uses the shape the desktop client parses: it shows `message`
|
|
// verbatim, so that string is user-facing text, not a developer note.
|
|
func writeErr(w http.ResponseWriter, code int, kind, message string) {
|
|
writeJSON(w, code, map[string]string{"error": kind, "message": message})
|
|
}
|
|
|
|
func unauthorized(w http.ResponseWriter, msg string) {
|
|
writeErr(w, http.StatusUnauthorized, "unauthorized", msg)
|
|
}
|
|
|
|
func badRequest(w http.ResponseWriter, msg string) {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", msg)
|
|
}
|
|
|
|
func (s *Server) serverError(w http.ResponseWriter, where string, err error) {
|
|
// The error text stays in the log. A database error surfaced to a shop
|
|
// floor tells an attacker about the schema and tells the operator nothing
|
|
// they can act on.
|
|
s.logf("ERROR %s: %v", where, err)
|
|
writeErr(w, http.StatusInternalServerError, "server_error",
|
|
"something went wrong at our end - please try again")
|
|
}
|
|
|
|
// decode reads a JSON body with a hard size limit. Unknown fields are rejected
|
|
// so a client sending `client_id` to a handler that ignores it finds out,
|
|
// rather than believing it took effect.
|
|
func decode(w http.ResponseWriter, r *http.Request, out any) error {
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(out); err != nil {
|
|
return errors.New("could not read the request: " + err.Error())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decodeOptional is decode for a body where every field has a default.
|
|
//
|
|
// An empty body is then a legitimate request - "mint me a code, I have nothing
|
|
// to say about it" - and answering that with 400 "could not read the request:
|
|
// EOF" is a confusing failure for the simplest possible call. Not the default,
|
|
// because for most endpoints an empty body IS the mistake, and silently
|
|
// treating it as an empty object would let a PUT wipe a profile.
|
|
func decodeOptional(w http.ResponseWriter, r *http.Request, out any) error {
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(out); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
return errors.New("could not read the request: " + err.Error())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func queryInt(r *http.Request, name string, def, max int) int {
|
|
v, err := strconv.Atoi(r.URL.Query().Get(name))
|
|
if err != nil || v <= 0 {
|
|
return def
|
|
}
|
|
if v > max {
|
|
return max
|
|
}
|
|
return v
|
|
}
|
|
|
|
func trim(s string) string { return strings.TrimSpace(s) }
|
|
|
|
// looksLikeUUID checks the shape of a path id before it reaches SQL.
|
|
//
|
|
// Every id in this schema is a uuid, and `$1::uuid` on a malformed string is a
|
|
// Postgres cast error - which surfaces as a 500. A mistyped URL is not a server
|
|
// fault, and answering one with "something went wrong at our end" sends an
|
|
// operator looking for an outage that is not there. It also means a scanner
|
|
// walking the API can tell, from the status code alone, which of its guesses
|
|
// reached a query.
|
|
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
|
|
}
|
|
|
|
// ErrNoSecrets means the server has no encryption key, so camera passwords can
|
|
// be neither stored nor handed out. Declared here so handlers can recognise it
|
|
// without importing the store package.
|
|
var ErrNoSecrets = errors.New("this server has no encryption key, so camera passwords cannot be stored")
|
|
|
|
// ErrNoSnapshot means a camera has no stored picture. An ordinary state - a
|
|
// camera added a minute ago has none - so it is reported as absence, never as
|
|
// a failure.
|
|
var ErrNoSnapshot = errors.New("no snapshot for this camera")
|
|
|
|
// BlobStore is what the API needs from object storage. Declared here and
|
|
// implemented by internal/blob, so the handlers can be tested without a bucket
|
|
// and so a deployment with images switched off is a nil field rather than a
|
|
// second code path.
|
|
type BlobStore interface {
|
|
Key(client, site, objectID string, at time.Time) string
|
|
PresignPut(key string, ttl time.Duration) (string, http.Header, error)
|
|
PresignGet(key string, ttl time.Duration) (string, error)
|
|
Delete(ctx context.Context, key string) error
|
|
}
|
|
|
|
// newObjectID names one uploaded image.
|
|
//
|
|
// Random rather than derived from the event id: an object key is guessable if
|
|
// it is derived, and this bucket allows anonymous listing, so a predictable key
|
|
// would let someone enumerate a shop's customers by date. It is also the only
|
|
// identifier the agent gets, and it must not encode who the person is.
|
|
func newObjectID() string {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
// Cannot happen short of a broken kernel, and a predictable key here
|
|
// would be worse than a failed upload.
|
|
panic("api: no entropy for an object id: " + err.Error())
|
|
}
|
|
return hex.EncodeToString(b[:])
|
|
}
|