The last step of onboarding that needed a shell: provision site printed a broker password and a person typed it into Mosquitto's passwd file on the host - mounted read-only in the container, so the first attempt failed silently and the password was re-rolled. No tenant could open a second branch without us. The server now drives Mosquitto's dynamic-security plugin over its own broker login: POST /api/sites (owner) writes the row and the sealed password, registers the login and a per-site role with literal topics (the 2.0 plugin does not substitute %u - measured), and removes the row again if the broker refuses, so a shop cannot exist in the database and not on the broker. provision site goes through the same path. The head-office Shops screen gets 'Open a new shop'. broker-init converts the existing passwd file into the plugin's store with every hash intact - PBKDF2-SHA512 both sides - so the cutover re-claims no shop PC. Rehearsed locally: old logins keep working, isolation holds, the health probe works, and a PC claiming a shop opened through the API connects as that shop. run-local.sh now brings the broker up the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
550 lines
25 KiB
Go
550 lines
25 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)
|
|
|
|
// --- shops ---
|
|
// CreateSite writes the shop and its sealed broker password in one
|
|
// transaction and returns the plaintext once, for the broker registration
|
|
// that must follow. DeleteNewSite is the compensation when that
|
|
// registration fails: a shop whose PC can enrol but never publish is the
|
|
// silent failure this whole endpoint exists to end.
|
|
CreateSite(ctx context.Context, clientID, slug, name, tz string) (NewSite, error)
|
|
DeleteNewSite(ctx context.Context, clientID, siteID string) 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)
|
|
// CreateMember inserts an active account into the caller's tenant. The
|
|
// hash is computed by the handler, so the plaintext never reaches the
|
|
// store - same boundary invitations and sessions already keep.
|
|
CreateMember(ctx context.Context, clientID string, in NewMemberInput, hash string) (TeamMember, error)
|
|
// ResetMemberPassword replaces the hash and revokes every session the
|
|
// member holds, in one transaction. A reset is what happens after a lost
|
|
// phone; leaving that phone signed in would defeat it.
|
|
ResetMemberPassword(ctx context.Context, clientID, userID, hash string) (TeamMember, error)
|
|
|
|
// --- public references ---
|
|
// Resolving the names people actually use to the uuids the schema stores.
|
|
// All three answer "" with a nil error when nothing matches; a found id is
|
|
// never empty, so a miss cannot be confused with a fault. See refs.go.
|
|
SiteIDBySlug(ctx context.Context, clientID, slug string) (string, error)
|
|
CameraIDByRef(ctx context.Context, clientID, ref string) (string, error)
|
|
VisitorIDByNumber(ctx context.Context, clientID string, number int64) (string, 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
|
|
// Broker registers a shop's login with Mosquitto at the moment the shop is
|
|
// created. Nil means this deployment cannot create shops through the API
|
|
// and says so, rather than creating one that can never publish.
|
|
Broker SiteBroker
|
|
// 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("POST /api/team/members", s.authed(s.handleCreateMember))
|
|
mux.HandleFunc("POST /api/team/{id}/password", s.authed(s.handleResetPassword))
|
|
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))
|
|
mux.HandleFunc("POST /api/sites", s.authed(s.handleCreateSite))
|
|
|
|
// 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[:])
|
|
}
|