Compare commits
23 Commits
c7024b57ca
...
v0.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
| e262fc8482 | |||
| b59e667a68 | |||
| 70c447873d | |||
| 719ba2c7f5 | |||
| 5e1dcf7050 | |||
| 92573e9067 | |||
| 92b12bcb1c | |||
| c50a74de47 | |||
| 0a423ed8cc | |||
| 22196ab9ba | |||
| 5e544eee3d | |||
| 9521cb986b | |||
| 30e01765ae | |||
| ee9e8b80b7 | |||
| 3598d8e9c0 | |||
| ce0223006b | |||
| 08873f4a67 | |||
| 9182f70442 | |||
| 3f9fb33b24 | |||
| ffae7e45d5 | |||
| 18686cbceb | |||
| 2cd7a78ddc | |||
| e0ceb14589 |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -52,3 +52,16 @@ node_modules/
|
||||
# in the tree `go build ./...` fails on a fresh checkout - on a machine that may
|
||||
# have no npm at all. They are ~200 KB and regenerating them is one command; a
|
||||
# repository that does not compile is the more expensive problem.
|
||||
|
||||
# Backups of .env made when editing camera credentials.
|
||||
/.env.bak-*
|
||||
|
||||
# Generated by the wails CLI on every dev run and build, not source.
|
||||
# NOT /desktop/build/ as a whole: appicon.png, darwin/ and windows/ under it
|
||||
# are the Wails project scaffolding (icon, Info.plist, manifest) that a
|
||||
# reproducible Windows build needs. Only the compiled output is ignored.
|
||||
/desktop/frontend/wailsjs/
|
||||
/desktop/frontend/package.json.md5
|
||||
|
||||
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
|
||||
/behavision.egg-info/
|
||||
|
||||
613
CLAUDE.md
613
CLAUDE.md
@@ -152,7 +152,8 @@ own camera, same-person similarity p05 0.719 vs 0.620) → `arcface_int8.onnx`
|
||||
the chain. AdaFace slots are wired but empty: drop a converted
|
||||
`adaface_ir50.onnx` in and it is picked up, BGR channel order already
|
||||
handled (see `color_order_for`). The dev machine is
|
||||
memory-starved (16 GB, often < 1.5 GB free): the 260 MB model fails with
|
||||
memory-starved (**8 GB**, measured 0.45 GB free with a browser and Docker
|
||||
open): the 260 MB model fails with
|
||||
"bad allocation"; int8 quantization of it segfaulted (OOM). w600k_mbf comes
|
||||
from InsightFace buffalo_sc; genderage.onnx from buffalo_l. On failure,
|
||||
the encoder retries loading with `ORT_DISABLE_ALL` graph optimization.
|
||||
@@ -1953,6 +1954,182 @@ Verified live 2026-08-31, whole chain: enrol → upload-url → PUT → anonymou
|
||||
JPEG downloaded → erase → presigned GET **404**, 0 templates, 0 profiles, label
|
||||
`Erased`, visit row kept.
|
||||
|
||||
## Identifiers: a customer number people can say (migration 012)
|
||||
|
||||
Every id in the schema is a uuid and stays one. What was wrong was putting one
|
||||
in front of a person. `RecordVisit` named every new customer from theirs:
|
||||
|
||||
```sql
|
||||
UPDATE visitors SET label = 'Visitor ' || left(id::text, 8)
|
||||
```
|
||||
|
||||
So the name on the arrivals feed, on the shop PC, and in the mobile app was
|
||||
**"Visitor 3446ec35"** — the string a shop assistant reads out to a colleague,
|
||||
writes on a card, and types into a search box. Not a display problem to paper
|
||||
over in a front end either: `label` is a stored column staff can overwrite and
|
||||
`SearchVisitors` matches on, so it had to be fixed where it is written.
|
||||
|
||||
`visitors.number` is a **per-client** sequence and the label is now
|
||||
`Visitor 42`, referenced as **`V-42`**. Three properties, each ruling out an
|
||||
alternative:
|
||||
|
||||
- **Speakable.** The whole point.
|
||||
- **Per client, not global.** A global sequence tells any customer who signs up
|
||||
how many people the entire platform has ever seen, from their own first
|
||||
visitor number. Per tenant it reveals a tenant's own count to that tenant's
|
||||
own staff, who know it already.
|
||||
- **Not the primary key.** Ids are minted where nothing can ask a database for
|
||||
the next value, and eleven tables reference `visitors.id`. This is a public
|
||||
*reference* beside the key, which is the part humans needed.
|
||||
|
||||
The counter is `clients.visitor_seq`, taken with `UPDATE ... RETURNING` inside
|
||||
the visit transaction. That returns the value **after** the update — the same
|
||||
semantics that silently broke the face prune in 011 by handing back what it had
|
||||
just written, and here exactly what is wanted. It row-locks the client for the
|
||||
length of the insert, which serialises new-visitor creation per tenant and costs
|
||||
nothing: it runs only for a face nobody in the estate has ever seen.
|
||||
|
||||
The backfill numbers existing rows by `first_seen_at` and relabels **only** the
|
||||
eight-lowercase-hex pattern the old statement produced, so a name a human typed
|
||||
is never overwritten. Verified on the live database: 13 hex labels became
|
||||
Visitor 1-13 in first-seen order, two "Walk-in test" names were left alone, and
|
||||
`visitor_seq` landed on 15.
|
||||
|
||||
### Three of the four things already had a human name; the API refused it
|
||||
|
||||
That is the part worth keeping. Only visitors genuinely lacked a reference:
|
||||
|
||||
| thing | reference | since |
|
||||
|---|---|---|
|
||||
| shop | `slug` — "chennai" | 001 |
|
||||
| camera | `camera_id` — "Office1", and what `visits.camera_id` holds | 005 |
|
||||
| customer | `V-<number>` | 012 |
|
||||
| person | email | 002 |
|
||||
|
||||
`refs.go` accepts either form anywhere an id is taken. A uuid resolves with no
|
||||
lookup at all, so nothing that worked yesterday changes — including every URL a
|
||||
client has already stored. Only a non-uuid costs a query.
|
||||
|
||||
- **A camera id is unique per SITE, not per tenant.** Two shops may each have an
|
||||
`Office1`, so an ambiguous name resolves to **nothing** rather than to
|
||||
whichever row sorted first — acting on a guess would edit the wrong shop's
|
||||
camera.
|
||||
- **404 on a path, 400 on a query filter.** `/api/visits` exists and answered;
|
||||
what was wrong was the filter, and a 404 there reads as "the arrivals feed is
|
||||
gone". A path segment names the resource itself, so an unknown one *is* a 404.
|
||||
- **`site` and `site_id` are both accepted everywhere now.** Reports took one and
|
||||
the arrivals feed the other, and an unknown query parameter is silently
|
||||
ignored — so getting it the wrong way round returned the whole estate instead
|
||||
of an error, which is a wrong number nobody would question.
|
||||
- **The search matches the reference.** `V-13` is what the product now shows, so
|
||||
it is what gets pasted into the search box, and `label ILIKE '%V-13%'` finds
|
||||
nothing because the label says "Visitor 13". A search that comes back empty
|
||||
for the identifier you were just shown is worse than no search.
|
||||
|
||||
The **edge** engine has always numbered its identities from a SQLite rowid, so
|
||||
"Visitor 3" there and "Visitor 47" here are the same person under two numbers.
|
||||
Left alone deliberately: making them agree means the shop PC asking the server
|
||||
for a number, which cannot work offline — and the edge number appears only on
|
||||
the engine's own diagnostic dashboard.
|
||||
|
||||
### Two bugs, one from a real database and one from a real browser
|
||||
|
||||
- **`'Visitor ' || $2::text` next to `number = $2`.** Postgres deduces two types
|
||||
for one parameter and refuses the whole insert: *"inconsistent types deduced
|
||||
for parameter $2"*. It compiled, it passed every in-memory test, and it failed
|
||||
on the first real database — along with the existing face tests, which go
|
||||
through the same path. The label is formatted in Go now.
|
||||
- **The avatar said `V1` for three different people.** With no photograph the
|
||||
arrivals feed draws initials, and `initials("Visitor 13")` takes the first
|
||||
letter of each word — `V1`, which is also what "Visitor 10" and "Visitor 15"
|
||||
produce, and which reads as the `V-1` reference for a fourth person. It shows
|
||||
the number itself now. Found by opening the page: every test here passes a
|
||||
human name. The prop carrying it is `customerRef`, not `ref` — React reserves
|
||||
that name, so it would never have reached the component.
|
||||
|
||||
### Why the uuid stays, when the slug would do
|
||||
|
||||
Asked directly: `site_id` is 36 characters, why not a small number?
|
||||
|
||||
The honest answer is that **the length was never the problem — needing it was**,
|
||||
and that is already fixed: `?site=chennai` and `/api/sites/chennai/check` work,
|
||||
and the shop PC has always identified itself by slug (`agent.json` holds
|
||||
`"site_id": "chennai"`, never the uuid). The uuid in a *response* is the stable
|
||||
key for a client that wants to store one.
|
||||
|
||||
Two reasons not to replace it, and one reason that is NOT among them:
|
||||
|
||||
- **Enumeration.** `/api/sites/3/check` makes any future tenancy hole walkable
|
||||
by counting; a uuid makes it require a leak first. Every handler scopes by the
|
||||
session's client today, so this is defence in depth rather than the control —
|
||||
but this database holds biometric templates, and defence in depth is the point
|
||||
of a second layer.
|
||||
- **The payoff is now zero.** Eight tables carry a foreign key to `sites(id)`,
|
||||
against a live database, to make a field shorter that a client is already told
|
||||
not to use.
|
||||
- **NOT because ids must be minted offline.** Sites, visitors and visits are all
|
||||
created server-side with a database in hand. That argument holds for the
|
||||
agent's `event_id` — which is derived precisely so it needs no coordination —
|
||||
and it does not hold here; claiming it would be a defence of the status quo
|
||||
rather than a reason for it.
|
||||
|
||||
What DID need fixing is that the references were only stable by accident.
|
||||
Migration 013 makes `clients.slug`, `sites.slug`, `site_cameras.camera_id` and
|
||||
`visitors.number` immutable in the database, because 012 turned them from
|
||||
descriptive columns into identifiers other systems store:
|
||||
|
||||
- `clients.slug` is an MQTT topic segment the broker ACL is written against.
|
||||
Rename one and that tenant's whole estate is silently refused by the broker,
|
||||
with no way to tell the agents.
|
||||
- `sites.slug` is what a shop PC calls itself. A rename orphans the PC from the
|
||||
shop it is standing in.
|
||||
- `site_cameras.camera_id` lands in `visits.camera_id`, which is text and not a
|
||||
foreign key. A rename orphans every visit already attributed to the old name:
|
||||
the footfall is still there and no longer joins to a camera. This was
|
||||
half-enforced in `handleUpdateCamera` and nowhere else — the shape of a rule
|
||||
that holds until somebody adds a second write path.
|
||||
|
||||
A trigger rather than a CHECK, because a CHECK cannot see the old row and the
|
||||
rule is about the transition. **The display name is deliberately NOT frozen** —
|
||||
"TeNext Chennai", "Front door" — it is what a person reads, nothing keys on it,
|
||||
and a system that cannot fix a typo in a shop's name has confused the two.
|
||||
|
||||
### Three uuids on one arrival, three different answers
|
||||
|
||||
Asked of the row the feed actually returns, and they do not get the same reply:
|
||||
|
||||
- **`site_id`** had a reference all along and the feed was not sending it. A
|
||||
client could read the shop's *name* off an arrival and still had no way to ask
|
||||
for that shop except by uuid, which is the exact gap the scheme exists to
|
||||
close. `site_slug` now travels with it.
|
||||
- **`visit_id` stays a uuid, and needs no reference.** No route takes it; it is
|
||||
a key a client de-duplicates on, because delivery is at-least-once. Nobody
|
||||
says a visit id out loud.
|
||||
- **The uuid in a face URL must STAY random.** `visit_faces.id` is
|
||||
`gen_random_uuid()` and a derived or sequential one would let somebody walk a
|
||||
shop's customers by date — the same reason object keys in the bucket are
|
||||
random rather than derived from the event id. A readable identifier is right
|
||||
for a customer and wrong for the thing that points at their photograph.
|
||||
|
||||
And one field left with it: **`seq` is now `json:"-"`**. `visits.seq` is a plain
|
||||
bigserial, so it counts every visit on the *platform*, and shipping it put the
|
||||
total footfall of every customer we have on every row of every tenant's feed —
|
||||
the same German-tank estimate that decided `visitors.number` had to be per
|
||||
client. It was there as a convenience for *"have I fallen behind"*, nothing ever
|
||||
read it, and the cursor already answers that question without disclosing a
|
||||
number. The SSE event id was never the raw value; it has always been the opaque
|
||||
cursor.
|
||||
|
||||
The one test that broke was reading `seq` back off the wire to assert the cursor
|
||||
pointed at the last row of a burst. It asserts against the seeded position now —
|
||||
the property is unchanged, and the test can no longer see what a client cannot.
|
||||
|
||||
Fixture note: `embedding(seed)` fills every dimension with one value, so after
|
||||
L2 normalisation 0.31 and 0.62 are the **same direction** and the matcher
|
||||
correctly calls them one person. Tests that need several different people use
|
||||
`distinctFace(i)`, which is orthogonal per index.
|
||||
|
||||
|
||||
## Setting up on a new machine
|
||||
|
||||
1. Copy the `Behavision` folder **including `.env`** (gitignored, holds
|
||||
@@ -2171,3 +2348,437 @@ Two bugs, both found by running it after a reboot rather than by reading it.
|
||||
longer discarded, and the broker is waited for and reported on if it will not
|
||||
stay up. A failure there means the server cannot authenticate to its own
|
||||
broker, which is exactly what this script exists to surface early.
|
||||
|
||||
## Live view at head office, and what it honestly is
|
||||
|
||||
Live video exists and always has — on the shop PC, where the camera is:
|
||||
|
||||
- `GET /api/cameras/{id}/stream.mjpeg` on the engine's loopback API. Measured
|
||||
on the office camera: 1280×720, ~850 KB/s.
|
||||
- The desktop app's Live screen and the engine's own dashboard both render it.
|
||||
|
||||
Head office has it too, and this is the part that was got wrong first: the
|
||||
original answer here was "cannot cheaply", which conflated **true video** with
|
||||
**seeing the camera now**. Only the first needs infrastructure this does not
|
||||
have.
|
||||
|
||||
The shop PC is behind a router with no inbound route, so head office cannot
|
||||
pull that stream. What it CAN do is answer the agent's outbound requests, which
|
||||
is the shape of everything else in this system — so `LiveHub` + `cameras.Live`
|
||||
relay frames the other way: head office holds a poll open, the agent asks "is
|
||||
anyone watching?", and pushes JPEGs up for exactly as long as somebody is.
|
||||
|
||||
**It is ~13 frames a second of 640 px JPEG.** Measured end to end on the office
|
||||
camera: 131 frames in 10 s, 20.3 KB each, **259 KB/s**, and zero duplicates.
|
||||
|
||||
The rate is not a guess. The engine re-serves its latest frame until the
|
||||
pipeline produces a new one, so polling faster than it encodes returns the same
|
||||
picture: 93 polls in 6 s yielded 72 distinct frames. So the relay polls a little
|
||||
ahead of the engine and **drops frames identical to the last one by hash** —
|
||||
which lets the rate follow the camera rather than a constant, and means every
|
||||
byte on the wire is a picture the viewer has not seen.
|
||||
|
||||
### Why this is MJPEG and not the camera's own H.264
|
||||
|
||||
The obviously better design is passthrough: every CCTV camera already produces
|
||||
compressed video, and its **sub-stream** is exactly the right size for a live
|
||||
view. Probed on the office camera: main `/ch0_0.264` is 2304×1296 @ 15 fps, sub
|
||||
`/ch0_1.264` is **800×448 @ 15 fps**. Relaying that untouched would be smoother
|
||||
than this, cost less bandwidth, and use no CPU at all — no decode, no encode.
|
||||
|
||||
**It cannot be done on this camera, and the reason is worth recording: both
|
||||
streams are H.265.** The file names end in `.264`; the codec is HEVC. A browser
|
||||
plays H.264 everywhere and H.265 only on some platforms, so a passthrough relay
|
||||
cannot rely on it — and transcoding HEVC→H.264 on the shop PC would put a video
|
||||
encoder on the machine that is already doing the recognition.
|
||||
|
||||
So the choice is not MJPEG-versus-video in the abstract. It is: **re-encode
|
||||
frames and work on every camera, or pass through and work only on H.264
|
||||
cameras.** This does the first. Passthrough (RTSP → fMP4 → Media Source
|
||||
Extensions, no re-encode) is a well-understood build on top of the same relay
|
||||
and is the right upgrade for an estate of H.264 cameras — including this one, if
|
||||
its sub-stream is switched to H.264 in the camera's own settings.
|
||||
|
||||
`probe_source` therefore reports `codec`, because it decides what is possible
|
||||
and an installer can usually change it. Otherwise the only way to learn it is to
|
||||
read RTSP by hand, which is how this was found.
|
||||
|
||||
True sub-second video with no re-encode at any codec is WebRTC. Worth noting
|
||||
that the earlier claim here — that it needs a TURN server — is wrong: the server
|
||||
has a public address, so a shop PC behind NAT connects to it directly and TURN
|
||||
is only needed when *neither* side is reachable.
|
||||
|
||||
The browser cannot be pointed straight at the shop PC even on one LAN: the
|
||||
engine's API is Basic-authenticated with a credential it generates locally and
|
||||
never sends anywhere, and shipping that to the cloud so a web page could use it
|
||||
would put the key to the biometric API and the live face feed in the server's
|
||||
database.
|
||||
|
||||
**Nothing is uploaded when nobody is looking**, and that is the entire cost
|
||||
argument:
|
||||
|
||||
- `Publish` returns false once the last viewer has gone, which is what tells the
|
||||
agent to stop pushing. If it were ever optimistic every shop PC in an estate
|
||||
would upload continuously.
|
||||
- Interest lapses on a timer refreshed by each viewer as it reads, so a browser
|
||||
that vanishes without saying so — the normal way a tab closes — stops the
|
||||
upload within seconds.
|
||||
- One push is capped at five minutes. A tab left open for a week must not leave
|
||||
a shop uploading for a week; a viewer who is still there simply reconnects.
|
||||
- Only one camera streams at a time in the UI. A grid that went live all at once
|
||||
would put an estate's worth of cameras on the wire because somebody opened a
|
||||
page.
|
||||
|
||||
**`LiveHub` is the exact opposite of the arrivals `Hub`, deliberately.** There a
|
||||
doorbell pushes nothing because nothing may be lost. Here a dropped frame is the
|
||||
*correct* outcome: each viewer has a one-slot buffer and a full slot is
|
||||
overwritten, because the only frame worth having is the newest one and a queue
|
||||
would show an ever-growing delay behind the shop instead of dropping back to
|
||||
live.
|
||||
|
||||
Other decisions worth keeping:
|
||||
|
||||
- **Ownership is proved once, before anything streams.** Everything after that
|
||||
point is keyed on a camera id and a hub does not know whose camera it holds —
|
||||
and a camera id is not a secret. An agent pushing is checked against its own
|
||||
site for the same reason.
|
||||
- **The agent's poll is held open by the server** rather than answered at once.
|
||||
Polling every few seconds puts a floor under how quickly a view can start;
|
||||
polling slowly puts a ceiling on it. Holding it means pressing Live reaches
|
||||
the shop PC immediately and an idle site costs about two requests a minute.
|
||||
- **One request carries many frames**, each prefixed with its length. At a few
|
||||
frames a second, per-request overhead and TLS handshakes would cost more than
|
||||
the pictures.
|
||||
- **The engine does the re-encode** (`frame.jpg?width=&quality=`). It already
|
||||
has OpenCV open and the frame decoded; a scaler in the agent would be the same
|
||||
work twice. On demand only — a camera nobody watches must not pay for a second
|
||||
encode it will never use.
|
||||
- **Duplicate frames are dropped by hash before they are sent.** Without it a
|
||||
fifth of the bandwidth was the same picture twice, and the poll rate could not
|
||||
safely run ahead of the engine.
|
||||
- **The Live button is offered even when the card says the camera is down.**
|
||||
`connected` is head office's last report and can be two minutes stale, so
|
||||
gating on it hid the button during every reconnect — and "is that camera
|
||||
really down?" is exactly when somebody wants to look. A hidden control says
|
||||
*"you cannot"* where the honest answer is *"here is why"*, which the live view
|
||||
gives: it distinguishes a camera that is not connecting from a shop PC that is
|
||||
not answering.
|
||||
|
||||
Head office also still shows the camera's **latest frame** on the cards,
|
||||
refreshed every 60 s, which is what a page of cameras should cost when nobody
|
||||
has asked to watch one.
|
||||
|
||||
### The picture only worked if you had an S3 bucket
|
||||
|
||||
Which meant that on any deployment without object storage — every local install,
|
||||
and any self-hosted customer who does not want a bucket — `attachSnapshots`
|
||||
returned *"This system is not storing images"* for every camera, **forever**, on
|
||||
the two screens whose entire job is to show the camera. Making those screens
|
||||
picture-led is what turned a missing feature into a wall of empty tiles.
|
||||
|
||||
`migrations/009` adds `camera_snapshots`, and the agent falls back to
|
||||
`PUT /api/agent/cameras/{camera}/snapshot` when the presigned route answers
|
||||
`images_disabled`. What makes this safe in the database when face images are
|
||||
not:
|
||||
|
||||
- **One row per camera.** The primary key *is* the camera, so a snapshot
|
||||
replaces its predecessor. Storage is (cameras × ~100 KB) and does not grow
|
||||
with time or footfall. Face images grow with every visitor who ever walks in,
|
||||
which is exactly why they stay in a bucket.
|
||||
- It is a picture of a shop floor, not a face crop bound to an identity, and it
|
||||
carries no template.
|
||||
- `ON DELETE CASCADE` from the camera, so removing a camera removes its picture
|
||||
with no second place to remember.
|
||||
|
||||
Details that are not incidental:
|
||||
|
||||
- **The bucket stays primary where one exists.** Both routes exist because they
|
||||
are right for different deployments, not because one supersedes the other —
|
||||
a presigned PUT never passes the bytes through the API at all, which is what
|
||||
makes it the right route at estate scale.
|
||||
- **The fallback is chosen by a sentinel (`bridge.ErrImagesOff`), never by
|
||||
matching the message.** It decides which of two routes to take; getting it
|
||||
wrong from prose somebody later rewords would silently stop every camera
|
||||
picture in the estate.
|
||||
- **The camera is resolved by (site_id, camera_id) inside the INSERT**, so an
|
||||
agent cannot store a picture against another site's camera. The tenant and
|
||||
site come from the agent's credential, never the request.
|
||||
- **`snapshot_at` is written in the same transaction as the bytes.** It is what
|
||||
tells the camera list a picture exists; set apart, a camera could advertise
|
||||
one that is not there, which renders as a broken image on the one screen
|
||||
meant to show it.
|
||||
- **JPEG is verified from the magic bytes, not the Content-Type header**, and
|
||||
the body is bounded by `MaxBytesReader` at 2 MB. 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.
|
||||
- **The read is session-authenticated, not a signed link.** There is no third
|
||||
party to delegate to — the bytes are in our own database — and minting an
|
||||
unauthenticated URL so that `<img src>` could use it would add a way to reach
|
||||
a photograph of somebody's shop floor with no session at all.
|
||||
|
||||
That last decision has a front-end consequence, and it is why `Shot.jsx` exists:
|
||||
**an `<img>` cannot send an Authorization header.** A presigned bucket URL is
|
||||
absolute and carries its own signature, so a plain `src` loads it; a relative
|
||||
URL served by this server has to be fetched with the session and handed over as
|
||||
an object URL. `useAuthedImage` keys on the URL string rather than the
|
||||
`snapshot` object — which is a fresh object on every poll, so an effect
|
||||
depending on it would re-fetch ~90 KB per camera every few seconds — and revokes
|
||||
the object URL on cleanup, or a screen left open all afternoon holds hundreds of
|
||||
copies of the same photograph.
|
||||
|
||||
Verified against the real office camera with no object storage configured: a
|
||||
90,587-byte frame stored in Postgres, served as `image/jpeg` to a signed-in
|
||||
user, **401 without a session**, and rendered on both the Cameras and Shops
|
||||
cards.
|
||||
|
||||
## Claiming a headless PC, and telling a refused broker from an absent one
|
||||
|
||||
Both found by the local stack falling over on a memory-starved machine and
|
||||
needing to be brought back — the kind of thing that only surfaces when the
|
||||
software is operated rather than written.
|
||||
|
||||
### `behavision-agent claim <code>`
|
||||
|
||||
The desktop app has had a Setup screen since enrolment was built. The **headless
|
||||
agent had nothing**: `Bootstrap` lived only in `desktop/internal/cloud`, so the
|
||||
one configuration the agent binary exists for — a back-office PC with no window
|
||||
— could not be claimed at all. The only route was hand-editing `agent.json`,
|
||||
which is exactly the state that Setup screen was built to end.
|
||||
|
||||
`agent/pkg/enrol` is that call, and the CLI joins its arguments rather than
|
||||
demanding quotes: the code is printed in groups so it can be read aloud, and an
|
||||
operator pasting it will paste the spaces too. It clears `Standalone`, and a
|
||||
config that fails to save is **reported** — a claim that is not on disk works
|
||||
until the next restart and then silently is not claimed, which looks exactly
|
||||
like a wrong code.
|
||||
|
||||
### A rejected connection and an unreachable broker are not the same fault
|
||||
|
||||
Measured, on the real stack: after the site's broker password was re-rolled,
|
||||
mosquitto logged `not authorised` while the agent logged **`connect to
|
||||
tcp://... timed out`**. Those need opposite actions — re-link this PC, or go and
|
||||
look at the network — and paho's `SetConnectRetry` is why they collapse into
|
||||
one: it retries internally, so the connect token never completes and *every*
|
||||
failure arrives as a timeout.
|
||||
|
||||
`describeStall` asks the one question that separates them: can a TCP socket be
|
||||
opened to the broker at all? Reachable-but-not-accepted names the likely cause
|
||||
and the command to fix it; unreachable says to check the network. It does not
|
||||
claim to know the exact reason — the broker does not tell a rejected client why,
|
||||
and a TLS failure looks the same from here — so it reports what is known rather
|
||||
than guessing. Same rule as `artifact` vs `no_faces` in the commissioning
|
||||
verdicts, and the `connected` pointer being three states rather than two.
|
||||
|
||||
`brokerHostPort` parses with `net/url`, never by scanning for the first `:` —
|
||||
this package has already been bitten once by IPv6 literals being bracketed and
|
||||
full of them.
|
||||
|
||||
### The dev machine is 8 GB, not 16
|
||||
|
||||
Corrected in this file, because it feeds a real decision. Measured while the
|
||||
stack was up: **0.45 GB free** with a browser and Docker Desktop open, and
|
||||
Docker alone is allocated 4 GB of the 8. The engine's steady state is only
|
||||
~260 MB, so the OOM kill happened during a build (npm + go + Docker at once),
|
||||
not in normal running — but the margin is what makes `/api/health` reporting
|
||||
`recognition_model` worth checking after every restart. The local gallery
|
||||
already holds **17 embeddings tagged `w600k_mbf` and 19 tagged `w600k_r50`**:
|
||||
proof that the fallback has silently fired before, and that model-tagging is
|
||||
what stopped it corrupting anything.
|
||||
|
||||
## Accounts: how a second person gets one (`invitations`, migration 010)
|
||||
|
||||
A tenant had exactly the users `provision user` had created on the server's
|
||||
command line. That is not a missing screen, it is a missing product: a shop with
|
||||
an owner and four staff either shared one password between five people or raised
|
||||
a support ticket per person, and **a phone app for shop-floor staff could not
|
||||
exist at all** while there was only ever one account to sign in as.
|
||||
|
||||
Registration is by **invitation**, never open signup — the same line
|
||||
`handlers_admin.go` already draws around creating a company. An endpoint a
|
||||
stranger can call to create an account is a far larger thing to secure than one
|
||||
reachable only through somebody who already has one.
|
||||
|
||||
```
|
||||
POST /api/team/invitations manager+ -> the code, ONCE
|
||||
GET /api/auth/invitation?code=… unauthenticated preview
|
||||
POST /api/auth/register unauthenticated -> a SESSION
|
||||
```
|
||||
|
||||
- **The code decides the address and the role; the request decides only the
|
||||
password and a display name.** A code gets forwarded, screenshotted and
|
||||
pasted into chat, so if the body could name either, one staff invitation would
|
||||
be an owner account for anybody who saw it. `decode` rejects unknown fields,
|
||||
so a client cannot even ask — verified live: `unknown field "role"` → 400.
|
||||
- **`register` returns a session, not a 201.** Sending somebody who chose a
|
||||
password four seconds ago to a sign-in form to type it again is the sort of
|
||||
thing that gets blamed on the password.
|
||||
- **Single use is enforced by the UPDATE** (`used_at IS NULL` and the write are
|
||||
one statement) and the account is created **in the same transaction**. A spent
|
||||
invitation with no user is unusable and invisible; a user with the invitation
|
||||
still open is a second account waiting for whoever else has the code. Same
|
||||
rule, same reason, as agent enrolment.
|
||||
- **Unknown, expired, spent and revoked read identically.** The difference only
|
||||
helps somebody guessing, and the holder's next step is the same in all four.
|
||||
- **`admin` is not an invitable role.** A platform administrator is defined by
|
||||
having *no* client, so an invitation — which always carries one — could never
|
||||
mint a real one. What it *could* do is create the tenant-scoped `role='admin'`
|
||||
row that `adminOnly` exists to reject, so it is refused at the constraint.
|
||||
- **A manager cannot mint an owner.** Promoting somebody past yourself is an
|
||||
escalation, and it is the shape of this endpoint that matters if a manager
|
||||
account is ever taken over.
|
||||
- A failed attempt (short password, mistyped code) does **not** spend the
|
||||
invitation. One typo must not cost somebody their invitation.
|
||||
|
||||
### Removing access has to mean now
|
||||
|
||||
`PATCH /api/team/{id}` with `{"active": false}` revokes every session that user
|
||||
holds **in the same transaction**. An access token lives twelve hours, so
|
||||
without that, "remove their access" removes it sometime tomorrow — which is not
|
||||
what anybody pressing that button believes they have just done.
|
||||
|
||||
`OwnerCount` refuses the change that locks a company out of itself: the last
|
||||
active owner may not demote or deactivate themselves. There is no way back from
|
||||
that except a shell on the server, which is precisely what this surface exists
|
||||
to stop needing.
|
||||
|
||||
### Devices: the benefit of opaque tokens, finally collected
|
||||
|
||||
`GET /api/auth/sessions`, `DELETE /api/auth/sessions/{id}`,
|
||||
`POST /api/auth/sessions/revoke-others`.
|
||||
|
||||
The argument for a session table over JWTs was always that this system puts
|
||||
customer data on shop-floor PCs and staff phones that get lost, resold and
|
||||
shared — so *"log that device out, now"* has to actually work. **Nothing could
|
||||
list what was signed in, let alone stop one.** The cost was being paid and the
|
||||
benefit was not being collected.
|
||||
|
||||
- A person may revoke only their **own** sessions; the store scopes the update
|
||||
by `user_id`, because a session id travels in that list and is not a secret.
|
||||
Removing a colleague's access is a different question with a different answer
|
||||
(deactivate them).
|
||||
- **"Sign out everywhere else" keeps the caller's own session.** Somebody who
|
||||
has just lost a phone must not also be signed out of the device they are
|
||||
holding while they deal with it.
|
||||
- `device` is a coarse label (`"Chrome on Mac"`), never a fingerprint. The
|
||||
question it answers is only *"which of these is the one in my hand"*.
|
||||
|
||||
## Face images without an object-storage bucket (`visit_faces`, migration 011)
|
||||
|
||||
009 did this for camera snapshots and its own comment says why face images are
|
||||
different: *"Face images grow with every visitor who ever walks in, which is why
|
||||
they stay in a bucket."* That is true of images kept **per visit**, and it is
|
||||
exactly why this table is bounded to **one row per visitor** instead.
|
||||
|
||||
The gap it closes is the one 009 closed a level up. With no bucket the API
|
||||
answered *"This system is not storing customer photos"* for every arrival,
|
||||
forever — including on the mobile feed, whose entire purpose is to put a face in
|
||||
front of somebody so they can recognise the customer walking towards them. Every
|
||||
local install and every self-hosted customer who does not want an S3 account got
|
||||
nothing.
|
||||
|
||||
```
|
||||
engine data/outbox/<uuid>.jpg (only when app.store_faces is on)
|
||||
agent POST /api/agent/upload-url -> 501 images_disabled
|
||||
POST /api/agent/faces -> {"key": "db:<uuid>"}
|
||||
server visits.image_key = 'db:…'
|
||||
staff GET /api/visits -> {"image":{"available":true,
|
||||
"url":"/api/faces/<uuid>.jpg",
|
||||
"auth":true}}
|
||||
```
|
||||
|
||||
What makes this acceptable in Postgres when per-visit images are not:
|
||||
|
||||
- **The engine still gates capture.** `app.store_faces` is false by default and
|
||||
no crop is written without it. This changes what happens to an image that
|
||||
already exists; it does not change whether one is taken.
|
||||
- **One row survives per visitor.** `RecordVisit` prunes the previous row as it
|
||||
links a newer one, so storage is (customers × ~20 KB) — it grows with the
|
||||
customer base, not with footfall. A shop seen by 5,000 people holds ~100 MB
|
||||
whether they visit once or a thousand times.
|
||||
- **Nothing reads a superseded face anyway.** Every surface shows the customer's
|
||||
latest view, which is what `VisitorImageKey` has always returned.
|
||||
- **Orphans are swept.** An agent uploads before the server has decided who the
|
||||
person is, so a row is briefly unreferenced by design — and permanently so if
|
||||
the visit that would have claimed it never arrives. That is a stored
|
||||
photograph of a real person that erasure could never reach, because erasure
|
||||
finds images through the visitor and this row has none.
|
||||
|
||||
The bucket stays primary wherever one exists: a presigned PUT never passes the
|
||||
bytes through the API at all, which is what makes it the right route at estate
|
||||
scale. The fallback is chosen by the **sentinel** `bridge.ErrImagesOff`, never
|
||||
by matching a message — getting that wrong from prose somebody later rewords
|
||||
would silently stop every customer photo in the estate. Same rule the camera
|
||||
snapshot fallback already follows.
|
||||
|
||||
### `UPDATE … RETURNING` returns the value AFTER the update
|
||||
|
||||
The prune's first version read the superseded keys with
|
||||
`UPDATE visits SET image_key = '' … RETURNING image_key`. Postgres returns the
|
||||
**new** row, so every key came back as the empty string it had just been set to,
|
||||
the delete list was always empty, and `visit_faces` grew with footfall exactly
|
||||
as if the prune did not exist. The visit rows looked perfectly correct; only the
|
||||
row count gave it away.
|
||||
|
||||
It is one CTE now — `doomed` reads the pre-image and drives both the update and
|
||||
the delete — which cannot have that bug. **The in-memory fake would have agreed
|
||||
with either version**; only `TestLiveOnlyOneFaceSurvivesPerVisitor` against a
|
||||
real Postgres caught it, which is the whole reason the live store tests exist.
|
||||
|
||||
### `Image.auth`, and one function that decides where a photo is
|
||||
|
||||
`s.imageFor(key)` is the single place that turns a stored key into the `Image` a
|
||||
client receives — the arrivals feed, the live stream and the customer record all
|
||||
go through it. There are now two places an image can live and four distinct
|
||||
reasons there may not be one, and computing that twice is how the shops screen
|
||||
once ended up labelled **Working** in green directly above *"2 of 3 cameras not
|
||||
connecting"*.
|
||||
|
||||
`auth: true` says the URL is one of ours and needs the session's bearer, rather
|
||||
than a presigned link carrying its own signature. It exists because the two are
|
||||
genuinely different to fetch and **a client cannot tell them apart by looking**:
|
||||
|
||||
- A browser `<img>` **cannot** load the authenticated one — no header — so the
|
||||
web app fetches it and hands over an object URL (`Shot.jsx`).
|
||||
- A **mobile** image view *can* attach the header and load it directly.
|
||||
- The **desktop** webview can do neither: a relative src resolves against
|
||||
`wails://`, not the cloud. `cloud.VisitorImage` therefore fetches the bytes in
|
||||
Go, where the session already lives, and returns a `data:` URI. The
|
||||
alternative — a local proxy inside the app holding the session — is a second
|
||||
authenticated surface on a shop PC to get wrong.
|
||||
|
||||
**Both signals are accepted, and that is not belt-and-braces.** A relative URL
|
||||
always needs the session; there is no public one. Trusting only the flag broke
|
||||
every shop card the moment `Sites.jsx`'s `bestView()` rebuilt a partial
|
||||
`{url, at}` copy and dropped it — found by opening the page, not by a test. The
|
||||
flag adds only the case a URL cannot express: an absolute link that still needs
|
||||
a bearer, which arrives the first time object storage is served from this host.
|
||||
|
||||
The bytes endpoint writes **no audit row**. Every read of a face is recorded
|
||||
where the *link* is handed out — one row per arrivals page, one per customer
|
||||
record — and the bucket route's bytes never touch this server, so counting the
|
||||
fetch as well would count one deployment twice and the other once.
|
||||
|
||||
`ago()` clamps at zero and renders a future timestamp as *"just now"*. That is
|
||||
right for a heartbeat whose clock runs slightly ahead and completely wrong for
|
||||
an expiry: a code valid for a week read *"expires just now"*, which tells the
|
||||
operator not to bother handing it over. `until()` is its opposite number.
|
||||
|
||||
### Verified live, 5 September 2026
|
||||
|
||||
Against real Postgres, on the demo tenant:
|
||||
|
||||
- Owner invites a staff member → code minted once → unauthenticated preview
|
||||
names the company, address and role → a body naming `role` or `email` is
|
||||
refused → proper redemption returns a **signed-in session** → replay 404s.
|
||||
- Staff can read arrivals, shops and the team; **cannot** invite (403).
|
||||
- Two devices listed, the calling one marked `current`; revoking the phone 401s
|
||||
its token immediately while the till keeps working.
|
||||
- Deactivating a member 401s their live session **at once**, and they cannot
|
||||
sign back in. The only owner cannot demote themselves (409 `last_owner`).
|
||||
- Agent enrols → `upload-url` answers **501 images_disabled** → falls back to
|
||||
`POST /api/agent/faces` → a 92,405-byte office-camera JPEG stored in Postgres,
|
||||
served as `image/jpeg` to the owner, **401 with no session**, **404 to another
|
||||
tenant**, and rendered in the arrivals feed avatar in a real browser.
|
||||
- HTML, PDF, GIF and empty bodies are all refused as face images: the check is
|
||||
on the magic bytes, never the `Content-Type` header, because this endpoint
|
||||
stores what it is handed and serves it back to a browser.
|
||||
|
||||
2
RUN.md
2
RUN.md
@@ -251,7 +251,7 @@ the frozen engine once to prove it runs, and compiles the installer.
|
||||
3. Launch from the Start menu. **Set this PC up on its own** — no code needed.
|
||||
4. Cameras → Add camera → pick the make → Test connection → Save. The feed must
|
||||
appear with no restart.
|
||||
5. Check `/api/health` reports `recognition_model`. On a 16 GB machine the
|
||||
5. Check `/api/health` reports `recognition_model`. On a small machine the
|
||||
166 MB r50 can lose the fallback chain to the 13 MB mbf, and embeddings are
|
||||
model-tagged, so which one wins decides whether a gallery carries over.
|
||||
6. Sign out of the tray (Quit) — recognition must stop with it. Reboot; the app
|
||||
|
||||
80
agent/cmd/behavision-demo-pack/main.go
Normal file
80
agent/cmd/behavision-demo-pack/main.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Command behavision-demo-pack seals a camera list into demo-cameras.enc for a
|
||||
// demo release. It runs on the machine that builds the release and is never
|
||||
// shipped.
|
||||
//
|
||||
// behavision-demo-pack -cameras cameras.json -out demo-cameras.enc
|
||||
//
|
||||
// Prints the unlock code exactly once. It is not stored anywhere; a code you
|
||||
// can look up later is a code anyone with access to the build machine holds.
|
||||
// Lose it and seal again.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/demo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
in := flag.String("cameras", "", "JSON array of cameras (id, host, port, path, username, password)")
|
||||
out := flag.String("out", "demo-cameras.enc", "sealed bundle to write")
|
||||
flag.Parse()
|
||||
if *in == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: behavision-demo-pack -cameras cameras.json [-out demo-cameras.enc]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(*in)
|
||||
if err != nil {
|
||||
die("read cameras: %v", err)
|
||||
}
|
||||
var cams []demo.Camera
|
||||
if err := json.Unmarshal(raw, &cams); err != nil {
|
||||
die("cameras.json: %v", err)
|
||||
}
|
||||
if len(cams) == 0 {
|
||||
die("no cameras in %s", *in)
|
||||
}
|
||||
for i, c := range cams {
|
||||
switch {
|
||||
case c.ID == "":
|
||||
die("camera %d has no id", i)
|
||||
case c.Host == "":
|
||||
die("camera %q has no host", c.ID)
|
||||
case c.Path == "":
|
||||
die("camera %q has no path - the stream path is the field nobody can guess", c.ID)
|
||||
}
|
||||
}
|
||||
// Re-marshal so only the fields the engine accepts travel, in a stable
|
||||
// shape, whatever extra keys the input happened to carry.
|
||||
plain, err := json.Marshal(cams)
|
||||
if err != nil {
|
||||
die("marshal: %v", err)
|
||||
}
|
||||
|
||||
code, err := demo.NewCode()
|
||||
if err != nil {
|
||||
die("code: %v", err)
|
||||
}
|
||||
sealed, err := demo.Seal(code, plain)
|
||||
if err != nil {
|
||||
die("seal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*out, sealed, 0o644); err != nil {
|
||||
die("write: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n sealed %d camera(s) into %s (%d bytes)\n\n", len(cams), *out, len(sealed))
|
||||
fmt.Printf(" unlock code: %s\n\n", code)
|
||||
fmt.Println(" Shown once. Give it to whoever runs behavision-setup, by voice")
|
||||
fmt.Println(" or message - not in the same place as the zip.")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, " "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
543
agent/cmd/behavision-setup/main.go
Normal file
543
agent/cmd/behavision-setup/main.go
Normal file
@@ -0,0 +1,543 @@
|
||||
// Command behavision-setup prepares a shop PC to run the recognition engine.
|
||||
//
|
||||
// It exists because the engine is Python and the rest of the product is Go.
|
||||
// The Go halves cross-compile to Windows from any machine; the engine, frozen
|
||||
// with PyInstaller, does not - PyInstaller bundles the interpreter and native
|
||||
// wheels of the machine it runs on, so a frozen engine can only be built on
|
||||
// Windows. That single fact was the whole reason a release could not be cut.
|
||||
//
|
||||
// So this installs the engine from source instead of shipping it frozen: find
|
||||
// a Python, build a private virtual environment beside the database, install
|
||||
// the engine into it, fetch the models, and record how to start it. Everything
|
||||
// in the release can then be built anywhere.
|
||||
//
|
||||
// The trade, stated plainly because whoever runs this is standing in a shop:
|
||||
// it needs Python and a working internet connection at install time, and it
|
||||
// takes minutes rather than seconds. A frozen build needs neither. What it
|
||||
// buys is a release that exists.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
"github.com/loyaly/behavision-agent/pkg/demo"
|
||||
"github.com/loyaly/behavision-agent/pkg/engine"
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// The engine needs 3.10; nothing here works below it and the failure would
|
||||
// otherwise arrive as a syntax error deep inside a dependency.
|
||||
const minMinor = 10
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\n Setup did not finish: %v\n\n", err)
|
||||
pause()
|
||||
os.Exit(1)
|
||||
}
|
||||
pause()
|
||||
}
|
||||
|
||||
func run() error {
|
||||
fmt.Println()
|
||||
fmt.Println(" Behavision setup")
|
||||
fmt.Println(" ----------------")
|
||||
fmt.Println()
|
||||
|
||||
state := paths.StateRoot()
|
||||
src, err := engineSource()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" engine source %s\n", src)
|
||||
fmt.Printf(" install into %s\n", state)
|
||||
fmt.Println()
|
||||
|
||||
if err := paths.EnsureState(); err != nil {
|
||||
return fmt.Errorf("could not create %s: %w", state, err)
|
||||
}
|
||||
|
||||
// A demo release ships its cameras sealed. Ask for the code NOW, before
|
||||
// the ten-minute download, so a mistyped one costs seconds; the cameras
|
||||
// are actually added at the end, through the running engine.
|
||||
demoCams, err := unlockDemo(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if demoCams != nil {
|
||||
step("Demo cameras", fmt.Sprintf("%d unlocked", len(demoCams)))
|
||||
}
|
||||
|
||||
py, ver, err := findPython()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
step("Python", fmt.Sprintf("%s (%s)", ver, py))
|
||||
|
||||
venv := filepath.Join(state, "runtime")
|
||||
if err := makeVenv(py, venv); err != nil {
|
||||
return err
|
||||
}
|
||||
vpy := venvPython(venv)
|
||||
step("Virtual environment", venv)
|
||||
|
||||
// The engine reads its settings from <state>/config/default.yaml and will
|
||||
// seed that from beside its own code on first run - which works when its
|
||||
// code is a checkout or a frozen folder and not when it is a package in
|
||||
// site-packages, where there is no config beside it. Seeded here, from the
|
||||
// copy the release ships. Never overwritten: an upgrade must not revert an
|
||||
// operator's thresholds.
|
||||
if err := seedConfig(src, state); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Settings", filepath.Join(state, "config", "default.yaml"))
|
||||
|
||||
// --upgrade so re-running after a new release replaces the engine rather
|
||||
// than leaving the old one in place and reporting success.
|
||||
if err := pipInstall(vpy, src); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Engine and dependencies", "installed")
|
||||
|
||||
if err := runEngine(vpy, "setup-models"); err != nil {
|
||||
return fmt.Errorf("downloading the recognition models: %w", err)
|
||||
}
|
||||
step("Recognition models", "downloaded")
|
||||
|
||||
if err := writeConfig(vpy); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Startup settings", filepath.Join(state, "agent.json"))
|
||||
|
||||
// Proving it starts is the point. An installer that reports success and
|
||||
// leaves a shop with an engine that will not run has done worse than
|
||||
// failing: the failure surfaces later, to someone who did not install it.
|
||||
if err := smokeTest(vpy, demoCams); err != nil {
|
||||
return fmt.Errorf("the engine installed but would not start: %w", err)
|
||||
}
|
||||
step("Engine starts and answers", "verified")
|
||||
if demoCams != nil {
|
||||
step("Demo cameras", "added to the engine")
|
||||
// No head office in a demo. Without this the app opens on "type an
|
||||
// installation code" and sits there; with it, it opens on Live.
|
||||
if err := markStandalone(); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Head office", "none - running on this PC only")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
|
||||
fmt.Println(" It appears in the system tray; right-click there to stop it.")
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func step(label, detail string) {
|
||||
fmt.Printf(" [ok] %-24s %s\n", label, detail)
|
||||
}
|
||||
|
||||
// engineSource finds the Python source shipped beside this executable. Beside,
|
||||
// not downloaded: the engine and the app must be the same release, and a
|
||||
// version skew between them is the class of bug nobody can reproduce.
|
||||
func engineSource() (string, error) {
|
||||
candidates := []string{
|
||||
filepath.Join(paths.InstallRoot(), "engine-src"),
|
||||
filepath.Join(paths.InstallRoot(), "..", "engine-src"),
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(wd, "engine-src"), wd)
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(filepath.Join(c, "pyproject.toml")); err == nil {
|
||||
abs, _ := filepath.Abs(c)
|
||||
return abs, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("could not find the engine source (expected an " +
|
||||
"engine-src folder with pyproject.toml beside this program). " +
|
||||
"Unzip the whole release together rather than moving this file out of it")
|
||||
}
|
||||
|
||||
// findPython returns the first interpreter that is new enough.
|
||||
//
|
||||
// `py -3` first on Windows: the launcher is what the official installer puts
|
||||
// on PATH, and `python` there is often the Microsoft Store stub that prints an
|
||||
// advert and exits 9009 instead of running anything.
|
||||
func findPython() (string, string, error) {
|
||||
type cand struct {
|
||||
exe string
|
||||
args []string
|
||||
}
|
||||
var cands []cand
|
||||
if runtime.GOOS == "windows" {
|
||||
cands = append(cands, cand{"py", []string{"-3"}})
|
||||
}
|
||||
cands = append(cands, cand{"python3", nil}, cand{"python", nil})
|
||||
|
||||
var tried []string
|
||||
for _, c := range cands {
|
||||
exe, err := exec.LookPath(c.exe)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
args := append(append([]string{}, c.args...), "-c",
|
||||
"import sys;print('%d.%d'%sys.version_info[:2])")
|
||||
out, err := exec.Command(exe, args...).Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ver := strings.TrimSpace(string(out))
|
||||
tried = append(tried, c.exe+" "+ver)
|
||||
if major, minor, ok := parseVer(ver); ok && (major > 3 || (major == 3 && minor >= minMinor)) {
|
||||
full := exe
|
||||
if len(c.args) > 0 {
|
||||
full = exe + " " + strings.Join(c.args, " ")
|
||||
}
|
||||
return full, "Python " + ver, nil
|
||||
}
|
||||
}
|
||||
|
||||
msg := "no Python 3.10 or newer was found on this PC.\n\n" +
|
||||
" Install it from https://www.python.org/downloads/windows/\n" +
|
||||
" and tick \"Add python.exe to PATH\" on the first screen,\n" +
|
||||
" then run this again."
|
||||
if len(tried) > 0 {
|
||||
msg += "\n\n Found, but too old: " + strings.Join(tried, ", ")
|
||||
}
|
||||
return "", "", errors.New(msg)
|
||||
}
|
||||
|
||||
func parseVer(s string) (int, int, bool) {
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) < 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
major, err1 := strconv.Atoi(parts[0])
|
||||
minor, err2 := strconv.Atoi(parts[1])
|
||||
return major, minor, err1 == nil && err2 == nil
|
||||
}
|
||||
|
||||
// splitLauncher turns `py -3` back into a command and its arguments.
|
||||
func splitLauncher(s string) (string, []string) {
|
||||
f := strings.Fields(s)
|
||||
if len(f) == 0 {
|
||||
return s, nil
|
||||
}
|
||||
return f[0], f[1:]
|
||||
}
|
||||
|
||||
func venvPython(venv string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(venv, "Scripts", "python.exe")
|
||||
}
|
||||
return filepath.Join(venv, "bin", "python")
|
||||
}
|
||||
|
||||
// makeVenv builds the engine's own interpreter under the writable state root.
|
||||
//
|
||||
// A virtual environment rather than the system Python: a shop PC may have
|
||||
// Python there for something else, and pinning numpy below 2.0 - which the
|
||||
// engine requires - inside a shared interpreter is how you break the other
|
||||
// thing months later, silently.
|
||||
func makeVenv(py, venv string) error {
|
||||
if _, err := os.Stat(venvPython(venv)); err == nil {
|
||||
return nil // already built; pip below brings it up to date
|
||||
}
|
||||
exe, args := splitLauncher(py)
|
||||
args = append(args, "-m", "venv", venv)
|
||||
return stream(exec.Command(exe, args...), "creating the virtual environment")
|
||||
}
|
||||
|
||||
func pipInstall(vpy, src string) error {
|
||||
fmt.Println(" Installing the engine and its libraries. This downloads a few")
|
||||
fmt.Println(" hundred megabytes and takes a while on a slow connection.")
|
||||
fmt.Println()
|
||||
if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade",
|
||||
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A wheel if the release ships one - nothing to build on the shop PC, and
|
||||
// pip never has to touch the folder the release was unzipped into.
|
||||
//
|
||||
// That matters more than it sounds: `pip install <folder>` makes setuptools
|
||||
// write behavision.egg-info INTO that folder, and the folder is read-only
|
||||
// whenever the release was unzipped somewhere sensible - Program Files, or
|
||||
// the shared drive INSTALL.txt says is fine. Found by running this in a
|
||||
// container with the source mounted read-only: "could not create
|
||||
// 'behavision.egg-info': Read-only file system". Falling back to source
|
||||
// copies it somewhere writable first, for the same reason.
|
||||
if wheels, _ := filepath.Glob(filepath.Join(src, "behavision-*.whl")); len(wheels) > 0 {
|
||||
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]),
|
||||
"installing the engine")
|
||||
}
|
||||
tmp, err := os.MkdirTemp("", "behavision-src-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
if err := copyTree(src, tmp); err != nil {
|
||||
return fmt.Errorf("staging the engine source: %w", err)
|
||||
}
|
||||
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", tmp),
|
||||
"installing the engine")
|
||||
}
|
||||
|
||||
// seedConfig puts the shipped default.yaml where the engine will look for it,
|
||||
// and leaves an existing one alone.
|
||||
func seedConfig(src, state string) error {
|
||||
dst := filepath.Join(state, "config", "default.yaml")
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
from := filepath.Join(src, "config", "default.yaml")
|
||||
b, err := os.ReadFile(from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the release is missing config/default.yaml: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, b, 0o644)
|
||||
}
|
||||
|
||||
// copyTree copies a source tree, skipping the caches a checkout accumulates.
|
||||
func copyTree(from, to string) error {
|
||||
return filepath.WalkDir(from, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(from, path)
|
||||
if d.IsDir() {
|
||||
if d.Name() == "__pycache__" || strings.HasSuffix(d.Name(), ".egg-info") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return os.MkdirAll(filepath.Join(to, rel), 0o755)
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(to, rel), b, 0o644)
|
||||
})
|
||||
}
|
||||
|
||||
// runEngine runs the engine exactly as the app will later: same interpreter,
|
||||
// same environment. In particular ChildEnv sets BEHAVISION_DATA_DIR, without
|
||||
// which a pip-installed engine decides its state lives in site-packages and
|
||||
// downloads the models to a place the app never looks.
|
||||
func runEngine(vpy string, args ...string) error {
|
||||
full := append([]string{"-m", "behavision"}, args...)
|
||||
cmd := exec.Command(vpy, full...)
|
||||
cmd.Env = engine.ChildEnv("")
|
||||
return stream(cmd, "running the engine")
|
||||
}
|
||||
|
||||
// writeConfig records how to start the engine, in the same file and through
|
||||
// the same type the app reads, so the two cannot disagree about it.
|
||||
func writeConfig(vpy string) error {
|
||||
path := paths.AgentConfig()
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
// An absolute path: the app resolves a relative EngineExe against its own
|
||||
// install root under Program Files, and the interpreter is not there.
|
||||
cfg.EngineExe = vpy
|
||||
cfg.EngineArgs = []string{"-m", "behavision", "run"}
|
||||
if cfg.APIBase == "" {
|
||||
cfg.APIBase = "http://127.0.0.1:8010"
|
||||
}
|
||||
return cfg.Save(path)
|
||||
}
|
||||
|
||||
// smokeTest starts the engine exactly as the app will and waits for its API to
|
||||
// answer. Any reply counts, including 401: the engine invents its own
|
||||
// credential when none is configured, and a refusal proves it is serving.
|
||||
func smokeTest(vpy string, demoCams []demo.Camera) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
|
||||
cmd.Env = engine.ChildEnv("")
|
||||
var log strings.Builder
|
||||
cmd.Stdout, cmd.Stderr = &log, &log
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}()
|
||||
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
deadline := time.Now().Add(75 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := client.Get("http://127.0.0.1:8010/api/health")
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if demoCams == nil {
|
||||
return nil
|
||||
}
|
||||
// Through the engine's own Add Camera, not written to its file:
|
||||
// the store is what applies DPAPI to the password on Windows, so
|
||||
// this is how the credential ends up encrypted on disk rather
|
||||
// than sitting in cameras.json for anyone who can read
|
||||
// ProgramData.
|
||||
return addCameras(demoCams)
|
||||
}
|
||||
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("it did not answer within 75 seconds.\n\n%s",
|
||||
tail(log.String(), 15))
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return " " + strings.Join(lines, "\n ")
|
||||
}
|
||||
|
||||
// stream runs a command and shows its output. Shown, not swallowed: pip failing
|
||||
// on a missing build tool prints exactly what is wrong, and hiding that leaves
|
||||
// the operator with "setup failed" and nothing to act on.
|
||||
func stream(cmd *exec.Cmd, what string) error {
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s failed: %w", what, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pause keeps the window open. Double-clicked from Explorer, a console program
|
||||
// that finishes closes instantly and the operator sees nothing at all -
|
||||
// success and failure look identical.
|
||||
func pause() {
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
fmt.Print(" Press Enter to close. ")
|
||||
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
}
|
||||
|
||||
// unlockDemo returns the sealed cameras a demo release ships, or nil when this
|
||||
// is not a demo release. Asks for the unlock code on the console; three tries,
|
||||
// because a code is read down a phone and typed by hand.
|
||||
func unlockDemo(src string) ([]demo.Camera, error) {
|
||||
sealed, err := os.ReadFile(filepath.Join(src, "demo-cameras.enc"))
|
||||
if err != nil {
|
||||
return nil, nil // not a demo release
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(" This is a demo release with the cameras already set up.")
|
||||
fmt.Println(" It needs the unlock code you were given.")
|
||||
fmt.Println()
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
fmt.Print(" Unlock code: ")
|
||||
line, _ := in.ReadString('\n')
|
||||
plain, err := demo.Open(line, sealed)
|
||||
if err == nil {
|
||||
var cams []demo.Camera
|
||||
if err := json.Unmarshal(plain, &cams); err != nil {
|
||||
return nil, fmt.Errorf("the bundle unlocked but did not parse: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
return cams, nil
|
||||
}
|
||||
fmt.Printf(" %v\n", err)
|
||||
}
|
||||
return nil, errors.New("no valid unlock code after three tries. Check it " +
|
||||
"with whoever gave you this release and run setup again")
|
||||
}
|
||||
|
||||
// addCameras posts each demo camera to the running engine, with the credential
|
||||
// the engine generated for itself on first start.
|
||||
func addCameras(cams []demo.Camera) error {
|
||||
user, pass, err := engineCredential()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
for _, c := range cams {
|
||||
if c.Port == 0 {
|
||||
c.Port = 554
|
||||
}
|
||||
body, _ := json.Marshal(c)
|
||||
req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8010/api/cameras",
|
||||
bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding camera %s: %w", c.ID, err)
|
||||
}
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
// 409 is "already there" - a re-run of setup, which is allowed.
|
||||
if resp.StatusCode >= 300 && resp.StatusCode != http.StatusConflict {
|
||||
return fmt.Errorf("adding camera %s: %s: %s", c.ID, resp.Status,
|
||||
strings.TrimSpace(string(msg)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// engineCredential reads the Basic credential the engine wrote on its first
|
||||
// start. Empty when the engine is configured without one.
|
||||
func engineCredential() (string, string, error) {
|
||||
b, err := os.ReadFile(paths.APICredentials())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", nil
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
var user, pass string
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "username="); ok {
|
||||
user = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := strings.CutPrefix(line, "password="); ok {
|
||||
pass = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return user, pass, nil
|
||||
}
|
||||
|
||||
// markStandalone records that this PC runs on its own, through the same
|
||||
// config type the app reads.
|
||||
func markStandalone() error {
|
||||
path := paths.AgentConfig()
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Standalone = true
|
||||
return cfg.Save(path)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/loyaly/behavision-agent/pkg/cameras"
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
"github.com/loyaly/behavision-agent/pkg/engine"
|
||||
"github.com/loyaly/behavision-agent/pkg/enrol"
|
||||
"github.com/loyaly/behavision-agent/pkg/mqtt"
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
"github.com/loyaly/behavision-agent/pkg/spool"
|
||||
@@ -38,8 +39,15 @@ var version = "dev"
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "behavision-agent %s\n\nusage: %s <run|status|paths>\n",
|
||||
version, filepath.Base(os.Args[0]))
|
||||
fmt.Fprintf(os.Stderr, `behavision-agent %s
|
||||
|
||||
usage: %s <command>
|
||||
|
||||
run supervise the engine and report to head office (default)
|
||||
claim <code> link this PC to a shop, using an installation code
|
||||
status what this PC is and whether it is claimed
|
||||
paths where this install reads and writes
|
||||
`, version, filepath.Base(os.Args[0]))
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
@@ -53,6 +61,8 @@ func main() {
|
||||
err = cmdRun()
|
||||
case "status":
|
||||
err = cmdStatus()
|
||||
case "claim":
|
||||
err = cmdClaim(flag.Args()[1:])
|
||||
case "paths":
|
||||
err = cmdPaths()
|
||||
default:
|
||||
@@ -64,6 +74,67 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// cmdClaim is the headless half of onboarding.
|
||||
//
|
||||
// The desktop app has had a Setup screen for this; a back-office PC with no
|
||||
// window had nothing at all, so the only way to claim one was to hand-edit
|
||||
// agent.json - which is the state that screen was built to end.
|
||||
func cmdClaim(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: behavision-agent claim <installation code>\n" +
|
||||
"Ask whoever manages your shops for one - they can create it from\n" +
|
||||
"the Behavision platform, under the shop.")
|
||||
}
|
||||
// Joined rather than requiring quotes: the code is printed in groups for
|
||||
// reading aloud, and an operator pasting it will paste the spaces too.
|
||||
code := strings.Join(args, "")
|
||||
|
||||
if err := paths.EnsureState(); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(paths.AgentConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := cfg.CloudBase
|
||||
if v := os.Getenv("BEHAVISION_CLOUD"); v != "" {
|
||||
base = v
|
||||
}
|
||||
if base == "" {
|
||||
base = "https://mcp.loyaly.ai"
|
||||
}
|
||||
|
||||
b, err := enrol.Claim(context.Background(), base, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The slugs, not the uuids: the topic prefix is <client>.<site> and the
|
||||
// broker's ACL is written against exactly that username.
|
||||
cfg.ClientID = b.ClientSlug
|
||||
cfg.SiteID = b.SiteSlug
|
||||
cfg.SiteName = b.SiteName
|
||||
cfg.BrokerURL = b.MQTTURL
|
||||
cfg.BrokerUsername = b.MQTTUser
|
||||
cfg.BrokerPassword = b.MQTTPass
|
||||
cfg.AgentToken = b.AgentToken
|
||||
cfg.CloudBase = base
|
||||
// A PC that was running on its own and has now been linked is no longer
|
||||
// standalone.
|
||||
cfg.Standalone = false
|
||||
if err := cfg.Save(paths.AgentConfig()); err != nil {
|
||||
// Reported, never swallowed: a claim that is not on disk works until
|
||||
// the next restart and then silently is not claimed any more, which
|
||||
// looks exactly like a wrong code.
|
||||
return fmt.Errorf("could not save the settings: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("linked to %s (%s.%s)\n", b.SiteName, b.ClientSlug, b.SiteSlug)
|
||||
fmt.Printf("settings written to %s\n", paths.AgentConfig())
|
||||
fmt.Println("restart the agent for it to take effect.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdPaths() error {
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]string{
|
||||
"version": version,
|
||||
@@ -161,7 +232,7 @@ func cmdRun() error {
|
||||
// nothing - the URL was returned, logged and even exposed on the
|
||||
// desktop's status object, and never actually given to the engine.
|
||||
// A claimed shop PC published heartbeats and zero visits.
|
||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+hookURL)
|
||||
cmd.Env = engine.ChildEnv(hookURL)
|
||||
return cmd
|
||||
},
|
||||
LogWriter: logFile,
|
||||
@@ -203,6 +274,9 @@ func cmdRun() error {
|
||||
cloud.Upload = uploader.UploadBytes
|
||||
eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword)
|
||||
go cameras.New(eng, cloud, logger).Run(ctx)
|
||||
// The live relay, which uploads nothing until somebody at head office is
|
||||
// actually watching a camera.
|
||||
go cameras.NewLive(eng, cloud, logger).Run(ctx)
|
||||
|
||||
// Before the engine starts, so the engine can be launched already knowing
|
||||
// where to post its detections.
|
||||
|
||||
@@ -106,6 +106,18 @@ func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string,
|
||||
}
|
||||
|
||||
target, err := u.target(ctx)
|
||||
if errors.Is(err, ErrImagesOff) {
|
||||
// No object storage on this server. Send the bytes to the API itself,
|
||||
// which holds them for a deployment that has no bucket - the same
|
||||
// fallback camera snapshots already take, and chosen by the SENTINEL
|
||||
// rather than by matching the message, because a prose change would
|
||||
// otherwise silently stop every photo in the estate.
|
||||
//
|
||||
// Only after target() has spoken. The unclaimed case returns the same
|
||||
// sentinel from the guard at the top of this function, and a PC with no
|
||||
// credentials has no server to PUT to either.
|
||||
return u.uploadDirect(ctx, body)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -135,6 +147,54 @@ func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string,
|
||||
return target.Key, nil
|
||||
}
|
||||
|
||||
// uploadDirect posts the image to our own API, for a deployment with no bucket.
|
||||
//
|
||||
// Deliberately the second choice. A presigned PUT never passes a photograph
|
||||
// through the server at all, which is what makes it the right route wherever
|
||||
// object storage exists; this one is what stops "no S3 account" from meaning
|
||||
// "no customer photo, ever" on every local install and every self-hosted site.
|
||||
//
|
||||
// The server decides where it lands and returns the key, exactly as the
|
||||
// presigned route does. That symmetry is the point: the caller cannot tell
|
||||
// which route ran, so the queued visit, the read path and erasure all stay
|
||||
// single implementations.
|
||||
func (u *SpacesUploader) uploadDirect(ctx context.Context, body []byte) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
strings.TrimRight(u.BaseURL, "/")+"/api/agent/faces", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+u.Token)
|
||||
req.Header.Set("Content-Type", "image/jpeg")
|
||||
req.ContentLength = int64(len(body))
|
||||
|
||||
resp, err := u.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upload face: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotImplemented {
|
||||
// This server stores no images at all. Stop trying rather than retry
|
||||
// every visitor forever.
|
||||
return "", ErrImagesOff
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return "", fmt.Errorf("upload face returned %s: %s",
|
||||
resp.Status, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<10)).Decode(&out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.Key == "" {
|
||||
return "", errors.New("server stored the face but named no key for it")
|
||||
}
|
||||
return out.Key, nil
|
||||
}
|
||||
|
||||
func (u *SpacesUploader) target(ctx context.Context) (uploadTarget, error) {
|
||||
var out uploadTarget
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -200,3 +201,80 @@ func TestNoUploaderMeansNoImageAndNoLeftovers(t *testing.T) {
|
||||
t.Fatal("the local image was left on disk")
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment with no object storage must still get a photo onto the customer
|
||||
// record. Until the fallback existed, `images_disabled` meant every local
|
||||
// install and every self-hosted site showed no face for anybody, forever.
|
||||
func TestNoBucketFallsBackToTheServer(t *testing.T) {
|
||||
var askedURL, postedFace bool
|
||||
var gotBody []byte
|
||||
var gotAuth, gotType string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/agent/upload-url":
|
||||
askedURL = true
|
||||
// What a server with no bucket answers.
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
_, _ = w.Write([]byte(`{"error":"images_disabled"}`))
|
||||
case "/api/agent/faces":
|
||||
postedFace = true
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotType = r.Header.Get("Content-Type")
|
||||
gotBody, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"key":"db:11111111-1111-4111-8111-111111111111"}`))
|
||||
default:
|
||||
t.Errorf("unexpected request to %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()}
|
||||
img := []byte{0xFF, 0xD8, 0xFF, 0xE0, 'x', 'y', 'z'}
|
||||
key, err := u.UploadBytes(context.Background(), img)
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
if !askedURL {
|
||||
t.Error("the presigned route must be tried first - it is the right one where a bucket exists")
|
||||
}
|
||||
if !postedFace {
|
||||
t.Fatal("no fallback upload was made")
|
||||
}
|
||||
if !strings.HasPrefix(key, "db:") {
|
||||
t.Errorf("want the server's own key, got %q", key)
|
||||
}
|
||||
if !bytes.Equal(gotBody, img) {
|
||||
t.Error("the bytes sent are not the bytes given")
|
||||
}
|
||||
if gotAuth != "Bearer agent-token" || gotType != "image/jpeg" {
|
||||
t.Errorf("auth %q type %q", gotAuth, gotType)
|
||||
}
|
||||
}
|
||||
|
||||
// A server that stores no images AT ALL must stop the agent trying, rather than
|
||||
// have it retry every visitor forever. Distinct from a failure, which is why
|
||||
// it is a sentinel and not a message.
|
||||
func TestAServerThatStoresNothingSaysSoOnce(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()}
|
||||
_, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8, 0xFF, 0xE0})
|
||||
if !errors.Is(err, ErrImagesOff) {
|
||||
t.Fatalf("want ErrImagesOff so the caller stops trying, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An unclaimed PC has no server to send anything to. The fallback must not fire
|
||||
// there - it would be a request to nowhere on every single visit.
|
||||
func TestAnUnclaimedAgentDoesNotTryToUpload(t *testing.T) {
|
||||
u := &SpacesUploader{} // no BaseURL, no token
|
||||
if _, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8}); !errors.Is(err, ErrImagesOff) {
|
||||
t.Fatalf("want ErrImagesOff, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,14 @@ type Engine interface {
|
||||
type Cloud interface {
|
||||
Desired(ctx context.Context) ([]Desired, error)
|
||||
Report(ctx context.Context, rep Report) error
|
||||
// UploadSnapshot puts a JPEG in object storage and names it. Used for the
|
||||
// placement check's proof picture, which is transient.
|
||||
UploadSnapshot(ctx context.Context, jpeg []byte) (key string, err error)
|
||||
// PutSnapshot gets a camera's latest frame to head office by whichever
|
||||
// route this deployment has - the bucket, or the server itself when there
|
||||
// is none. An empty key means the server already stored it, so the state
|
||||
// report has nothing to carry.
|
||||
PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (key string, err error)
|
||||
}
|
||||
|
||||
// Local is a camera as the engine holds it.
|
||||
@@ -48,6 +55,9 @@ type Local struct {
|
||||
|
||||
// Desired is a camera as head office holds it.
|
||||
type Desired struct {
|
||||
// ID is head office's uuid for this camera; CameraID is the name the
|
||||
// engine on this PC knows it by. The live relay translates between them.
|
||||
ID string `json:"id"`
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
Host string `json:"host"`
|
||||
@@ -252,7 +262,9 @@ func (s *Syncer) reportWith(ctx context.Context, adopt []Desired) {
|
||||
// camera is down matters far more than having a picture of it,
|
||||
// and the picture is the part most likely to fail.
|
||||
if jpeg, err := s.Engine.Snapshot(ctx, c.ID); err == nil && len(jpeg) > 0 {
|
||||
if key, err := s.Cloud.UploadSnapshot(ctx, jpeg); err == nil {
|
||||
// An empty key is not a failure: it means this deployment has
|
||||
// no object storage and the server stored the picture itself.
|
||||
if key, err := s.Cloud.PutSnapshot(ctx, c.ID, jpeg); err == nil {
|
||||
st.SnapshotKey = key
|
||||
} else {
|
||||
s.logf("camera %s: snapshot upload failed: %v", c.ID, err)
|
||||
|
||||
@@ -59,6 +59,10 @@ type fakeCloud struct {
|
||||
reports []Report
|
||||
uploads int
|
||||
uploadErr error
|
||||
// direct records the cameras whose picture went to the server itself
|
||||
// rather than to object storage.
|
||||
direct []string
|
||||
directOnly bool
|
||||
}
|
||||
|
||||
func (f *fakeCloud) Desired(context.Context) ([]Desired, error) {
|
||||
@@ -68,6 +72,20 @@ func (f *fakeCloud) Report(_ context.Context, r Report) error {
|
||||
f.reports = append(f.reports, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PutSnapshot mirrors the real client: the bucket when there is one, the
|
||||
// server itself when there is not.
|
||||
func (f *fakeCloud) PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (string, error) {
|
||||
if f.directOnly {
|
||||
if f.uploadErr != nil {
|
||||
return "", f.uploadErr
|
||||
}
|
||||
f.direct = append(f.direct, cameraID)
|
||||
return "", nil
|
||||
}
|
||||
return f.UploadSnapshot(ctx, jpeg)
|
||||
}
|
||||
|
||||
func (f *fakeCloud) UploadSnapshot(context.Context, []byte) (string, error) {
|
||||
if f.uploadErr != nil {
|
||||
return "", f.uploadErr
|
||||
@@ -239,3 +257,28 @@ func TestAnEngineThatIsNotRunningIsNotAnError(t *testing.T) {
|
||||
t.Fatal("reported state it could not have observed")
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment with no object storage must still get its picture to head
|
||||
// office. Before this, the camera screen said "This system is not storing
|
||||
// images" for every camera, forever - on the one screen whose entire job is to
|
||||
// show the camera.
|
||||
func TestASnapshotStillReachesHeadOfficeWithNoObjectStorage(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Connected: true})
|
||||
c := &fakeCloud{directOnly: true}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
if len(c.direct) != 1 || c.direct[0] != "entrance" {
|
||||
t.Fatalf("the picture did not reach the server: %v", c.direct)
|
||||
}
|
||||
if len(c.reports) == 0 || len(c.reports[0].State) != 1 {
|
||||
t.Fatalf("no state was reported: %+v", c.reports)
|
||||
}
|
||||
// Empty, and that is the point: there is no object to name. A key here
|
||||
// would have head office try to presign a bucket it does not have.
|
||||
if key := c.reports[0].State[0].SnapshotKey; key != "" {
|
||||
t.Fatalf("the direct route reported an object key %q", key)
|
||||
}
|
||||
if !c.reports[0].State[0].Connected {
|
||||
t.Error("connected state was lost")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/bridge"
|
||||
)
|
||||
|
||||
// EngineClient talks to the recognition engine on this PC's loopback.
|
||||
@@ -95,8 +99,29 @@ func (e *EngineClient) Remove(ctx context.Context, id string) error {
|
||||
// trip. A camera that has not produced a frame yet answers 503, which is a
|
||||
// normal state on a just-added camera and not an error worth logging loudly.
|
||||
func (e *EngineClient) Snapshot(ctx context.Context, id string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
e.Base+"/api/cameras/"+url.PathEscape(id)+"/frame.jpg", nil)
|
||||
return e.Frame(ctx, id, 0, 0)
|
||||
}
|
||||
|
||||
// Frame fetches the latest frame, optionally re-encoded smaller.
|
||||
//
|
||||
// The live relay asks for ~640 px at quality 60 - about a third the bytes of
|
||||
// the full frame - because it sends several a second up a shop's uplink, where
|
||||
// the snapshot sends one a minute and can afford the detail. The engine does
|
||||
// the re-encode: it already has OpenCV open and the frame in memory, and
|
||||
// shipping a scaler into the agent to redo that would be the same work twice.
|
||||
func (e *EngineClient) Frame(ctx context.Context, id string, width, quality int) ([]byte, error) {
|
||||
q := url.Values{}
|
||||
if width > 0 {
|
||||
q.Set("width", strconv.Itoa(width))
|
||||
}
|
||||
if quality > 0 {
|
||||
q.Set("quality", strconv.Itoa(quality))
|
||||
}
|
||||
target := e.Base + "/api/cameras/" + url.PathEscape(id) + "/frame.jpg"
|
||||
if len(q) > 0 {
|
||||
target += "?" + q.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -192,6 +217,64 @@ func (c *CloudClient) UploadSnapshot(ctx context.Context, jpeg []byte) (string,
|
||||
return c.Upload(ctx, jpeg)
|
||||
}
|
||||
|
||||
// PutSnapshot gets a camera's latest frame to head office by whichever route
|
||||
// that deployment has.
|
||||
//
|
||||
// The bucket first: a presigned PUT goes straight to object storage and never
|
||||
// passes through the API, which is what makes it the right route at estate
|
||||
// scale. When there is no bucket the picture goes to the server itself, which
|
||||
// stores one row per camera. Without this second route head office reported
|
||||
// "This system is not storing images" for every camera forever, on the screen
|
||||
// whose entire job is to show the camera.
|
||||
//
|
||||
// The returned key is empty for the direct route - there is no object to name -
|
||||
// and the server records the picture as it stores it, so the state report has
|
||||
// nothing to carry.
|
||||
func (c *CloudClient) PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (string, error) {
|
||||
if c.Upload != nil {
|
||||
key, err := c.Upload(ctx, jpeg)
|
||||
if err == nil {
|
||||
return key, nil
|
||||
}
|
||||
// A bucket that is configured here but disabled at the server is the
|
||||
// ordinary case on a self-hosted install: fall through rather than
|
||||
// giving up, and let the direct route decide.
|
||||
if !isImagesDisabled(err) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", c.putSnapshotDirect(ctx, cameraID, jpeg)
|
||||
}
|
||||
|
||||
func (c *CloudClient) putSnapshotDirect(ctx context.Context, cameraID string, jpeg []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
|
||||
c.Base+"/api/agent/cameras/"+url.PathEscape(cameraID)+"/snapshot",
|
||||
bytes.NewReader(jpeg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Content-Type", "image/jpeg")
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("head office: %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isImagesDisabled recognises the server saying it has no object storage.
|
||||
//
|
||||
// A sentinel, not a string match on the message: this decides whether to take a
|
||||
// completely different route, and getting it wrong from prose that somebody
|
||||
// later rewords would silently stop every camera picture in the estate.
|
||||
func isImagesDisabled(err error) bool {
|
||||
return errors.Is(err, bridge.ErrImagesOff)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- probing
|
||||
|
||||
// Test opens the candidate stream once, without saving it.
|
||||
|
||||
243
agent/pkg/cameras/live.go
Normal file
243
agent/pkg/cameras/live.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package cameras
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Live relays camera frames to head office, but only while somebody is
|
||||
// watching.
|
||||
//
|
||||
// The engine serves MJPEG on this PC's loopback and this PC sits behind a
|
||||
// router with no inbound route, so head office cannot pull it. It can answer
|
||||
// our outbound requests, which is the shape of everything else here: we ask
|
||||
// "is anyone watching?", and push frames for as long as the answer is yes.
|
||||
//
|
||||
// It is a few frames a second of re-encoded JPEG, not 25 fps video. True video
|
||||
// needs WebRTC and a TURN server; this needs neither, and answers the question
|
||||
// somebody at head office is actually asking - what does that camera see right
|
||||
// now - at a cost a shop's uplink can carry.
|
||||
//
|
||||
// **Nothing is uploaded when nobody is looking.** That is the entire cost
|
||||
// argument, and it is why the wanted-check comes first and the push stops the
|
||||
// moment the server says the last viewer has gone.
|
||||
type Live struct {
|
||||
Engine *EngineClient
|
||||
Cloud *CloudClient
|
||||
Log *log.Logger
|
||||
FPS float64
|
||||
Width int
|
||||
Quality int
|
||||
pollDelay time.Duration
|
||||
}
|
||||
|
||||
// Defaults, measured against the office camera rather than guessed.
|
||||
//
|
||||
// The engine produces ~12 distinct frames a second, so asking for more than
|
||||
// that only re-sends pictures the viewer already has - which is why the poll
|
||||
// runs slightly ahead of it and identical frames are dropped rather than sent.
|
||||
// 640 px at quality 60 is ~20 KB, so a watcher costs ~200 KB/s at the full
|
||||
// rate, and a camera nobody is watching costs nothing at all.
|
||||
const (
|
||||
DefaultLiveFPS = 15.0
|
||||
DefaultLiveWidth = 640
|
||||
DefaultLiveQuality = 60
|
||||
)
|
||||
|
||||
func NewLive(eng *EngineClient, cloud *CloudClient, logger *log.Logger) *Live {
|
||||
return &Live{Engine: eng, Cloud: cloud, Log: logger,
|
||||
FPS: DefaultLiveFPS, Width: DefaultLiveWidth, Quality: DefaultLiveQuality}
|
||||
}
|
||||
|
||||
// Run waits for viewers and serves them until the context ends.
|
||||
func (l *Live) Run(ctx context.Context) {
|
||||
if l.Engine == nil || l.Cloud == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
wanted, err := l.Cloud.LiveWanted(ctx)
|
||||
if err != nil {
|
||||
// Unclaimed, offline, or head office is down. All three mean the
|
||||
// same thing here - nobody can be watching - so back off rather
|
||||
// than hammering, and keep the shop's own recognition untouched.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
l.sleep(ctx, 15*time.Second)
|
||||
continue
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
// The poll is held open by the server, so an empty answer already
|
||||
// means ~25 s passed. No extra delay.
|
||||
continue
|
||||
}
|
||||
for _, id := range wanted {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
l.serve(ctx, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serve pushes frames for one camera until the server says stop.
|
||||
func (l *Live) serve(ctx context.Context, cameraID string) {
|
||||
engineID, err := l.Cloud.LiveEngineID(ctx, cameraID)
|
||||
if err != nil {
|
||||
l.logf("live %s: %v", cameraID, err)
|
||||
l.sleep(ctx, 2*time.Second)
|
||||
return
|
||||
}
|
||||
interval := time.Duration(float64(time.Second) / l.fps())
|
||||
|
||||
// A pipe so frames can be written as they are grabbed while one request
|
||||
// carries all of them. A request per frame would spend more on handshakes
|
||||
// and headers than on pictures.
|
||||
pr, pw := io.Pipe()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- l.Cloud.PushLive(ctx, cameraID, pr) }()
|
||||
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
// The engine re-serves its latest frame until the pipeline produces a new
|
||||
// one, so polling faster than it encodes returns the SAME picture again.
|
||||
// Measured: 93 polls in 6 s yielded 72 distinct frames. Sending the
|
||||
// duplicates would cost a fifth of the bandwidth for nothing, so the poll
|
||||
// runs a little ahead of the engine and the repeats are dropped - which is
|
||||
// what lets the rate follow the camera instead of a guess.
|
||||
var lastSum [32]byte
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = pw.CloseWithError(context.Canceled)
|
||||
<-done
|
||||
return
|
||||
case err := <-done:
|
||||
// The server closed the request: the last viewer went away, or the
|
||||
// session cap was reached. Either way stop grabbing frames.
|
||||
_ = pw.Close()
|
||||
if err != nil {
|
||||
l.logf("live %s ended: %v", cameraID, err)
|
||||
}
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
jpeg, err := l.Engine.Frame(ctx, engineID, l.Width, l.Quality)
|
||||
if err != nil || len(jpeg) == 0 {
|
||||
// A camera that is reconnecting has no frame. Keep the request
|
||||
// open - the viewer sees the last frame rather than a dropped
|
||||
// stream, and the next tick may well have one.
|
||||
continue
|
||||
}
|
||||
if sum := sha256.Sum256(jpeg); sum == lastSum {
|
||||
continue
|
||||
} else {
|
||||
lastSum = sum
|
||||
}
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(jpeg)))
|
||||
if _, err := pw.Write(hdr[:]); err != nil {
|
||||
<-done
|
||||
return
|
||||
}
|
||||
if _, err := pw.Write(jpeg); err != nil {
|
||||
<-done
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Live) fps() float64 {
|
||||
if l.FPS <= 0 || l.FPS > 25 {
|
||||
// A ceiling rather than a target: duplicate frames are dropped, so
|
||||
// polling above what the engine encodes costs requests and no
|
||||
// bandwidth - but it is still work, on the PC doing the recognition.
|
||||
return DefaultLiveFPS
|
||||
}
|
||||
return l.FPS
|
||||
}
|
||||
|
||||
func (l *Live) sleep(ctx context.Context, d time.Duration) {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Live) logf(format string, args ...any) {
|
||||
if l.Log != nil {
|
||||
l.Log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ wire --
|
||||
|
||||
// LiveWanted asks head office which of this site's cameras are being watched.
|
||||
// The server holds the request open, so this returns promptly when somebody
|
||||
// presses Live and after ~25 s when nobody has.
|
||||
func (c *CloudClient) LiveWanted(ctx context.Context) ([]string, error) {
|
||||
var body struct {
|
||||
Cameras []string `json:"cameras"`
|
||||
}
|
||||
// Longer than the server's own wait, so a held request is not cut off by
|
||||
// our own client timeout and reported as a failure.
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
if err := c.do(ctx, http.MethodGet, "/api/agent/live", nil, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body.Cameras, nil
|
||||
}
|
||||
|
||||
// LiveEngineID maps head office's camera uuid to the name the engine knows,
|
||||
// which is the only name this PC can ask for a frame with.
|
||||
func (c *CloudClient) LiveEngineID(ctx context.Context, cameraID string) (string, error) {
|
||||
desired, err := c.Desired(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, d := range desired {
|
||||
if d.ID == cameraID {
|
||||
return d.CameraID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("camera %s is not one of this site's", cameraID)
|
||||
}
|
||||
|
||||
// PushLive streams frames until the server stops reading.
|
||||
func (c *CloudClient) PushLive(ctx context.Context, cameraID string, body io.Reader) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.Base+"/api/agent/cameras/"+cameraID+"/live", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
blob, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("head office: %s: %s", resp.Status, bytes.TrimSpace(blob))
|
||||
}
|
||||
var out struct {
|
||||
Frames int `json:"frames"`
|
||||
}
|
||||
_ = json.Unmarshal(blob, &out)
|
||||
return nil
|
||||
}
|
||||
114
agent/pkg/demo/bundle.go
Normal file
114
agent/pkg/demo/bundle.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Package demo seals a camera list so a release can carry it without carrying
|
||||
// the credentials in any usable form.
|
||||
//
|
||||
// The need: a demo build that installs with the office cameras already set up,
|
||||
// handed to people who should not be able to read the cameras' admin password
|
||||
// out of the zip. "Encode it" does not do that - anything the installer can
|
||||
// decode, anyone holding the installer can decode. So the bundle is encrypted
|
||||
// with a key that is NOT in the package: a short unlock code, generated when
|
||||
// the bundle is sealed, spoken or messaged to whoever runs setup, and typed
|
||||
// once. Without it the file is noise.
|
||||
//
|
||||
// The code is random, not chosen, so it is used as key material directly
|
||||
// (through SHA-256) rather than stretched with a KDF. A human-chosen
|
||||
// passphrase would need argon2 and a dependency; 120 random bits do not.
|
||||
package demo
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Magic identifies the file and the format version, so a future change can be
|
||||
// told apart from corruption instead of failing as "authentication failed".
|
||||
const magic = "BVDEMO1\n"
|
||||
|
||||
// Camera is one entry as the engine's Add Camera endpoint accepts it.
|
||||
type Camera struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Path string `json:"path"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
MaxWidth int `json:"max_width,omitempty"`
|
||||
}
|
||||
|
||||
// NewCode mints an unlock code: 15 random bytes as 24 base32 characters in
|
||||
// four groups, the same shape as an installation code, for the same reason -
|
||||
// it gets read down a phone.
|
||||
func NewCode() (string, error) {
|
||||
raw := make([]byte, 15)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)
|
||||
return fmt.Sprintf("%s-%s-%s-%s", s[0:6], s[6:12], s[12:18], s[18:24]), nil
|
||||
}
|
||||
|
||||
// NormalizeCode makes the typed and the printed form hash the same: case,
|
||||
// spaces and dashes are all noise a person adds or drops.
|
||||
func NormalizeCode(code string) string {
|
||||
code = strings.ToUpper(code)
|
||||
code = strings.NewReplacer("-", "", " ", "", "\t", "", "\r", "", "\n", "").Replace(code)
|
||||
return code
|
||||
}
|
||||
|
||||
func keyFor(code string) []byte {
|
||||
sum := sha256.Sum256([]byte("behavision-demo-bundle:" + NormalizeCode(code)))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// Seal encrypts plaintext under the code. Output is magic || nonce || ciphertext.
|
||||
func Seal(code string, plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(keyFor(code))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := append([]byte(magic), nonce...)
|
||||
return gcm.Seal(out, nonce, plaintext, []byte(magic)), nil
|
||||
}
|
||||
|
||||
// ErrWrongCode is what a mistyped code looks like. GCM cannot tell a wrong key
|
||||
// from a corrupted file, and neither can we, so both read as this.
|
||||
var ErrWrongCode = errors.New("that unlock code does not open this bundle")
|
||||
|
||||
// Open decrypts a sealed bundle.
|
||||
func Open(code string, sealed []byte) ([]byte, error) {
|
||||
if !strings.HasPrefix(string(sealed), magic) {
|
||||
return nil, errors.New("not a Behavision demo bundle")
|
||||
}
|
||||
body := sealed[len(magic):]
|
||||
block, err := aes.NewCipher(keyFor(code))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) < gcm.NonceSize() {
|
||||
return nil, errors.New("bundle is truncated")
|
||||
}
|
||||
nonce, ct := body[:gcm.NonceSize()], body[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ct, []byte(magic))
|
||||
if err != nil {
|
||||
return nil, ErrWrongCode
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
87
agent/pkg/demo/bundle_test.go
Normal file
87
agent/pkg/demo/bundle_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package demo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSealedBundleRoundTripsWithTheCodeAsTyped(t *testing.T) {
|
||||
code, err := NewCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(NormalizeCode(code)) != 24 {
|
||||
t.Fatalf("code should be 24 base32 chars, got %q", code)
|
||||
}
|
||||
secret := []byte(`[{"id":"cam1","password":"the-camera-admin-password"}]`)
|
||||
|
||||
sealed, err := Seal(code, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// People type codes in lower case, with the dashes dropped, with a space
|
||||
// where a dash was. All of those are the same code.
|
||||
for _, typed := range []string{
|
||||
code,
|
||||
strings.ToLower(code),
|
||||
strings.ReplaceAll(code, "-", ""),
|
||||
strings.ReplaceAll(code, "-", " "),
|
||||
" " + code + "\n",
|
||||
} {
|
||||
got, err := Open(typed, sealed)
|
||||
if err != nil {
|
||||
t.Fatalf("open with %q: %v", typed, err)
|
||||
}
|
||||
if !bytes.Equal(got, secret) {
|
||||
t.Fatalf("round trip changed the contents")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the file: the password is not in it.
|
||||
func TestTheSealedFileDoesNotContainTheSecret(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
sealed, _ := Seal(code, []byte(`{"password":"the-camera-admin-password","host":"192.168.1.121"}`))
|
||||
for _, leak := range []string{"the-camera-admin-password", "192.168.1.121", "password"} {
|
||||
if bytes.Contains(sealed, []byte(leak)) {
|
||||
t.Fatalf("sealed bundle contains %q in the clear", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongCodeIsRefusedNotMisread(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
other, _ := NewCode()
|
||||
sealed, _ := Seal(code, []byte("secret"))
|
||||
|
||||
if _, err := Open(other, sealed); !errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a different code should be ErrWrongCode, got %v", err)
|
||||
}
|
||||
// One flipped byte in the ciphertext is the same answer: GCM refuses
|
||||
// rather than returning garbage that then gets written into cameras.json.
|
||||
tampered := append([]byte{}, sealed...)
|
||||
tampered[len(tampered)-1] ^= 0x01
|
||||
if _, err := Open(code, tampered); !errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a tampered bundle should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSomethingThatIsNotABundleSaysSo(t *testing.T) {
|
||||
if _, err := Open("ABCDEF-GHIJKL-MNOPQR-STUVWX", []byte("hello")); err == nil ||
|
||||
errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a non-bundle should be named as such, not blamed on the code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two seals of the same plaintext under the same code must differ: a fixed
|
||||
// nonce would let two releases' bundles be compared byte for byte.
|
||||
func TestEverySealIsDifferent(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
a, _ := Seal(code, []byte("same"))
|
||||
b, _ := Seal(code, []byte("same"))
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("nonce is not random")
|
||||
}
|
||||
}
|
||||
41
agent/pkg/engine/env.go
Normal file
41
agent/pkg/engine/env.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// ChildEnv is the environment the engine is launched with, wherever it is
|
||||
// launched from - the desktop app and the headless agent both go through
|
||||
// here, so a third caller cannot get it half right.
|
||||
//
|
||||
// The line that matters is BEHAVISION_DATA_DIR.
|
||||
//
|
||||
// The engine's paths.py knows two worlds: frozen with PyInstaller, where state
|
||||
// lives under ProgramData, and a checkout, where everything sits in the repo
|
||||
// root. An engine installed from source into a virtual environment is neither.
|
||||
// Left to itself it resolves its state root to site-packages - writes its
|
||||
// database and camera list there, and generates its API credential into a
|
||||
// folder this process never reads - while this process resolves the same
|
||||
// state root to ProgramData. The two halves then disagree about where
|
||||
// everything lives, and every call to the engine is 401 on a stock install,
|
||||
// with nothing in either log saying why. Seen twice: once on a Mac checkout
|
||||
// (the app in ~/Library, the engine in the repo) and once in a clean Linux
|
||||
// container running the installer.
|
||||
//
|
||||
// Telling the engine where THIS process keeps state makes the two agree by
|
||||
// construction, however the engine was installed. paths.py honours the
|
||||
// override ahead of every other rule it has.
|
||||
//
|
||||
// hookURL is where the engine posts detections; empty is allowed and means
|
||||
// the bridge has not started, which the engine treats as "no webhook".
|
||||
func ChildEnv(hookURL string) []string {
|
||||
env := append(os.Environ(),
|
||||
"BEHAVISION_DATA_DIR="+paths.StateRoot(),
|
||||
)
|
||||
if hookURL != "" {
|
||||
env = append(env, "BEHAVISION_WEBHOOK_URL="+hookURL)
|
||||
}
|
||||
return env
|
||||
}
|
||||
44
agent/pkg/engine/env_test.go
Normal file
44
agent/pkg/engine/env_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// The engine must be told where THIS process keeps state, or a pip-installed
|
||||
// engine decides on site-packages and the two halves never find each other.
|
||||
func TestTheEngineIsToldWhereStateLives(t *testing.T) {
|
||||
t.Setenv("BEHAVISION_DATA_DIR", t.TempDir())
|
||||
|
||||
env := ChildEnv("http://127.0.0.1:5555/events")
|
||||
|
||||
want := "BEHAVISION_DATA_DIR=" + paths.StateRoot()
|
||||
if !contains(env, want) {
|
||||
t.Fatalf("engine env lacks %q - a source-installed engine would put its "+
|
||||
"database and credential somewhere this process never looks", want)
|
||||
}
|
||||
if !contains(env, "BEHAVISION_WEBHOOK_URL=http://127.0.0.1:5555/events") {
|
||||
t.Fatal("webhook url not passed to the engine")
|
||||
}
|
||||
}
|
||||
|
||||
// Before the bridge has a port there is no webhook. An empty variable would be
|
||||
// read by the engine as a webhook at "", which is not the same as none.
|
||||
func TestNoWebhookMeansNoVariable(t *testing.T) {
|
||||
for _, v := range ChildEnv("") {
|
||||
if strings.HasPrefix(v, "BEHAVISION_WEBHOOK_URL=") {
|
||||
t.Fatalf("empty hook still exported: %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(env []string, want string) bool {
|
||||
for _, v := range env {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
92
agent/pkg/enrol/enrol.go
Normal file
92
agent/pkg/enrol/enrol.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Package enrol links a PC to a shop, using the one-shot code an operator is
|
||||
// given.
|
||||
//
|
||||
// It existed only inside the desktop app, which meant a HEADLESS install - a
|
||||
// back-office PC with no window, the configuration the agent binary is for -
|
||||
// could not be claimed at all. The only route was hand-editing agent.json,
|
||||
// which is exactly the state the desktop's Setup screen was built to end.
|
||||
//
|
||||
// The endpoint behind this is deliberately unauthenticated: the PC doing it has
|
||||
// nobody signed in yet, and requiring a login would mean shipping a password to
|
||||
// every shop that installs the software.
|
||||
package enrol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bootstrap is what the server hands back: which shop this PC is, and the
|
||||
// credentials it needs to say so.
|
||||
type Bootstrap struct {
|
||||
ClientSlug string `json:"client_slug"`
|
||||
SiteSlug string `json:"site_slug"`
|
||||
SiteName string `json:"site_name"`
|
||||
MQTTURL string `json:"mqtt_url"`
|
||||
MQTTUser string `json:"mqtt_username"`
|
||||
MQTTPass string `json:"mqtt_password"`
|
||||
CAPem string `json:"ca_pem,omitempty"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
// Claim redeems an installation code.
|
||||
//
|
||||
// The code is read aloud down a phone and photographed off screens, so what is
|
||||
// typed here can be as untidy as it needs to be: the server strips spaces,
|
||||
// dashes and case at its end. Sending it as typed keeps ONE implementation of
|
||||
// that normalisation, on the side that also issued the code - two would
|
||||
// eventually disagree and hash to something the redeemer never produces.
|
||||
func Claim(ctx context.Context, base, code string) (Bootstrap, error) {
|
||||
var out Bootstrap
|
||||
base = strings.TrimRight(base, "/")
|
||||
if base == "" {
|
||||
return out, fmt.Errorf("no server address configured (set cloud_base or BEHAVISION_CLOUD)")
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"site_token": code})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
base+"/api/agent/enrol", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("could not reach %s: %w", base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
blob, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// The server answers unknown, expired and already-used identically on
|
||||
// purpose - the difference only helps somebody guessing codes, and the
|
||||
// operator's next step is the same in all three cases. Its own words
|
||||
// are passed through rather than reworded here.
|
||||
var e struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(blob, &e)
|
||||
if e.Message != "" {
|
||||
return out, fmt.Errorf("%s", e.Message)
|
||||
}
|
||||
return out, fmt.Errorf("head office: %s", resp.Status)
|
||||
}
|
||||
if err := json.Unmarshal(blob, &out); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.SiteSlug == "" || out.MQTTURL == "" {
|
||||
return out, fmt.Errorf("head office returned an incomplete setup")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -104,7 +106,13 @@ func NewClient(opts ClientOptions) (*Client, error) {
|
||||
|
||||
tok := c.client.Connect()
|
||||
if !tok.WaitTimeout(20 * time.Second) {
|
||||
return c, fmt.Errorf("mqtt: connect to %s timed out", opts.BrokerURL)
|
||||
// SetConnectRetry means paho retries internally and this token never
|
||||
// completes, so a REFUSED connection and an UNREACHABLE broker both
|
||||
// arrive here as a timeout. They need opposite actions - re-link this
|
||||
// PC, or go and look at the network - and reporting both as "timed
|
||||
// out" sent the diagnosis to the wrong place. Measured: mosquitto
|
||||
// logged "not authorised" while the agent logged a timeout.
|
||||
return c, fmt.Errorf("mqtt: %s", describeStall(opts.BrokerURL))
|
||||
}
|
||||
if err := tok.Error(); err != nil {
|
||||
return c, fmt.Errorf("mqtt: connect to %s: %w", opts.BrokerURL, err)
|
||||
@@ -112,6 +120,47 @@ func NewClient(opts ClientOptions) (*Client, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// describeStall says which of the two failures this is, by asking the one
|
||||
// question that separates them: can we open a socket to the broker at all?
|
||||
//
|
||||
// It cannot name the exact reason - the broker does not tell a rejected client
|
||||
// why, and a TLS failure looks the same from here - so it says what is known
|
||||
// and what to check, rather than guessing. Being reachable but not accepted is
|
||||
// overwhelmingly a credential this PC no longer has, which is what happens when
|
||||
// a site is re-provisioned.
|
||||
func describeStall(brokerURL string) string {
|
||||
host := brokerHostPort(brokerURL)
|
||||
if host == "" {
|
||||
return fmt.Sprintf("connect to %s timed out", brokerURL)
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", host, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("cannot reach the broker at %s: %v - check the "+
|
||||
"network and that the broker is running", host, err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
return fmt.Sprintf("the broker at %s is reachable but did not accept this "+
|
||||
"PC - usually its credentials are no longer valid; re-link it with "+
|
||||
"`behavision-agent claim <code>`", host)
|
||||
}
|
||||
|
||||
// brokerHostPort extracts host:port for the reachability probe. Parsed with
|
||||
// net/url, never by scanning for the first ":" - an IPv6 literal is bracketed
|
||||
// and full of them.
|
||||
func brokerHostPort(brokerURL string) string {
|
||||
u, err := url.Parse(brokerURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
if u.Port() != "" {
|
||||
return u.Host
|
||||
}
|
||||
if strings.HasPrefix(brokerURL, "tls://") || strings.HasPrefix(brokerURL, "ssl://") {
|
||||
return net.JoinHostPort(u.Hostname(), "8883")
|
||||
}
|
||||
return net.JoinHostPort(u.Hostname(), "1883")
|
||||
}
|
||||
|
||||
// Publish sends one message at QoS 1 and waits for the broker's PUBACK.
|
||||
//
|
||||
// QoS 1, not 0 or 2. At QoS 0 the broker never confirms, so the pump would ack
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -102,3 +103,66 @@ func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
|
||||
func writeFile(path, content string) error {
|
||||
return osWriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
|
||||
// "The broker refused this PC" and "the broker is not there" need opposite
|
||||
// actions - re-link this PC, or go and look at the network - and paho's
|
||||
// connect-retry makes both arrive as a timeout. Measured on a real broker:
|
||||
// mosquitto logged "not authorised" while the agent logged a timeout, which
|
||||
// sent the diagnosis to the wrong place.
|
||||
func TestARefusedBrokerIsNotDescribedAsUnreachable(t *testing.T) {
|
||||
// A listener that accepts TCP and then says nothing is exactly what a
|
||||
// broker rejecting a client looks like from out here.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = c
|
||||
}
|
||||
}()
|
||||
|
||||
got := describeStall("tcp://" + ln.Addr().String())
|
||||
if !strings.Contains(got, "reachable but did not accept") {
|
||||
t.Fatalf("a reachable broker was described as unreachable: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "claim") {
|
||||
t.Errorf("the message does not say what to do about it: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAbsentBrokerIsDescribedAsUnreachable(t *testing.T) {
|
||||
// Bound and immediately closed, so the port is certainly nobody's.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
|
||||
got := describeStall("tcp://" + addr)
|
||||
if !strings.Contains(got, "cannot reach the broker") {
|
||||
t.Fatalf("an absent broker was not described as unreachable: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An IPv6 literal is bracketed and full of colons, so scanning for the first
|
||||
// one gives "[". The same bug this package already fixed once for broker URLs.
|
||||
func TestTheProbeAddressHandlesIPv6AndDefaultPorts(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"tcp://127.0.0.1:51883", "127.0.0.1:51883"},
|
||||
{"tcp://[::1]:1883", "[::1]:1883"},
|
||||
{"tcp://broker.example", "broker.example:1883"},
|
||||
{"tls://broker.example", "broker.example:8883"},
|
||||
{"tls://[2001:db8::1]:8884", "[2001:db8::1]:8884"},
|
||||
} {
|
||||
if got := brokerHostPort(tc.in); got != tc.want {
|
||||
t.Errorf("brokerHostPort(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ models finish loading without a single unguarded None dereference.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
@@ -111,6 +112,35 @@ def _auth_dependencies(api_cfg: ApiSection) -> list:
|
||||
return [Depends(check)]
|
||||
|
||||
|
||||
def _reencode(jpeg: bytes, width: int, quality: int) -> "bytes | None":
|
||||
"""Decode, scale and re-encode one frame. None on any failure.
|
||||
|
||||
None rather than an exception on purpose: the caller falls back to the
|
||||
original frame, so a re-encode that fails costs bandwidth rather than the
|
||||
picture. A live view that goes blank because a resize failed is a worse
|
||||
outcome than one that is briefly larger than asked for.
|
||||
"""
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
return None
|
||||
if 0 < width < img.shape[1]:
|
||||
# Only ever DOWN. Upscaling a frame to a requested width would send
|
||||
# more bytes than the original for no more detail.
|
||||
scale = width / img.shape[1]
|
||||
img = cv2.resize(img, (width, max(1, int(img.shape[0] * scale))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
q = quality if 1 <= quality <= 100 else 75
|
||||
ok, buf = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), q])
|
||||
return buf.tobytes() if ok else None
|
||||
except Exception:
|
||||
log.debug("frame re-encode failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def create_app(engine: Engine) -> FastAPI:
|
||||
app = FastAPI(title="Behavision", version="1.0.0",
|
||||
dependencies=_auth_dependencies(engine.cfg.api))
|
||||
@@ -313,10 +343,23 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
return probe_source(source, cam.max_width)
|
||||
|
||||
@app.get("/api/cameras/{camera_id}/frame.jpg")
|
||||
def frame(camera_id: str) -> Response:
|
||||
def frame(camera_id: str, width: int = 0, quality: int = 0) -> Response:
|
||||
"""The latest frame, optionally re-encoded smaller.
|
||||
|
||||
`width`/`quality` exist for the live relay, which sends several frames
|
||||
a second up a shop's uplink and cannot afford the full-size picture the
|
||||
dashboard uses. The re-encode happens here rather than in the agent
|
||||
because this process already has OpenCV open and the frame decoded;
|
||||
shipping a scaler into the agent would be the same work done twice.
|
||||
|
||||
Done on demand, not on every frame: a camera nobody is watching must
|
||||
not pay for a second encode it will never use.
|
||||
"""
|
||||
jpeg = worker_or_404(camera_id).latest_jpeg()
|
||||
if jpeg is None:
|
||||
raise HTTPException(503, "no frame yet")
|
||||
if width > 0 or quality > 0:
|
||||
jpeg = _reencode(jpeg, width, quality) or jpeg
|
||||
return Response(jpeg, media_type="image/jpeg")
|
||||
|
||||
@app.get("/api/cameras/{camera_id}/stream.mjpeg")
|
||||
@@ -328,11 +371,23 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
# Stop when the camera is deleted or its worker dies - otherwise a
|
||||
# removed camera leaves this generator running for the life of the
|
||||
# process, holding a reference to a worker nothing else can see.
|
||||
# Driven by the camera, not a timer: a frame goes out when the
|
||||
# capture thread has one newer than the last one sent, so nothing
|
||||
# is sent twice and nothing waits on the recognition pipeline.
|
||||
# Capped at 15 fps - the office cameras' own rate - so a viewer
|
||||
# never costs more encodes than the camera produces pictures.
|
||||
last_ts, min_gap, sent_at = 0.0, 1.0 / 15, 0.0
|
||||
while engine.workers.get(camera_id) is worker and worker.is_alive():
|
||||
jpeg = worker.latest_jpeg()
|
||||
if jpeg is not None:
|
||||
yield boundary + jpeg + b"\r\n"
|
||||
await asyncio.sleep(0.1) # ~10 fps to the browser
|
||||
now = time.time()
|
||||
if now - sent_at < min_gap:
|
||||
await asyncio.sleep(min_gap - (now - sent_at))
|
||||
continue
|
||||
jpeg, ts = worker.latest_jpeg_since(last_ts)
|
||||
if jpeg is None:
|
||||
await asyncio.sleep(0.02)
|
||||
continue
|
||||
last_ts, sent_at = ts, time.time()
|
||||
yield boundary + jpeg + b"\r\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
|
||||
@@ -17,10 +17,20 @@ import numpy as np
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Force TCP transport and a 5s socket timeout for RTSP before OpenCV loads
|
||||
# ffmpeg. UDP is the default and silently drops frames on lossy Wi-Fi.
|
||||
# Set before OpenCV loads ffmpeg, which reads this once.
|
||||
#
|
||||
# rtsp_transport=tcp: UDP is the default and silently drops frames on lossy
|
||||
# Wi-Fi. stimeout: a 5s socket timeout so a dead camera is noticed.
|
||||
#
|
||||
# fflags=nobuffer and flags=low_delay: without them ffmpeg's RTSP demuxer
|
||||
# holds a comfortable queue of frames before handing over the first, which
|
||||
# on a live feed is half a second to two seconds of latency that no amount of
|
||||
# work downstream can recover - the frame is already old when we get it. A
|
||||
# recorder wants that buffer; a live view does not. max_delay caps the
|
||||
# reorder wait for the same reason.
|
||||
os.environ.setdefault(
|
||||
"OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp|stimeout;5000000"
|
||||
"OPENCV_FFMPEG_CAPTURE_OPTIONS",
|
||||
"rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay|max_delay;200000",
|
||||
)
|
||||
|
||||
|
||||
@@ -47,6 +57,23 @@ def _tcp_reachable(source: "str | int", timeout: float
|
||||
return False, f"cannot reach {parsed.hostname}:{port} - {exc.strerror or exc}"
|
||||
|
||||
|
||||
def _fourcc(cap) -> str:
|
||||
"""The stream's codec as a four-character code, or "" if unknown.
|
||||
|
||||
FFmpeg reports H.265 as "hevc" and H.264 as "h264"/"avc1" depending on the
|
||||
container. Returned as-is rather than mapped to a friendly name: the raw
|
||||
value is what somebody searching their camera's manual will match.
|
||||
"""
|
||||
try:
|
||||
raw = int(cap.get(cv2.CAP_PROP_FOURCC))
|
||||
except Exception:
|
||||
return ""
|
||||
if raw <= 0:
|
||||
return ""
|
||||
code = "".join(chr((raw >> (8 * i)) & 0xFF) for i in range(4))
|
||||
return code.strip().strip("\x00")
|
||||
|
||||
|
||||
def probe_source(source: "str | int", max_width: int = 1280,
|
||||
timeout: float = 12.0, connect_timeout: float = 3.0) -> dict:
|
||||
"""Open a candidate camera, grab one frame, and let go.
|
||||
@@ -97,6 +124,17 @@ def probe_source(source: "str | int", max_width: int = 1280,
|
||||
return {
|
||||
"ok": True, "width": int(width), "height": int(height),
|
||||
"downscaled_to": int(preview.shape[1]) if preview is not frame else None,
|
||||
"fps": round(cap.get(cv2.CAP_PROP_FPS) or 0, 1),
|
||||
# The codec decides whether head office can ever show TRUE live
|
||||
# video from this camera. A browser plays H.264 everywhere; H.265
|
||||
# only on some platforms, so a passthrough relay cannot rely on it
|
||||
# and the picture has to be re-encoded frame by frame instead.
|
||||
# Reported here because it is a property of the camera's settings
|
||||
# that an installer can usually change, and because otherwise the
|
||||
# only way to learn it is to read RTSP by hand — which is how this
|
||||
# was found: a camera whose paths end in ".264" was emitting H.265
|
||||
# on both streams.
|
||||
"codec": _fourcc(cap),
|
||||
"snapshot": (base64.b64encode(buf.tobytes()).decode("ascii")
|
||||
if ok else None),
|
||||
}
|
||||
|
||||
@@ -162,7 +162,11 @@ class CameraWorker(threading.Thread):
|
||||
# every test using a stubbed worker passed.
|
||||
self._stopping = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._annotated_jpeg: Optional[bytes] = None
|
||||
# What the live view draws over the freshest frame: the boxes from
|
||||
# the most recent processed frame, and when they were computed. NOT a
|
||||
# pre-rendered JPEG - see latest_jpeg for why.
|
||||
self._overlay: "list[tuple[tuple[int, int, int, int], tuple[int, int, int], str]]" = []
|
||||
self._overlay_ts = 0.0
|
||||
self._last_frame_ts = 0.0
|
||||
self._was_connected = False
|
||||
self.frames_processed = 0
|
||||
@@ -187,8 +191,46 @@ class CameraWorker(threading.Thread):
|
||||
self.source.stop()
|
||||
|
||||
def latest_jpeg(self) -> Optional[bytes]:
|
||||
jpeg, _ = self.latest_jpeg_since(0.0)
|
||||
return jpeg
|
||||
|
||||
def latest_jpeg_since(self, known_ts: float) -> "tuple[Optional[bytes], float]":
|
||||
"""The freshest captured frame with the latest boxes drawn on it, or
|
||||
(None, known_ts) if the camera has produced nothing newer.
|
||||
|
||||
The live picture is deliberately NOT the frame the pipeline last
|
||||
finished with. That version advanced only when detection, tracking and
|
||||
identification had all completed on a frame - a few times a second on a
|
||||
modest shop PC - and every picture it showed was already as old as that
|
||||
processing. It looked like lag because it was lag. Here the picture runs
|
||||
at the camera's rate off the capture thread's latest frame, and the
|
||||
boxes - which genuinely can only update at pipeline rate - are drawn
|
||||
over it from the last processed frame. Boxes may trail a fast walker by
|
||||
one pipeline period; the picture never does.
|
||||
|
||||
Encoded on demand, per request, so a camera nobody is watching pays for
|
||||
no JPEG at all. The old path encoded every processed frame whether or
|
||||
not a viewer existed - CPU spent on precisely the machine short of it.
|
||||
"""
|
||||
frame, ts = self.source.latest_since(known_ts)
|
||||
if frame is None:
|
||||
return None, known_ts
|
||||
with self._lock:
|
||||
return self._annotated_jpeg
|
||||
overlay, overlay_ts = list(self._overlay), self._overlay_ts
|
||||
# A stalled pipeline must not leave a box floating over an empty spot.
|
||||
# Older than a second and the person has walked out from under it.
|
||||
draw = overlay if (time.time() - overlay_ts) < 1.0 else []
|
||||
if draw:
|
||||
frame = frame.copy()
|
||||
for (x1, y1, x2, y2), color, text in draw:
|
||||
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
|
||||
if text:
|
||||
cv2.putText(frame, text, (x1, max(20, y1 - 8)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
|
||||
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
||||
if not ok:
|
||||
return None, known_ts
|
||||
return buf.tobytes(), ts
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
@@ -236,7 +278,7 @@ class CameraWorker(threading.Thread):
|
||||
for track in ended:
|
||||
self._finish_track(track, ts)
|
||||
|
||||
self._publish_annotated(frame, active)
|
||||
self._remember_tracks(active)
|
||||
self.frames_processed += 1
|
||||
except Exception:
|
||||
log.exception("[%s] frame processing failed", self.cam_cfg.id)
|
||||
@@ -426,12 +468,14 @@ class CameraWorker(threading.Thread):
|
||||
track.quality, rcfg=self.rcfg):
|
||||
track.reinforcements += 1
|
||||
|
||||
def _publish_annotated(self, frame: np.ndarray, tracks: "list[Track]") -> None:
|
||||
canvas = frame.copy()
|
||||
def _remember_tracks(self, tracks: "list[Track]") -> None:
|
||||
"""Record what to draw. Cheap: a handful of tuples under the lock,
|
||||
no frame copy and no encode. The encode happens in latest_jpeg_since,
|
||||
only when somebody is looking."""
|
||||
overlay = []
|
||||
for t in tracks:
|
||||
if t.misses > 0:
|
||||
continue # only draw tracks matched in this frame
|
||||
x1, y1, x2, y2 = t.box
|
||||
if t.state == "resolved":
|
||||
color = _COLORS["known"] if t.label and not str(t.label).startswith(
|
||||
"Visitor") else _COLORS["new"]
|
||||
@@ -440,15 +484,10 @@ class CameraWorker(threading.Thread):
|
||||
color, text = _COLORS["ambiguous"], "?"
|
||||
else:
|
||||
color, text = _COLORS["pending"], ""
|
||||
cv2.rectangle(canvas, (x1, y1), (x2, y2), color, 2)
|
||||
if text:
|
||||
cv2.putText(canvas, text, (x1, max(20, y1 - 8)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
|
||||
ok, buf = cv2.imencode(".jpg", canvas,
|
||||
[int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
||||
if ok:
|
||||
with self._lock:
|
||||
self._annotated_jpeg = buf.tobytes()
|
||||
overlay.append((tuple(t.box), color, text))
|
||||
with self._lock:
|
||||
self._overlay = overlay
|
||||
self._overlay_ts = time.time()
|
||||
|
||||
|
||||
class Engine:
|
||||
|
||||
@@ -43,6 +43,9 @@ type App struct {
|
||||
broker *agentmqtt.Client
|
||||
stopBridge func()
|
||||
hookURL string
|
||||
// Relays camera feeds to the webview so the engine's credential never has
|
||||
// to travel in an <img> src, which a Chromium webview would strip anyway.
|
||||
proxy *streamProxy
|
||||
// Set once the operator logs in. Until then the UI shows the login sheet
|
||||
// and nothing else is reachable.
|
||||
onSessionChange func(bool)
|
||||
@@ -63,6 +66,7 @@ func NewApp() *App {
|
||||
cfg: cfg,
|
||||
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
|
||||
local: local.New(base, cfg.APIUser, cfg.APIPassword),
|
||||
proxy: newStreamProxy(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +74,14 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
_ = agentpaths.EnsureState()
|
||||
|
||||
// Before any screen asks for a camera URL. A failure here is logged and
|
||||
// not fatal: the rest of the app - people, cameras, the engine controls -
|
||||
// works without a picture, and refusing to start over a broken tile would
|
||||
// take a working shop offline.
|
||||
if err := a.proxy.start(a.local.Base, a.local.User, a.local.Password); err != nil {
|
||||
log.Printf("camera relay unavailable, tiles will not load: %v", err)
|
||||
}
|
||||
|
||||
// A saved session means a shop PC that rebooted overnight comes back
|
||||
// working instead of waiting for someone to log in.
|
||||
if a.cfg.SessionToken != "" {
|
||||
@@ -102,7 +114,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
// be told again. Without it the engine recognised people and the
|
||||
// bridge received nothing: a claimed shop PC published heartbeats
|
||||
// and zero visits.
|
||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL())
|
||||
cmd.Env = agentengine.ChildEnv(a.webhookURL())
|
||||
return cmd
|
||||
},
|
||||
LogWriter: logFile,
|
||||
@@ -112,6 +124,23 @@ func (a *App) startup(ctx context.Context) {
|
||||
})
|
||||
|
||||
a.startPipeline(ctx)
|
||||
|
||||
// Recognition starts with the app. Until this, the engine only ever
|
||||
// started when somebody pressed Start - which meant a till that rebooted
|
||||
// overnight came back with the window open, the tray icon showing, the
|
||||
// session restored, and recognition off until a shop assistant noticed.
|
||||
// That is the failure the tray colours exist to catch, and it should not
|
||||
// be the default state every morning.
|
||||
//
|
||||
// Guarded on the interpreter actually being there: on a PC where setup has
|
||||
// not run yet, starting the supervisor would loop on a missing executable
|
||||
// with nothing useful to say. The Start button still exists for the one
|
||||
// case where somebody has deliberately stopped it.
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
a.sup.Start()
|
||||
} else {
|
||||
log.Printf("engine not installed yet (%s); run behavision-setup, then Start", exe)
|
||||
}
|
||||
}
|
||||
|
||||
// webhookURL is the loopback address the bridge is listening on, or empty
|
||||
@@ -229,6 +258,9 @@ func (a *App) startLocalCameras(ctx context.Context, logger *log.Logger) {
|
||||
// this app - and the headless agent - both ended up wiring configuration
|
||||
// and forgetting the check runner, so "Test connection" at head office
|
||||
// never completed on any shop PC.
|
||||
// The live relay runs alongside the reconciler and uploads nothing until
|
||||
// somebody at head office is actually watching a camera.
|
||||
go agentcameras.NewLive(camEngine, camCloud, logger).Run(ctx)
|
||||
agentcameras.New(camEngine, camCloud, logger).Run(ctx)
|
||||
}
|
||||
|
||||
@@ -270,8 +302,8 @@ type PipelineStatus struct {
|
||||
// Standalone separates "nothing is being sent because this PC is set up on
|
||||
// its own" from "nothing is being sent and something is wrong". They look
|
||||
// identical from the counters alone, and only one of them is a fault.
|
||||
Standalone bool `json:"standalone"`
|
||||
BrokerUp bool `json:"broker_up"`
|
||||
Standalone bool `json:"standalone"`
|
||||
BrokerUp bool `json:"broker_up"`
|
||||
Accepted uint64 `json:"accepted"`
|
||||
}
|
||||
|
||||
@@ -546,15 +578,25 @@ func (a *App) PlacementResult(id string) (map[string]any, error) {
|
||||
return a.local.PlacementResult(ctx, id)
|
||||
}
|
||||
|
||||
// StreamURL is the MJPEG endpoint for a camera, with credentials inline so an
|
||||
// <img> tag can load it. Loopback only - it never leaves this machine.
|
||||
// StreamURL is the MJPEG endpoint for a camera tile.
|
||||
//
|
||||
// It points at this app's own loopback relay, not at the engine directly. The
|
||||
// previous version put the engine's Basic credentials inline in the URL, with
|
||||
// a comment saying they were there "so an <img> tag can load it" - which a
|
||||
// browser will not do. Chromium strips credentials from subresource URLs, and
|
||||
// WebView2 is Chromium, so every camera tile on a shop PC was a broken image.
|
||||
// See stream_proxy.go for the measurement.
|
||||
//
|
||||
// The relay is also why no password appears in the page any more. If it is not
|
||||
// running the fallback is the bare engine URL with no credential: correct for
|
||||
// an engine configured without auth, and for one with auth a tile that fails
|
||||
// to load rather than a password sitting in the DOM.
|
||||
func (a *App) StreamURL(cameraID string) string {
|
||||
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
|
||||
if a.local.User == "" {
|
||||
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
||||
if u := a.proxy.urlFor(cameraID, "stream.mjpeg"); u != "" {
|
||||
return u
|
||||
}
|
||||
return fmt.Sprintf("http://%s:%s@%s/api/cameras/%s/stream.mjpeg",
|
||||
a.local.User, a.local.Password, base, cameraID)
|
||||
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
|
||||
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- live --
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
desktop/frontend/dist/index.html
vendored
2
desktop/frontend/dist/index.html
vendored
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="./assets/index-whFsTNQf.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-B3NH0cQK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-XjqO50wd.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
<div className="panel">
|
||||
<div className="who">
|
||||
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
|
||||
customerRef={customer.ref}
|
||||
onBroken={() => setPhoto({ available: false,
|
||||
reason: 'The photo could not be loaded.' })} />
|
||||
<div className="grow">
|
||||
|
||||
@@ -30,16 +30,31 @@ export function useCustomerPhoto(id) {
|
||||
|
||||
// No photo is the normal case — images are off by default — so this renders
|
||||
// initials, not an error.
|
||||
export default function CustomerPhoto({ photo, name, onBroken }) {
|
||||
export default function CustomerPhoto({ photo, name, customerRef, onBroken }) {
|
||||
if (photo?.available) {
|
||||
return <img className="avatar" src={photo.url} alt={`Photo of ${name}`}
|
||||
onError={onBroken} />
|
||||
}
|
||||
const initials = String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
|
||||
.map(w => w[0].toUpperCase()).join('') || '?'
|
||||
return (
|
||||
<div className="avatar none" role="img" aria-label={`No photo of ${name}`}>
|
||||
<span>{initials}</span>
|
||||
<span>{avatarText(name, customerRef)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Initials of a name a human typed; the NUMBER for a customer the system named
|
||||
// itself. Taking the first letter of each word of "Visitor 13" gives "V1" —
|
||||
// which is also what "Visitor 10" and "Visitor 15" give, so three different
|
||||
// people wear the same badge, and it reads as the V-1 reference for a fourth.
|
||||
// Same fix as the web app's arrivals feed; the two must not disagree.
|
||||
//
|
||||
// customerRef, not `ref`: React reserves that prop name and it would never
|
||||
// reach this component.
|
||||
function avatarText(name, customerRef) {
|
||||
const auto = /^Visitor (\d+)$/.exec(String(name || '').trim())
|
||||
if (auto) return auto[1]
|
||||
const n = /^V-(\d+)$/.exec(String(customerRef || ''))
|
||||
if (n) return n[1]
|
||||
return String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
|
||||
.map(w => w[0].toUpperCase()).join('') || '?'
|
||||
}
|
||||
|
||||
@@ -56,7 +56,14 @@ export default function Customers({ session }) {
|
||||
<tbody>
|
||||
{rows.map(c => (
|
||||
<tr key={c.id} className="click" onClick={() => setSelected(c)}>
|
||||
<td>{c.full_name || <span className="note">{c.label}</span>}</td>
|
||||
<td>
|
||||
{c.full_name || <span className="note">{c.label}</span>}
|
||||
{/* Only beside a name a human typed: the auto label
|
||||
already IS the number ("Visitor 13"), so showing
|
||||
both reads as two identifiers for one person. */}
|
||||
{c.full_name && c.ref &&
|
||||
<span className="note"> · {c.ref}</span>}
|
||||
</td>
|
||||
<td className="mono">{c.phone || '—'}</td>
|
||||
<td className="num">{c.visit_count}</td>
|
||||
<td>{fmtDate(c.first_seen_at)}</td>
|
||||
|
||||
@@ -14,16 +14,34 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.10.2 // indirect
|
||||
github.com/labstack/gommon v0.4.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
|
||||
github.com/leaanthony/gosod v1.0.3 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/samber/lo v1.38.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.6 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.16 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
|
||||
@@ -20,6 +21,7 @@ github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN
|
||||
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
@@ -30,6 +32,7 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
|
||||
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
@@ -42,6 +45,7 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
@@ -50,6 +54,8 @@ github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
|
||||
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
|
||||
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
@@ -92,3 +98,5 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -9,6 +9,7 @@ package cloud
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -179,9 +180,16 @@ func (c *Client) send(ctx context.Context, method, path string, raw []byte, out
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
|
||||
return errTokenExpired
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
case resp.StatusCode == http.StatusUnauthorized && tok != "":
|
||||
// A 401 on a call we sent a session with: the session is the problem.
|
||||
return ErrUnauthorized
|
||||
case resp.StatusCode >= 400:
|
||||
// Every other 4xx/5xx - including a 401 on a call that carried NO
|
||||
// session, such as redeeming an installation code - is about the
|
||||
// request, and the server wrote its message for exactly this moment.
|
||||
// Mapping those to "session expired" told an installer their session
|
||||
// had lapsed on a screen where they had never signed in, and hid
|
||||
// "That installation code is not valid" behind it.
|
||||
msg := e.Message
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
|
||||
@@ -393,6 +401,11 @@ type Photo struct {
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason"`
|
||||
// Auth is set by the server when the URL is one of its own endpoints and
|
||||
// needs this session's bearer, rather than a presigned object-store link
|
||||
// that carries its own signature. It never reaches the front end - see
|
||||
// VisitorImage, which resolves it here.
|
||||
Auth bool `json:"auth"`
|
||||
}
|
||||
|
||||
// VisitorImage fetches a short-lived signed link to this customer's photo.
|
||||
@@ -413,9 +426,91 @@ func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) {
|
||||
return Photo{}, err
|
||||
}
|
||||
out.Available = out.URL != ""
|
||||
|
||||
// A deployment with no object storage serves the photo from the API itself,
|
||||
// which means a RELATIVE url that needs this session's bearer. Neither
|
||||
// works in the window: a webview <img> resolves a relative src against
|
||||
// wails://, not against the cloud, and it cannot send an Authorization
|
||||
// header at all - so handing it straight through renders a broken picture
|
||||
// on exactly the deployments that have just started storing photos.
|
||||
//
|
||||
// Fetched here and passed as a data: URI. The alternative is a local proxy
|
||||
// inside this process holding the session, which is a second authenticated
|
||||
// surface on the shop PC to get wrong. One photo per sheet, ~90 KB, and the
|
||||
// server already records the read where the link was handed out.
|
||||
if out.Available && out.Auth {
|
||||
data, err := c.fetchImage(ctx, out.URL)
|
||||
if err != nil {
|
||||
// The record itself is worth far more than the picture, so this is
|
||||
// an absence with a reason rather than a failure that blanks the
|
||||
// customer - the same rule the whole image path follows.
|
||||
return Photo{Reason: "That photo could not be loaded."}, nil
|
||||
}
|
||||
out.URL = data
|
||||
out.Auth = false
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fetchImage reads an image this server holds itself and returns a data: URI.
|
||||
//
|
||||
// Deliberately not routed through send(): that decodes JSON into `out`, and
|
||||
// these are bytes. It shares the token and the expiry retry, because a sheet
|
||||
// opened twelve hours after the last one must not show a broken photo.
|
||||
func (c *Client) fetchImage(ctx context.Context, path string) (string, error) {
|
||||
body, err := c.imageBytes(ctx, path)
|
||||
if errors.Is(err, errTokenExpired) {
|
||||
if rerr := c.Refresh(ctx); rerr != nil {
|
||||
return "", rerr
|
||||
}
|
||||
body, err = c.imageBytes(ctx, path)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(body), nil
|
||||
}
|
||||
|
||||
// maxPhotoBytes bounds what will be pulled into memory and then base64'd into
|
||||
// the window. Face crops are ~20 KB and a camera still ~100 KB; anything near
|
||||
// this is a different file or a fault, and a shop PC should not spend its
|
||||
// memory finding that out.
|
||||
const maxPhotoBytes = 4 << 20
|
||||
|
||||
func (c *Client) imageBytes(ctx context.Context, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
_ = json.Unmarshal(body, &e)
|
||||
if e.Error == "token_expired" {
|
||||
return nil, errTokenExpired
|
||||
}
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("photo: %s", resp.Status)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, maxPhotoBytes))
|
||||
}
|
||||
|
||||
// ForgetVisitor erases a customer: face template, photo and profile.
|
||||
//
|
||||
// Irreversible by design — a soft-deleted face template is a retained
|
||||
@@ -457,7 +552,10 @@ func (c *Client) Sales(ctx context.Context, from, to string) (SalesReport, error
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
// Ref is the customer number - "V-42" - and is what staff say to each
|
||||
// other. It is accepted anywhere this customer's id is.
|
||||
Ref string `json:"ref"`
|
||||
Label string `json:"label"`
|
||||
FullName string `json:"full_name"`
|
||||
Phone string `json:"phone"`
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -137,3 +138,43 @@ func TestVisitorIDIsPathEscaped(t *testing.T) {
|
||||
t.Errorf("path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Redeeming an installation code is the one call a fresh PC makes before it
|
||||
// has any session. When the server refuses it - wrong code, wrong head office -
|
||||
// it answers 401 with a message written for the installer. That message must
|
||||
// reach them: "session expired" on a screen where nobody has signed in sent a
|
||||
// real installer looking for a login problem that did not exist.
|
||||
func TestARefusedInstallationCodeSaysWhyNotSessionExpired(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Errorf("enrol must not carry a session, got %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
fail(w, http.StatusUnauthorized, "bad_token",
|
||||
"That installation code is not valid. Ask for a new one.")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := New(srv.URL) // deliberately no session
|
||||
|
||||
_, err := c.Bootstrap(context.Background(), "KWFH5S-EH46LT-EE4X47-OSOH7D")
|
||||
if err == nil {
|
||||
t.Fatal("a refused code must be an error")
|
||||
}
|
||||
if errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("a refused code is not a session problem, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "installation code is not valid") {
|
||||
t.Fatalf("the server's own words should reach the installer, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The other side of the same rule: a 401 on a call that DID carry a session is
|
||||
// a session problem, and must still read as one.
|
||||
func TestARejectedSessionStillReadsAsSessionExpired(t *testing.T) {
|
||||
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusUnauthorized, "unauthorized", "Sign in again.")
|
||||
})
|
||||
err := c.do(context.Background(), http.MethodGet, "/api/auth/me", nil, nil)
|
||||
if !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("a 401 with a session should be ErrUnauthorized, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ func main() {
|
||||
},
|
||||
OnShutdown: func(ctx context.Context) {
|
||||
tray.stop()
|
||||
app.proxy.stop()
|
||||
app.StopEngine()
|
||||
},
|
||||
Bind: []any{app},
|
||||
|
||||
249
desktop/stream_proxy.go
Normal file
249
desktop/stream_proxy.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package main
|
||||
|
||||
// streamProxy serves the engine's camera feeds to this app's own webview
|
||||
// without putting a credential in the page.
|
||||
//
|
||||
// What this replaces: StreamURL used to build
|
||||
// http://user:pass@127.0.0.1:8010/api/cameras/<id>/stream.mjpeg and hand it
|
||||
// to an <img>, with a comment saying the credentials were inline "so an <img>
|
||||
// tag can load it". It cannot. Chromium strips credentials from subresource
|
||||
// URLs and has since M59, and WebView2 is Chromium - so on the one platform
|
||||
// this product ships to, every camera tile on the shop floor renders as a
|
||||
// broken image. Measured against the same running engine: the app's Go-side
|
||||
// calls returned stats and people while an <img> on the very same URL failed,
|
||||
// and curl proved the URL itself answered 200. The engine was never the
|
||||
// problem; the browser was throwing the password away before it asked.
|
||||
//
|
||||
// So the password stays on this side of the process boundary. The webview
|
||||
// asks this loopback listener, the listener attaches Basic auth and relays
|
||||
// the engine's bytes back unchanged. It is the same reasoning the head-office
|
||||
// web app already follows in Shot.jsx, where an <img> equally cannot carry a
|
||||
// session and the bytes are fetched and handed over as an object URL.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A camera id reaches this from the engine and from a person typing into the
|
||||
// Add Camera form. Validated rather than interpolated: without this a `..`
|
||||
// would climb out of the two paths below and turn a camera relay into a proxy
|
||||
// for any engine endpoint, with the credential helpfully attached.
|
||||
var safeCameraIDChars = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`)
|
||||
|
||||
// safeCameraID is the character check AND the two names that pass it and still
|
||||
// mean something to a path resolver.
|
||||
//
|
||||
// The pattern allows `.` because real camera ids contain them - which means it
|
||||
// also allows exactly `.` and `..`, and `/api/cameras/../stream.mjpeg` is not
|
||||
// the endpoint anyone intended. The id can never contain a slash (the path is
|
||||
// split on them before we get here), so these two strings are the entire
|
||||
// remaining traversal surface. Found by the test, not by reading the regex.
|
||||
func safeCameraID(id string) bool {
|
||||
if id == "." || id == ".." {
|
||||
return false
|
||||
}
|
||||
return safeCameraIDChars.MatchString(id)
|
||||
}
|
||||
|
||||
type streamProxy struct {
|
||||
mu sync.RWMutex
|
||||
ln net.Listener
|
||||
srv *http.Server
|
||||
client *http.Client
|
||||
token string
|
||||
target string // engine origin, e.g. http://127.0.0.1:8010
|
||||
user string
|
||||
pass string
|
||||
}
|
||||
|
||||
func newStreamProxy() *streamProxy { return &streamProxy{} }
|
||||
|
||||
// start binds a loopback listener and begins relaying. Calling it again while
|
||||
// running is a no-op, so a restarted engine cannot leave two listeners behind.
|
||||
func (p *streamProxy) start(base, user, pass string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.srv != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
base = "http://" + base
|
||||
}
|
||||
if _, err := url.Parse(base); err != nil {
|
||||
return fmt.Errorf("engine base %q: %w", base, err)
|
||||
}
|
||||
|
||||
// The engine's own credential exists precisely so that the live face feed
|
||||
// is never served open - CLAUDE.md is explicit that an unauthenticated
|
||||
// listener would expose it. An unauthenticated loopback relay would hand
|
||||
// that same feed to any other process on this PC, which on a shop counter
|
||||
// is not a theoretical set. A per-run token, minted here and given only to
|
||||
// this app's own webview, keeps the relay as private as the engine is.
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return fmt.Errorf("proxy token: %w", err)
|
||||
}
|
||||
|
||||
// Port 0: the OS picks a free one. A fixed port would collide with
|
||||
// whatever else a shop PC happens to be running, and the failure would be
|
||||
// "the cameras stopped working" with nothing pointing at the cause.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("stream proxy listen: %w", err)
|
||||
}
|
||||
|
||||
p.ln = ln
|
||||
p.token = hex.EncodeToString(raw)
|
||||
p.target = strings.TrimRight(base, "/")
|
||||
p.user, p.pass = user, pass
|
||||
// No client timeout: an MJPEG stream is endless by design and any deadline
|
||||
// would cut the picture off mid-shift. The request context ends it when
|
||||
// the webview navigates away or the tile is replaced.
|
||||
p.client = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
srv := &http.Server{Handler: http.HandlerFunc(p.handle)}
|
||||
p.srv = srv
|
||||
|
||||
// srv and ln are captured, not read off the struct inside the goroutine:
|
||||
// stop() sets both to nil, so a serve loop that reached for them after a
|
||||
// quick start/stop would dereference nil and take the whole app down. The
|
||||
// test that stops the relay found exactly that.
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *streamProxy) stop() {
|
||||
p.mu.Lock()
|
||||
srv, ln := p.srv, p.ln
|
||||
p.srv, p.ln, p.token = nil, nil, ""
|
||||
p.mu.Unlock()
|
||||
if srv != nil {
|
||||
_ = srv.Close()
|
||||
}
|
||||
if ln != nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// urlFor returns the loopback URL for one camera resource, or "" when the
|
||||
// proxy is not running so the caller can fall back.
|
||||
func (p *streamProxy) urlFor(cameraID, file string) string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.ln == nil || p.token == "" || !safeCameraID(cameraID) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("http://%s/s/%s/%s/%s",
|
||||
p.ln.Addr().String(), p.token, cameraID, file)
|
||||
}
|
||||
|
||||
func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
|
||||
p.mu.RLock()
|
||||
token, target, user, pass, client := p.token, p.target, p.user, p.pass, p.client
|
||||
p.mu.RUnlock()
|
||||
if token == "" || client == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// /s/<token>/<camera>/<file>
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
|
||||
if len(parts) != 4 || parts[0] != "s" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Constant time: the token is the only thing standing between another
|
||||
// local process and a live view of customers' faces.
|
||||
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(token)) != 1 {
|
||||
// 404 rather than 403. There is nothing here to tell an unwelcome
|
||||
// caller they have found the right door with the wrong key.
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cameraID := parts[2]
|
||||
if !safeCameraID(cameraID) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// An allow-list, not a prefix match. Everything else the engine serves -
|
||||
// the identity list, the gallery, erasure - stays unreachable through here
|
||||
// even for a caller holding the token.
|
||||
//
|
||||
// frame.jpg is listed although no screen asks for one yet. It is reachable
|
||||
// only through urlFor, which is internal, so it adds no bound API nobody
|
||||
// calls; it is here so that adding a still later is a change to a screen
|
||||
// rather than a change to the one file where a mistake is a credentialed
|
||||
// proxy onto the biometric API.
|
||||
var enginePath string
|
||||
switch parts[3] {
|
||||
case "stream.mjpeg":
|
||||
enginePath = "/api/cameras/" + cameraID + "/stream.mjpeg"
|
||||
case "frame.jpg":
|
||||
enginePath = "/api/cameras/" + cameraID + "/frame.jpg"
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target+enginePath, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "bad upstream request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// frame.jpg takes width and quality; the engine re-encodes on demand.
|
||||
req.URL.RawQuery = r.URL.RawQuery
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, "engine unreachable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for _, h := range []string{"Content-Type", "Cache-Control", "Pragma", "Expires"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
w.Header().Set(h, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
// Copied by hand rather than with io.Copy so every chunk is flushed. An
|
||||
// MJPEG stream never ends, so anything buffered waiting for a full buffer
|
||||
// is a tile that stays blank forever - which is the same symptom as the
|
||||
// bug this file exists to fix, and would look like it had not worked.
|
||||
flusher, _ := w.(http.Flusher)
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := w.Write(buf[:n]); werr != nil {
|
||||
return // webview went away
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
if rerr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
296
desktop/stream_proxy_test.go
Normal file
296
desktop/stream_proxy_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeEngine stands in for the Python engine: it demands Basic auth exactly as
|
||||
// the real one does when a credential is configured, and records what it was
|
||||
// asked for.
|
||||
type fakeEngine struct {
|
||||
*httptest.Server
|
||||
gotPath string
|
||||
gotUser string
|
||||
gotPass string
|
||||
hadAuth bool
|
||||
}
|
||||
|
||||
func newFakeEngine(t *testing.T, body string) *fakeEngine {
|
||||
t.Helper()
|
||||
f := &fakeEngine{}
|
||||
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.gotPath = r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
f.gotPath += "?" + r.URL.RawQuery
|
||||
}
|
||||
f.gotUser, f.gotPass, f.hadAuth = r.BasicAuth()
|
||||
if !f.hadAuth {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="behavision"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
t.Cleanup(f.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func startProxy(t *testing.T, engine string, user, pass string) *streamProxy {
|
||||
t.Helper()
|
||||
p := newStreamProxy()
|
||||
if err := p.start(engine, user, pass); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
t.Cleanup(p.stop)
|
||||
return p
|
||||
}
|
||||
|
||||
func get(t *testing.T, url string) (int, string) {
|
||||
t.Helper()
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := c.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
// The whole point: the webview gets a URL it can actually load, and the
|
||||
// password stays behind. A credential in the src is both unloadable in a
|
||||
// Chromium webview and readable by anything that can see the DOM.
|
||||
func TestTheCameraURLCarriesNoPassword(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "hunter2-the-real-one")
|
||||
|
||||
u := p.urlFor("cam2", "stream.mjpeg")
|
||||
if u == "" {
|
||||
t.Fatal("no url while the proxy is running")
|
||||
}
|
||||
if strings.Contains(u, "hunter2-the-real-one") || strings.Contains(u, "behavision:") {
|
||||
t.Fatalf("credential leaked into the tile URL: %s", u)
|
||||
}
|
||||
if !strings.HasPrefix(u, "http://127.0.0.1:") {
|
||||
t.Fatalf("relay must be loopback only, got %s", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRelayAttachesTheCredentialItself(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frame-bytes")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
|
||||
code, body := get(t, p.urlFor("cam2", "stream.mjpeg"))
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("want 200 through the relay, got %d", code)
|
||||
}
|
||||
if body != "frame-bytes" {
|
||||
t.Fatalf("body not relayed unchanged: %q", body)
|
||||
}
|
||||
if !engine.hadAuth || engine.gotUser != "behavision" || engine.gotPass != "s3cret" {
|
||||
t.Fatalf("engine did not receive the credential: auth=%v user=%q",
|
||||
engine.hadAuth, engine.gotUser)
|
||||
}
|
||||
if engine.gotPath != "/api/cameras/cam2/stream.mjpeg" {
|
||||
t.Fatalf("wrong upstream path: %s", engine.gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
// The token is what keeps every other process on a shop PC from opening a live
|
||||
// view of customers' faces, now that the relay itself has no password.
|
||||
func TestAnotherProcessCannotGuessItsWayIn(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
addr := p.ln.Addr().String()
|
||||
|
||||
for _, bad := range []string{"", "0", strings.Repeat("a", 64), "wrong-token"} {
|
||||
url := fmt.Sprintf("http://%s/s/%s/cam2/stream.mjpeg", addr, bad)
|
||||
if code, _ := get(t, url); code != http.StatusNotFound {
|
||||
t.Fatalf("token %q got %d, want 404", bad, code)
|
||||
}
|
||||
}
|
||||
if engine.hadAuth {
|
||||
t.Fatal("a rejected request still reached the engine")
|
||||
}
|
||||
}
|
||||
|
||||
// A camera id is interpolated into the upstream path, so it has to be a camera
|
||||
// id and not a way to walk to a different endpoint with the credential
|
||||
// attached.
|
||||
func TestACameraIdCannotClimbOutOfItsPath(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
addr := p.ln.Addr().String()
|
||||
|
||||
for _, bad := range []string{"..", "%2e%2e", "cam2/../../api/identities", "cam 2", ""} {
|
||||
url := fmt.Sprintf("http://%s/s/%s/%s/stream.mjpeg", addr, p.token, bad)
|
||||
code, _ := get(t, url)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("camera id %q got %d, want 404", bad, code)
|
||||
}
|
||||
}
|
||||
if strings.Contains(engine.gotPath, "identities") {
|
||||
t.Fatalf("reached a non-camera endpoint: %s", engine.gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the two files a tile needs. The engine also serves the identity list and
|
||||
// the erasure endpoint; holding the token must not open those.
|
||||
func TestOnlyTheTwoCameraFilesAreReachable(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
addr := p.ln.Addr().String()
|
||||
|
||||
for _, bad := range []string{"identities", "stats", "commission", "stream.mjpeg.bak"} {
|
||||
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, bad)
|
||||
if code, _ := get(t, url); code != http.StatusNotFound {
|
||||
t.Fatalf("file %q got %d, want 404", bad, code)
|
||||
}
|
||||
}
|
||||
|
||||
for _, good := range []string{"stream.mjpeg", "frame.jpg"} {
|
||||
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, good)
|
||||
if code, _ := get(t, url); code != http.StatusOK {
|
||||
t.Fatalf("file %q got %d, want 200", good, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// frame.jpg takes width and quality - the engine re-encodes on demand, and a
|
||||
// relay that dropped the query would silently serve full-size frames.
|
||||
func TestTheQueryStringSurvivesTheRelay(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
|
||||
url := p.urlFor("cam2", "frame.jpg") + "?width=640&quality=70"
|
||||
if code, _ := get(t, url); code != http.StatusOK {
|
||||
t.Fatalf("got %d", code)
|
||||
}
|
||||
if !strings.Contains(engine.gotPath, "width=640") ||
|
||||
!strings.Contains(engine.gotPath, "quality=70") {
|
||||
t.Fatalf("query dropped: %s", engine.gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
// An engine that is not running must read as a bad gateway, not as a hang. A
|
||||
// blank tile that never resolves is the symptom this whole file exists to end.
|
||||
func TestAnEngineThatIsDownFailsQuickly(t *testing.T) {
|
||||
// Port 1 on loopback: nothing listens, and the connection is refused
|
||||
// rather than dropped, so this is fast and deterministic.
|
||||
p := startProxy(t, "http://127.0.0.1:1", "behavision", "s3cret")
|
||||
|
||||
done := make(chan int, 1)
|
||||
go func() { code, _ := get(t, p.urlFor("cam2", "stream.mjpeg")); done <- code }()
|
||||
select {
|
||||
case code := <-done:
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("want 502, got %d", code)
|
||||
}
|
||||
case <-time.After(8 * time.Second):
|
||||
t.Fatal("a dead engine left the request hanging")
|
||||
}
|
||||
}
|
||||
|
||||
// Stopping must actually free the port, or a restarted engine leaves listeners
|
||||
// behind for the life of the process.
|
||||
func TestStoppingReleasesEverything(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := newStreamProxy()
|
||||
if err := p.start(engine.URL, "u", "p"); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
url := p.urlFor("cam2", "stream.mjpeg")
|
||||
if code, _ := get(t, url); code != http.StatusOK {
|
||||
t.Fatalf("want 200 before stop, got %d", code)
|
||||
}
|
||||
|
||||
p.stop()
|
||||
|
||||
if got := p.urlFor("cam2", "stream.mjpeg"); got != "" {
|
||||
t.Fatalf("still handing out URLs after stop: %s", got)
|
||||
}
|
||||
c := &http.Client{Timeout: 3 * time.Second}
|
||||
if resp, err := c.Get(url); err == nil {
|
||||
resp.Body.Close()
|
||||
t.Fatal("listener still accepting after stop")
|
||||
}
|
||||
}
|
||||
|
||||
// start twice must not leave two listeners, which is what a restarted engine
|
||||
// would otherwise cause.
|
||||
func TestStartingTwiceIsANoOp(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "u", "p")
|
||||
|
||||
first := p.urlFor("cam2", "stream.mjpeg")
|
||||
if err := p.start(engine.URL, "u", "p"); err != nil {
|
||||
t.Fatalf("second start: %v", err)
|
||||
}
|
||||
if second := p.urlFor("cam2", "stream.mjpeg"); second != first {
|
||||
t.Fatalf("second start moved the relay: %s -> %s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// Against the real engine, which the unit tests above deliberately do not
|
||||
// touch. Skipped unless TEST_ENGINE_URL is set, the same rule the server's
|
||||
// live store tests follow: the suite must stay runnable with no services.
|
||||
//
|
||||
// TEST_ENGINE_URL=http://127.0.0.1:8010 \
|
||||
// TEST_ENGINE_USER=... TEST_ENGINE_PASS=... go test ./desktop/ -run Live
|
||||
//
|
||||
// It exists because everything above proves the relay against a fake that
|
||||
// agrees with me. Only a real engine proves the thing that was actually
|
||||
// broken: that a multipart MJPEG stream arrives through the relay in pieces,
|
||||
// rather than being buffered into a tile that never paints.
|
||||
func TestLiveRelayCarriesRealMJPEGFrames(t *testing.T) {
|
||||
base := os.Getenv("TEST_ENGINE_URL")
|
||||
if base == "" {
|
||||
t.Skip("set TEST_ENGINE_URL to run the live relay test")
|
||||
}
|
||||
cam := os.Getenv("TEST_ENGINE_CAMERA")
|
||||
if cam == "" {
|
||||
cam = "cam2"
|
||||
}
|
||||
p := startProxy(t, base, os.Getenv("TEST_ENGINE_USER"), os.Getenv("TEST_ENGINE_PASS"))
|
||||
|
||||
url := p.urlFor(cam, "stream.mjpeg")
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("relay returned %d - the credential did not reach the engine", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "multipart") {
|
||||
t.Fatalf("not a stream: Content-Type %q", ct)
|
||||
}
|
||||
|
||||
// Read until two JPEG start markers have gone past. One proves it opened;
|
||||
// two prove it is still delivering, which is the difference between a
|
||||
// working tile and a single frozen frame.
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
var seen, total int
|
||||
buf := make([]byte, 16*1024)
|
||||
for seen < 2 && time.Now().Before(deadline) {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
total += n
|
||||
seen += strings.Count(string(buf[:n]), "\xff\xd8\xff")
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if seen < 2 {
|
||||
t.Fatalf("only %d JPEG frames in %d bytes - the relay is not streaming", seen, total)
|
||||
}
|
||||
t.Logf("relayed %d frames in %d bytes with no credential in the URL", seen, total)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -10,6 +11,25 @@ import (
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// BEHAVISION_NO_TRAY runs the window with no tray icon.
|
||||
//
|
||||
// It exists so the UI can be looked at on a Mac. fyne.io/systray's nativeLoop
|
||||
// must own the main thread on macOS - a Cocoa requirement, not a library
|
||||
// choice - and Wails already holds it, so starting both kills the process with
|
||||
// a SIGTRAP inside cgo before a single screen is drawn. On Windows, which is
|
||||
// what ships, a tray on its own goroutine is fine. That asymmetry is why this
|
||||
// went unnoticed for so long: the shop-floor UI had never once been run on the
|
||||
// platform it is developed on, so every screen in it was unreviewed.
|
||||
//
|
||||
// Deliberately an environment variable and NOT a GOOS check. A build that
|
||||
// quietly drops the tray on some platform is how a shop PC ends up with no
|
||||
// control surface at all - the one thing a shop manager has - and it would
|
||||
// fail exactly where nobody is watching. Nothing is skipped unless a person
|
||||
// asked for it, by name, on this run.
|
||||
const noTrayEnv = "BEHAVISION_NO_TRAY"
|
||||
|
||||
func trayDisabled() bool { return os.Getenv(noTrayEnv) != "" }
|
||||
|
||||
// tray is the always-present control surface. Wails v2 has no systray of its
|
||||
// own, so this drives fyne.io/systray alongside the window.
|
||||
//
|
||||
@@ -32,6 +52,9 @@ type tray struct {
|
||||
func newTray(a *App) *tray { return &tray{app: a, quit: make(chan struct{})} }
|
||||
|
||||
func (t *tray) start(ctx context.Context) {
|
||||
if trayDisabled() {
|
||||
return
|
||||
}
|
||||
t.once.Do(func() {
|
||||
go systray.Run(func() { t.onReady(ctx) }, func() {})
|
||||
})
|
||||
@@ -43,6 +66,13 @@ func (t *tray) stop() {
|
||||
default:
|
||||
close(t.quit)
|
||||
}
|
||||
// systray.Quit() on a systray that was never started is not a no-op in
|
||||
// v1.12.2, so the guard has to be on both ends or quitting the window
|
||||
// takes the process down with it - a crash on exit, which is the failure
|
||||
// most likely to be shrugged off as "it closed, fine".
|
||||
if trayDisabled() {
|
||||
return
|
||||
}
|
||||
systray.Quit()
|
||||
}
|
||||
|
||||
|
||||
120
installer/INSTALL.txt
Normal file
120
installer/INSTALL.txt
Normal file
@@ -0,0 +1,120 @@
|
||||
Behavision — installing on a shop PC
|
||||
====================================
|
||||
|
||||
This is a source install. It needs Python and a working internet connection
|
||||
once, at setup. After that the shop PC runs on its own.
|
||||
|
||||
|
||||
WHAT YOU NEED FIRST
|
||||
-------------------
|
||||
|
||||
Python 3.10 or newer.
|
||||
|
||||
https://www.python.org/downloads/windows/
|
||||
|
||||
On the very first screen of the Python installer, tick
|
||||
"Add python.exe to PATH". If you miss it, setup cannot find Python and
|
||||
you will have to run the Python installer again.
|
||||
|
||||
|
||||
SETTING UP
|
||||
----------
|
||||
|
||||
1. Unzip this whole folder somewhere permanent — for example
|
||||
C:\Behavision. Keep the files together; behavision-setup.exe looks for
|
||||
the engine-src folder next to itself.
|
||||
|
||||
2. Double-click behavision-setup.exe
|
||||
|
||||
It will:
|
||||
- find your Python and check it is new enough
|
||||
- build a private Python environment under
|
||||
C:\ProgramData\Behavision\runtime
|
||||
- install the recognition engine and its libraries (from the wheel
|
||||
in engine-src; the folder you unzipped is never written to)
|
||||
- download the recognition models (a few hundred megabytes)
|
||||
- start the engine once to prove it works
|
||||
|
||||
This takes several minutes. Leave the window open until it says Done.
|
||||
If anything fails it prints why, and running it again is safe.
|
||||
|
||||
DEMO RELEASE ONLY: if the release came with the cameras already set up,
|
||||
setup first asks for an unlock code. Type the code you were given. The
|
||||
camera details are sealed inside the release and cannot be read without
|
||||
it; with it, both cameras are added and the PC is set to run on its own,
|
||||
with no head office. Skip the installation-code screen - it will not
|
||||
appear.
|
||||
|
||||
3. Double-click Behavision.exe
|
||||
|
||||
The window opens and an icon appears in the system tray, next to the
|
||||
clock. Right-click the tray icon to open the window again, or to stop
|
||||
recognition.
|
||||
|
||||
|
||||
CONNECTING IT TO HEAD OFFICE
|
||||
----------------------------
|
||||
|
||||
The first screen asks for an installation code. Ask whoever manages your
|
||||
shops — they create one from the Behavision platform, under the shop.
|
||||
|
||||
No head office? Choose "set this PC up on its own" on the same screen.
|
||||
Recognition, the cameras and the customer list all work locally; nothing is
|
||||
sent anywhere.
|
||||
|
||||
|
||||
ADDING A CAMERA
|
||||
---------------
|
||||
|
||||
Cameras → Add. You need the camera's address on the shop network, its
|
||||
username and password. Choose your camera's make from the list and the
|
||||
stream path is filled in for you — that is the field nobody can look up.
|
||||
|
||||
Press "Test" before saving. Then press "Check placement" and walk past the
|
||||
camera a few times. It will tell you whether the camera can actually
|
||||
recognise faces from where it is mounted, which is not the same question as
|
||||
whether it is connected.
|
||||
|
||||
Camera placement matters more than camera quality. Aim for roughly head
|
||||
height, facing the direction people walk in. A camera high in a corner
|
||||
looking down, or pointing at a bright window or glass door, will connect
|
||||
perfectly and recognise almost nobody.
|
||||
|
||||
|
||||
WHERE THINGS LIVE
|
||||
-----------------
|
||||
|
||||
C:\ProgramData\Behavision\ database, logs, camera list, models
|
||||
C:\ProgramData\Behavision\runtime the engine's own Python
|
||||
|
||||
Everything the software writes is under ProgramData. The folder you unzipped
|
||||
is never written to, so you can keep it on a shared drive.
|
||||
|
||||
|
||||
STOPPING IT
|
||||
-----------
|
||||
|
||||
Right-click the tray icon and choose Quit. That stops recognition as well —
|
||||
leaving it running with no visible control would be worse than stopping it.
|
||||
|
||||
Closing the window does NOT stop recognition. The window hides and the tray
|
||||
icon stays, because a shop assistant clicking X should not switch the shop's
|
||||
footfall counting off for the rest of the day.
|
||||
|
||||
|
||||
IF SOMETHING IS WRONG
|
||||
---------------------
|
||||
|
||||
"No Python 3.10 or newer was found"
|
||||
Python is missing, too old, or was installed without the
|
||||
"Add python.exe to PATH" tick. Reinstall Python with that ticked.
|
||||
|
||||
Setup fails while installing libraries
|
||||
Almost always no internet, or a proxy in the way. The error printed
|
||||
just above the failure says which.
|
||||
|
||||
The window opens but says the engine is not running
|
||||
Run behavision-setup.exe again; it will report what is missing.
|
||||
|
||||
Logs
|
||||
C:\ProgramData\Behavision\engine.log
|
||||
21
installer/run-with-lan-head-office.cmd
Normal file
21
installer/run-with-lan-head-office.cmd
Normal file
@@ -0,0 +1,21 @@
|
||||
@echo off
|
||||
rem Start Behavision against a head office running on another PC on this LAN,
|
||||
rem instead of the production server it uses by default.
|
||||
rem
|
||||
rem For demos and pilots only. Two things are deliberately weaker than
|
||||
rem production and both are named here so nobody copies this into a shop:
|
||||
rem
|
||||
rem - head office over plain http, not https
|
||||
rem - the message broker over plain tcp. The app REFUSES plaintext MQTT to
|
||||
rem any address that is not its own machine, by design - the payloads are
|
||||
rem customer visit records - so the second line below is the documented
|
||||
rem escape hatch and must not be set anywhere that is not a demo.
|
||||
rem
|
||||
rem Edit the address to the PC running head office, then double-click this
|
||||
rem instead of Behavision.exe. Everything else - the installation code, the
|
||||
rem sign-in, the cameras - works exactly as INSTALL.txt describes.
|
||||
|
||||
set BEHAVISION_CLOUD=http://192.168.1.117:8088
|
||||
set BEHAVISION_ALLOW_PLAINTEXT_MQTT=1
|
||||
|
||||
start "" "%~dp0Behavision.exe"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "behavision"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
description = "Production face recognition over RTSP"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
@@ -14,6 +14,10 @@ dependencies = [
|
||||
"python-dotenv>=1.0",
|
||||
"faiss-cpu>=1.7.4",
|
||||
"requests>=2.31",
|
||||
# DPAPI for camera passwords at rest (behavision/cameras.py). Without it the
|
||||
# store logs a warning and writes them in the clear - which is what every
|
||||
# Windows install had been doing, since nothing pulled this in.
|
||||
"pywin32>=306; sys_platform == 'win32'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -8,3 +8,4 @@ PyYAML>=6.0
|
||||
python-dotenv>=1.0
|
||||
faiss-cpu>=1.7.4
|
||||
requests>=2.31
|
||||
pywin32>=306; sys_platform == "win32"
|
||||
|
||||
@@ -42,6 +42,43 @@ type Store interface {
|
||||
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)
|
||||
@@ -65,6 +102,18 @@ type Store interface {
|
||||
// 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,
|
||||
@@ -86,6 +135,11 @@ type Store interface {
|
||||
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
|
||||
@@ -114,6 +168,10 @@ type Server struct {
|
||||
// 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.
|
||||
@@ -180,6 +238,28 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
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))
|
||||
@@ -192,6 +272,8 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
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
|
||||
@@ -232,13 +314,25 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
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))
|
||||
@@ -408,6 +502,11 @@ func looksLikeUUID(s string) bool {
|
||||
// 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
|
||||
|
||||
@@ -86,9 +86,12 @@ func TestFourPeopleArrivingTogetherComeBackInOneRequest(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("cursor from a burst is unreadable: %v", err)
|
||||
}
|
||||
if seq != page.Arrivals[3].Seq {
|
||||
// Against the SEEDED position, not one read back off the wire: `seq` is
|
||||
// json:"-" because it counts every visit on the platform, so a client can
|
||||
// no longer see it - and the cursor is the whole reason it does not need to.
|
||||
if want := int64(4); seq != want {
|
||||
t.Errorf("cursor should point at the LAST row of the burst, got %d want %d",
|
||||
seq, page.Arrivals[3].Seq)
|
||||
seq, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +203,11 @@ func TestNoPhotoIsDataNotAnError(t *testing.T) {
|
||||
s.Blob = nil
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 1)
|
||||
// No key, because that is what this deployment actually produces: the
|
||||
// engine's `app.store_faces` is off, so no crop is ever captured and no
|
||||
// key is ever written. A bucket key on a server with no bucket is a
|
||||
// different state entirely and gets its own sentence below.
|
||||
fs.arrivals[0].ImageKey = ""
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
@@ -212,6 +220,54 @@ func TestNoPhotoIsDataNotAnError(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Three absences now, not two: face images may live in a bucket OR in this
|
||||
// database, so "there is no bucket" stopped being a synonym for "there are
|
||||
// no photos" the moment the fallback existed.
|
||||
t.Run("a bucket key on a server that has lost its bucket", func(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = nil
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 1) // seeded with an object-store key
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
got := page.Arrivals[0].Image
|
||||
if got.Available {
|
||||
t.Fatalf("nothing can be served without the bucket, got %+v", got)
|
||||
}
|
||||
// Deliberately NOT "we store no photos". The photo exists and this
|
||||
// server can no longer reach it, which is a configuration fault
|
||||
// somebody can fix - and reporting it as an ordinary empty record is
|
||||
// how it would go unnoticed for a year.
|
||||
if !strings.Contains(got.Reason, "no longer reach") {
|
||||
t.Errorf("want a configuration reason, got %q", got.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a face this server holds itself", func(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = nil // no object storage anywhere
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 1)
|
||||
fs.arrivals[0].ImageKey = "db:00000000-0000-4000-b000-000000000001"
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
got := page.Arrivals[0].Image
|
||||
if !got.Available {
|
||||
t.Fatalf("a stored face should be offered, got %+v", got)
|
||||
}
|
||||
// Auth is what tells a client this URL needs the session bearer. A
|
||||
// browser <img> cannot load it and a mobile image view can, and there
|
||||
// is nothing in the URL itself that says so.
|
||||
if !got.Auth {
|
||||
t.Error("a face held by this server must be marked as needing auth")
|
||||
}
|
||||
if strings.Contains(got.URL, "db:") {
|
||||
t.Errorf("the storage key leaked into the URL: %q", got.URL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("this visit simply had none", func(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
|
||||
230
server/internal/api/faces_test.go
Normal file
230
server/internal/api/faces_test.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Face images held by this server, for a deployment with no object storage.
|
||||
//
|
||||
// The property under test throughout is that the two storage routes differ in
|
||||
// exactly one hop: the key is minted differently and everything downstream -
|
||||
// ingest, the feed, the customer record, erasure - is one implementation.
|
||||
|
||||
func putFace(t *testing.T, srv *Server, token string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/agent/faces", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
srv.Routes().ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestAnAgentStoresAFaceAndAPersonReadsItBack(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
srv.Blob = nil // no object storage anywhere: the case this exists for
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
seedUser(fs)
|
||||
|
||||
img := jpegBytes(512)
|
||||
rr := putFace(t, srv, "agent-token", img)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("upload: %d %s", rr.Code, rr.Body)
|
||||
}
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A prefixed key, so `visits.image_key` can name an object in either store
|
||||
// and the read path can tell which without a second lookup.
|
||||
if !strings.HasPrefix(out.Key, "db:") {
|
||||
t.Fatalf("want a db: key, got %q", out.Key)
|
||||
}
|
||||
// The tenant and the site come from the AGENT's credential, never the
|
||||
// request, so a shop PC cannot file an image under another company.
|
||||
if fs.lastFaceClient != "client-acme" || fs.lastFaceSite != "site-1" {
|
||||
t.Fatalf("stored against %s/%s", fs.lastFaceClient, fs.lastFaceSite)
|
||||
}
|
||||
|
||||
sess := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, srv, "GET", faceURL(out.Key), sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("read back: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "image/jpeg" {
|
||||
t.Errorf("content type %q", got)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), img) {
|
||||
t.Error("the bytes that came back are not the ones that went in")
|
||||
}
|
||||
}
|
||||
|
||||
// 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. Checked against the bytes, never the header.
|
||||
func TestOnlyAJPEGIsStoredAsAFace(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
|
||||
for _, body := range []string{
|
||||
"<html><script>alert(1)</script></html>",
|
||||
"GIF89a",
|
||||
"%PDF-1.4",
|
||||
"",
|
||||
} {
|
||||
rr := putFace(t, srv, "agent-token", []byte(body))
|
||||
if rr.Code == http.StatusCreated {
|
||||
t.Errorf("accepted %q as a face image", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFaceIsNotReadableWithoutASession(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
rr := putFace(t, srv, "agent-token", jpegBytes(64))
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
||||
|
||||
// The reason it is 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.
|
||||
if rec := do(t, srv, "GET", faceURL(out.Key), "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("a face was served with no session, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherTenantCannotReadYourStoredFace(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addAgent("acme-agent", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
seedUser(fs)
|
||||
fs.addUser("other@beta.com", "correct horse battery", UserRecord{
|
||||
ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd",
|
||||
FullName: "Bo", Role: "manager", Active: true,
|
||||
})
|
||||
|
||||
rr := putFace(t, srv, "acme-agent", jpegBytes(64))
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
||||
|
||||
// An image key travels in API responses, so a caller who kept one - or
|
||||
// guessed one - must get nothing rather than somebody else's customer.
|
||||
beta := login(t, srv, "other@beta.com", "correct horse battery")
|
||||
if rec := do(t, srv, "GET", faceURL(out.Key), beta.Token, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("another tenant read a stored face, got %d", rec.Code)
|
||||
}
|
||||
acme := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
if rec := do(t, srv, "GET", faceURL(out.Key), acme.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("the owning tenant could not read its own face, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The customer record has to work on a deployment with no bucket too - it is
|
||||
// the screen staff use to recognise the person in front of them.
|
||||
func TestTheCustomerPhotoWorksWithNoObjectStorage(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
srv.Blob = nil
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
seedUser(fs)
|
||||
|
||||
rr := putFace(t, srv, "agent-token", jpegBytes(64))
|
||||
var up struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &up)
|
||||
const visitor = "44444444-4444-4444-8444-444444444444"
|
||||
fs.imageKeys[visitor] = up.Key
|
||||
|
||||
sess := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, srv, "GET", "/api/visitors/"+visitor+"/image", sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("customer photo: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var img Image
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !img.Available || !img.Auth {
|
||||
t.Fatalf("want an available image that needs the session, got %+v", img)
|
||||
}
|
||||
// The storage key names a tenant's prefix and must never be what a client
|
||||
// receives, on either route.
|
||||
if strings.Contains(rec.Body.String(), "db:") {
|
||||
t.Errorf("the storage key leaked: %s", rec.Body.String())
|
||||
}
|
||||
// Reading a face is worth an audit row wherever the LINK is handed out.
|
||||
// Recorded here rather than at the byte fetch, because the bucket route's
|
||||
// bytes never touch this server and the two must be counted the same way.
|
||||
if !audited(fs, "image.view") {
|
||||
t.Error("reading a customer photo left no audit row")
|
||||
}
|
||||
}
|
||||
|
||||
func audited(fs *fakeStore, action string) bool {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
for _, a := range fs.audits {
|
||||
if a.Action == action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Erasure has to destroy an image this server holds, not only one in a bucket.
|
||||
// A face image that survives an erasure request is the one outcome that
|
||||
// endpoint must never produce.
|
||||
func TestErasureDestroysAStoredFace(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
srv.Blob = nil
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
seedUser(fs)
|
||||
|
||||
rr := putFace(t, srv, "agent-token", jpegBytes(64))
|
||||
var up struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &up)
|
||||
const visitor = "55555555-5555-4555-8555-555555555555"
|
||||
fs.imageKeys[visitor] = up.Key
|
||||
|
||||
sess := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
if rec := do(t, srv, "DELETE", "/api/visitors/"+visitor, sess.Token, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("erase: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := do(t, srv, "GET", faceURL(up.Key), sess.Token, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("the face survived erasure, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// If the image cannot be destroyed, NOTHING is erased and the caller is told.
|
||||
// Reporting a legal request as honoured when it was not is the failure this
|
||||
// path exists to prevent.
|
||||
func TestAFailedFaceDeleteAbortsTheWholeErasure(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
srv.Blob = nil
|
||||
seedUser(fs)
|
||||
const visitor = "66666666-6666-4666-8666-666666666666"
|
||||
fs.imageKeys[visitor] = "db:66666666-6666-4666-8666-666666666666"
|
||||
fs.faceDeleteErr = errors.New("storage is down")
|
||||
|
||||
sess := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, srv, "DELETE", "/api/visitors/"+visitor, sess.Token, nil)
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("want 502 and nothing erased, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.forgotten) != 0 {
|
||||
t.Fatalf("the record was erased even though the photo could not be: %v", fs.forgotten)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +20,19 @@ import (
|
||||
// live - which tenant, which message on failure, what is echoed back - and
|
||||
// those are exactly what a real database would make slow and awkward to test.
|
||||
type fakeStore struct {
|
||||
// Which tenant and site each camera belongs to. The live relay is keyed on
|
||||
// a camera id and a hub does not know whose camera it holds, so ownership
|
||||
// is proved before anything streams - and that is what these tests check.
|
||||
cameraRefs map[string]cameraRef
|
||||
|
||||
// Camera pictures held by the server, for a deployment with no bucket.
|
||||
// Keyed as written by PutCameraSnapshot (by camera_id) and as read by
|
||||
// CameraSnapshot ("client/camera"), so a test has to say which it means.
|
||||
snapshots map[string][]byte
|
||||
snapshotRejects bool
|
||||
lastSnapshotClient string
|
||||
lastSnapshotSite string
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
users map[string]UserRecord // by lower-cased email
|
||||
@@ -57,6 +71,14 @@ type fakeStore struct {
|
||||
lastCheckKind string
|
||||
lastCheckSeconds int
|
||||
|
||||
// Invitations, and the faces this server holds itself.
|
||||
invites map[string]*fakeInvite // by code hash hex
|
||||
faces map[string][]byte // "client/id"
|
||||
lastFaceClient string
|
||||
lastFaceSite string
|
||||
deletedFaces []string
|
||||
faceDeleteErr error
|
||||
|
||||
cameras []Camera
|
||||
agentCameras []AgentCamera
|
||||
lastCameraReport AgentCameraReport
|
||||
@@ -84,6 +106,7 @@ type fakeSession struct {
|
||||
id string
|
||||
p auth.Principal
|
||||
accessExp, refreshExp time.Time
|
||||
device string
|
||||
revoked bool
|
||||
}
|
||||
|
||||
@@ -137,7 +160,11 @@ func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.nextID++
|
||||
id := "sess-" + itoa(f.nextID)
|
||||
// uuid-SHAPED, because the handlers validate the shape of an id before
|
||||
// spending a database round trip on it. A fake that mints "sess-1" would
|
||||
// make every id-addressed session route 404 in tests and pass in
|
||||
// production, which is the wrong way round.
|
||||
id := fmt.Sprintf("00000000-0000-4000-8000-%012d", f.nextID)
|
||||
var rec UserRecord
|
||||
for _, u := range f.users {
|
||||
if u.ID == n.UserID {
|
||||
@@ -152,6 +179,7 @@ func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
|
||||
FullName: rec.FullName, Role: rec.Role,
|
||||
},
|
||||
accessExp: n.AccessExpiry, refreshExp: n.RefreshExp,
|
||||
device: n.Device,
|
||||
}
|
||||
f.sessions[id] = s
|
||||
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
|
||||
@@ -586,3 +614,440 @@ func (b *fakeBlob) Delete(_ context.Context, key string) error {
|
||||
b.deleted = append(b.deleted, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- camera snapshots --
|
||||
|
||||
func (f *fakeStore) PutCameraSnapshot(_ context.Context,
|
||||
clientID, siteID, cameraID string, jpeg []byte) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.snapshots == nil {
|
||||
f.snapshots = map[string][]byte{}
|
||||
}
|
||||
if f.snapshotRejects {
|
||||
return ErrNoSnapshot
|
||||
}
|
||||
f.lastSnapshotClient, f.lastSnapshotSite = clientID, siteID
|
||||
f.snapshots[cameraID] = append([]byte(nil), jpeg...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CameraSnapshot(_ context.Context, clientID, cameraID string) (
|
||||
[]byte, time.Time, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
img, ok := f.snapshots[clientID+"/"+cameraID]
|
||||
if !ok {
|
||||
return nil, time.Time{}, ErrNoSnapshot
|
||||
}
|
||||
return img, time.Unix(1756900000, 0).UTC(), nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ live relay --
|
||||
|
||||
func (f *fakeStore) CameraRef(_ context.Context, clientID, cameraID string) (string, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
ref, ok := f.cameraRefs[cameraID]
|
||||
if !ok || ref.client != clientID {
|
||||
return "", "", ErrNoSnapshot
|
||||
}
|
||||
return ref.site, ref.engineID, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CameraRefBySite(_ context.Context, siteID, cameraID string) (string, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
ref, ok := f.cameraRefs[cameraID]
|
||||
if !ok || ref.site != siteID {
|
||||
return "", "", ErrNoSnapshot
|
||||
}
|
||||
return ref.site, ref.engineID, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SiteCameraIDs(_ context.Context, siteID string) ([]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []string
|
||||
for id, ref := range f.cameraRefs {
|
||||
if ref.site == siteID {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addCameraRef registers a camera so ownership checks have something to check.
|
||||
func (f *fakeStore) addCameraRef(id, client, site, engineID string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.cameraRefs == nil {
|
||||
f.cameraRefs = map[string]cameraRef{}
|
||||
}
|
||||
f.cameraRefs[id] = cameraRef{client: client, site: site, engineID: engineID}
|
||||
}
|
||||
|
||||
type cameraRef struct{ client, site, engineID string }
|
||||
|
||||
// ==================================== team, invitations, sessions, faces ====
|
||||
//
|
||||
// These behave rather than merely satisfy the interface: single use, tenant
|
||||
// scoping and "the role comes from the invitation" are the properties the
|
||||
// handlers are trusted for, so a fake that always says yes would make the tests
|
||||
// that check them meaningless.
|
||||
|
||||
type fakeInvite struct {
|
||||
id, clientID, email, fullName, role string
|
||||
expires time.Time
|
||||
used, revoked bool
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateInvitation(_ context.Context, in NewInvitation) (Invitation, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.invites == nil {
|
||||
f.invites = map[string]*fakeInvite{}
|
||||
}
|
||||
f.nextID++
|
||||
id := fmt.Sprintf("00000000-0000-4000-9000-%012d", f.nextID)
|
||||
f.invites[hex.EncodeToString(in.CodeHash)] = &fakeInvite{
|
||||
id: id, clientID: in.ClientID, email: in.Email,
|
||||
fullName: in.FullName, role: in.Role, expires: in.ExpiresAt,
|
||||
}
|
||||
return Invitation{
|
||||
ID: id, Email: in.Email, FullName: in.FullName, Role: in.Role,
|
||||
ExpiresAt: in.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) PendingInvitations(_ context.Context, clientID string) ([]Invitation, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []Invitation
|
||||
for _, v := range f.invites {
|
||||
if v.clientID != clientID || v.used || v.revoked {
|
||||
continue
|
||||
}
|
||||
out = append(out, Invitation{ID: v.id, Email: v.email,
|
||||
FullName: v.fullName, Role: v.role,
|
||||
ExpiresAt: v.expires.UTC().Format(time.RFC3339)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeInvitation(_ context.Context, clientID, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, v := range f.invites {
|
||||
if v.id == id && v.clientID == clientID && !v.used && !v.revoked {
|
||||
v.revoked = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("no such pending invitation")
|
||||
}
|
||||
|
||||
func (f *fakeStore) InvitationByCode(_ context.Context, hash []byte) (InvitationPreview, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
v, ok := f.invites[hex.EncodeToString(hash)]
|
||||
if !ok || v.used || v.revoked || time.Now().After(v.expires) {
|
||||
return InvitationPreview{}, errors.New("that invitation is not valid")
|
||||
}
|
||||
return InvitationPreview{Client: "Fake Co", Email: v.email,
|
||||
FullName: v.fullName, Role: v.role}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RedeemInvitation(_ context.Context, hash []byte,
|
||||
fullName, passwordHash string) (UserRecord, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
v, ok := f.invites[hex.EncodeToString(hash)]
|
||||
if !ok || v.used || v.revoked || time.Now().After(v.expires) {
|
||||
return UserRecord{}, errors.New("that invitation is not valid")
|
||||
}
|
||||
if _, taken := f.users[v.email]; taken {
|
||||
return UserRecord{}, errors.New("app_users_email_idx")
|
||||
}
|
||||
// Marked spent BEFORE the account exists, mirroring the real store's one
|
||||
// transaction: a test that redeems the same code twice must get one user.
|
||||
v.used = true
|
||||
f.nextID++
|
||||
rec := UserRecord{
|
||||
ID: fmt.Sprintf("00000000-0000-4000-a000-%012d", f.nextID),
|
||||
// From the INVITATION, never from the request - which is the property
|
||||
// worth having a fake at all for.
|
||||
ClientID: v.clientID, ClientName: "Fake Co", Email: v.email,
|
||||
FullName: fullName, Role: v.role, Active: true, Found: true,
|
||||
PasswordHash: passwordHash,
|
||||
}
|
||||
if rec.FullName == "" {
|
||||
rec.FullName = v.fullName
|
||||
}
|
||||
f.users[v.email] = rec
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Team(_ context.Context, clientID string) ([]TeamMember, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []TeamMember
|
||||
for _, u := range f.users {
|
||||
if u.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
out = append(out, TeamMember{ID: u.ID, Email: u.Email,
|
||||
FullName: u.FullName, Role: u.Role, Active: u.Active})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateTeamMember(_ context.Context, clientID, userID string,
|
||||
up TeamUpdate) (TeamMember, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for email, u := range f.users {
|
||||
if u.ID != userID || u.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
if up.Role != nil {
|
||||
u.Role = *up.Role
|
||||
}
|
||||
if up.Active != nil {
|
||||
u.Active = *up.Active
|
||||
if !u.Active {
|
||||
// The real store revokes in the same transaction; the fake
|
||||
// does it here so a test can prove "they have left" actually
|
||||
// signs them out rather than waiting twelve hours.
|
||||
for _, s := range f.sessions {
|
||||
if s.p.UserID == userID {
|
||||
s.revoked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
f.users[email] = u
|
||||
return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName,
|
||||
Role: u.Role, Active: u.Active}, nil
|
||||
}
|
||||
return TeamMember{}, errors.New("no such team member")
|
||||
}
|
||||
|
||||
func (f *fakeStore) UserSessions(_ context.Context, userID string) ([]DeviceSession, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []DeviceSession
|
||||
for _, s := range f.sessions {
|
||||
if s.p.UserID != userID || s.revoked {
|
||||
continue
|
||||
}
|
||||
out = append(out, DeviceSession{ID: s.id, Device: s.device,
|
||||
ExpiresAt: s.refreshExp.UTC().Format(time.RFC3339)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeUserSession(_ context.Context, userID, sessionID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s, ok := f.sessions[sessionID]
|
||||
// Scoped by user id, exactly as the real UPDATE is: a session id travels in
|
||||
// a list and is not a secret, so it must not sign anybody else out.
|
||||
if !ok || s.p.UserID != userID || s.revoked {
|
||||
return errors.New("no such session")
|
||||
}
|
||||
s.revoked = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeOtherSessions(_ context.Context, userID, keep string) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
n := 0
|
||||
for _, s := range f.sessions {
|
||||
if s.p.UserID == userID && s.id != keep && !s.revoked {
|
||||
s.revoked = true
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) PutVisitFace(_ context.Context, clientID, siteID string,
|
||||
jpeg []byte) (string, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.faces == nil {
|
||||
f.faces = map[string][]byte{}
|
||||
}
|
||||
f.nextID++
|
||||
id := fmt.Sprintf("00000000-0000-4000-b000-%012d", f.nextID)
|
||||
f.faces[clientID+"/"+id] = jpeg
|
||||
f.lastFaceClient, f.lastFaceSite = clientID, siteID
|
||||
return "db:" + id, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) VisitFace(_ context.Context, clientID, key string) ([]byte, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
img, ok := f.faces[clientID+"/"+strings.TrimPrefix(key, "db:")]
|
||||
if !ok {
|
||||
return nil, errors.New("no such face image")
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteVisitFaces(_ context.Context, clientID string, keys []string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.faceDeleteErr != nil {
|
||||
return f.faceDeleteErr
|
||||
}
|
||||
for _, k := range keys {
|
||||
delete(f.faces, clientID+"/"+strings.TrimPrefix(k, "db:"))
|
||||
f.deletedFaces = append(f.deletedFaces, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================ public reference resolution ===
|
||||
//
|
||||
// These behave rather than merely satisfy the interface. The properties the
|
||||
// handlers are trusted for - a reference resolves only within the caller's own
|
||||
// tenant, and an ambiguous camera name resolves to nothing rather than to
|
||||
// whichever row came first - are exactly what a fake that always said yes would
|
||||
// stop any test from checking.
|
||||
|
||||
func (f *fakeStore) SiteIDBySlug(_ context.Context, clientID, slug string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, s := range f.sites {
|
||||
// An owner of "" is a site the fake was not told about, which is the
|
||||
// ordinary case: SiteHealth carries no client id, and most tests seed
|
||||
// one tenant. Tests that assert cross-tenant resolution seed a camera,
|
||||
// which is what gives a site an owner here.
|
||||
if owner := f.siteClient(s.Slug, s.SiteID); s.Slug == slug &&
|
||||
(owner == "" || owner == clientID) {
|
||||
return s.SiteID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// siteClient answers which tenant a site belongs to. SiteHealth carries no
|
||||
// client id of its own - it is already scoped by the query that returns it - so
|
||||
// the fake reads ownership from the cameras it was seeded with.
|
||||
func (f *fakeStore) siteClient(_, siteID string) string {
|
||||
for _, ref := range f.cameraRefs {
|
||||
if ref.site == siteID {
|
||||
return ref.client
|
||||
}
|
||||
}
|
||||
for _, c := range f.cameras {
|
||||
if c.SiteID == siteID {
|
||||
return f.cameraOwner(c.ID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *fakeStore) cameraOwner(id string) string {
|
||||
if ref, ok := f.cameraRefs[id]; ok {
|
||||
return ref.client
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *fakeStore) CameraIDByRef(_ context.Context, clientID, ref string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var found []string
|
||||
for _, c := range f.cameras {
|
||||
if c.CameraID != ref {
|
||||
continue
|
||||
}
|
||||
if owner := f.cameraOwner(c.ID); owner != "" && owner != clientID {
|
||||
continue
|
||||
}
|
||||
found = append(found, c.ID)
|
||||
}
|
||||
// A camera id is unique per site, not per tenant. Two shops may each have
|
||||
// an "Office1", and acting on whichever sorted first would edit the wrong
|
||||
// shop's camera, so ambiguity is no match.
|
||||
if len(found) != 1 {
|
||||
return "", nil
|
||||
}
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) VisitorIDByNumber(_ context.Context, clientID string, number int64) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
want := VisitorRef(number)
|
||||
for _, v := range f.visitors {
|
||||
if v.Ref == want {
|
||||
return v.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// CreateMember behaves like the real store on the two things the handler
|
||||
// branches on: the account lands in the caller's tenant and nowhere else, and
|
||||
// an address that already exists anywhere is a conflict named the way Postgres
|
||||
// names it, so conflictMessage recognises it.
|
||||
func (f *fakeStore) CreateMember(_ context.Context, clientID string,
|
||||
in NewMemberInput, hash string) (TeamMember, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, taken := f.users[in.Email]; taken {
|
||||
return TeamMember{}, errors.New(`duplicate key value violates unique constraint "app_users_email_idx"`)
|
||||
}
|
||||
// The real UserByEmail joins clients for the name; this fake reads it off
|
||||
// the record, so copy it from a tenant-mate or a login as the new member
|
||||
// comes back with no company name and looks like it landed nowhere.
|
||||
clientName := ""
|
||||
for _, u := range f.users {
|
||||
if u.ClientID == clientID && u.ClientName != "" {
|
||||
clientName = u.ClientName
|
||||
break
|
||||
}
|
||||
}
|
||||
id := "member-" + itoa(len(f.users)+1)
|
||||
f.users[in.Email] = UserRecord{
|
||||
ID: id, ClientID: clientID, ClientName: clientName,
|
||||
Email: in.Email, FullName: in.FullName,
|
||||
Role: in.Role, Active: true, PasswordHash: hash, Found: true,
|
||||
}
|
||||
return TeamMember{ID: id, Email: in.Email, FullName: in.FullName,
|
||||
Role: in.Role, Active: true}, nil
|
||||
}
|
||||
|
||||
// ResetMemberPassword mirrors the real one: tenant-scoped, and every session
|
||||
// the member holds is revoked with it.
|
||||
func (f *fakeStore) ResetMemberPassword(_ context.Context, clientID, userID,
|
||||
hash string) (TeamMember, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for email, u := range f.users {
|
||||
if u.ID != userID || u.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
u.PasswordHash = hash
|
||||
f.users[email] = u
|
||||
for _, s := range f.sessions {
|
||||
if s.p.UserID == userID {
|
||||
s.revoked = true
|
||||
}
|
||||
}
|
||||
return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName,
|
||||
Role: u.Role, Active: u.Active}, nil
|
||||
}
|
||||
return TeamMember{}, errors.New("no such team member")
|
||||
}
|
||||
|
||||
@@ -41,12 +41,14 @@ func (s *Server) handleArrivals(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
q := ArrivalQuery{
|
||||
ClientID: p.ClientID,
|
||||
SiteID: trim(r.URL.Query().Get("site_id")),
|
||||
SiteID: siteParam(r),
|
||||
Limit: queryInt(r, "limit", defaultArrivals, maxArrivals),
|
||||
}
|
||||
if q.SiteID != "" && !looksLikeUUID(q.SiteID) {
|
||||
badRequest(w, "site_id must be a site identifier")
|
||||
return
|
||||
if q.SiteID != "" {
|
||||
var ok bool
|
||||
if q.SiteID, ok = s.resolveSiteFilter(w, r, q.SiteID); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
// The site is still filtered by client_id in SQL as well. A site_id from
|
||||
// the query string is caller-controlled, and this is a read of other
|
||||
@@ -122,27 +124,9 @@ func (s *Server) attachImages(r *http.Request, rows []Arrival) {
|
||||
for i := range rows {
|
||||
key := rows[i].ImageKey
|
||||
rows[i].ImageKey = ""
|
||||
switch {
|
||||
case s.Blob == nil:
|
||||
rows[i].Image.Reason = "This system is not storing customer photos."
|
||||
case key == "":
|
||||
rows[i].Image.Reason = "No photo was captured for this visit."
|
||||
default:
|
||||
url, err := s.Blob.PresignGet(key, viewTTL)
|
||||
if err != nil {
|
||||
// Log it, but never fail the feed over a picture. The visit is
|
||||
// the number the customer pays for; the photo is decoration on
|
||||
// top of it. This is the same rule the agent follows when an
|
||||
// upload fails.
|
||||
s.logf("ERROR presign arrival image: %v", err)
|
||||
rows[i].Image.Reason = "That photo could not be loaded."
|
||||
continue
|
||||
}
|
||||
rows[i].Image = Image{Available: true, URL: url,
|
||||
ExpiresIn: int(viewTTL.Seconds())}
|
||||
if rows[i].VisitorID != "" {
|
||||
seen = append(seen, rows[i].VisitorID)
|
||||
}
|
||||
rows[i].Image = s.imageFor(key)
|
||||
if rows[i].Image.Available && rows[i].VisitorID != "" {
|
||||
seen = append(seen, rows[i].VisitorID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,12 +159,14 @@ func (s *Server) handleArrivalStream(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
q := ArrivalQuery{
|
||||
ClientID: p.ClientID,
|
||||
SiteID: trim(r.URL.Query().Get("site_id")),
|
||||
SiteID: siteParam(r),
|
||||
Limit: queryInt(r, "limit", defaultArrivals, maxArrivals),
|
||||
}
|
||||
if q.SiteID != "" && !looksLikeUUID(q.SiteID) {
|
||||
badRequest(w, "site_id must be a site identifier")
|
||||
return
|
||||
if q.SiteID != "" {
|
||||
var ok bool
|
||||
if q.SiteID, ok = s.resolveSiteFilter(w, r, q.SiteID); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Last-Event-ID is what the browser's EventSource resends automatically on
|
||||
// a dropped connection, so honouring it is what makes a reconnect lossless
|
||||
|
||||
@@ -22,9 +22,11 @@ const snapshotTTL = 5 * time.Minute
|
||||
func (s *Server) handleCameras(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
siteID := trim(r.URL.Query().Get("site_id"))
|
||||
if siteID != "" && !looksLikeUUID(siteID) {
|
||||
badRequest(w, "site_id must be a site identifier")
|
||||
return
|
||||
if siteID != "" {
|
||||
var ok bool
|
||||
if siteID, ok = s.resolveSiteFilter(w, r, siteID); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
cams, err := s.Store.Cameras(r.Context(), p.ClientID, siteID)
|
||||
if err != nil {
|
||||
@@ -49,10 +51,28 @@ func (s *Server) attachSnapshots(cams []Camera) {
|
||||
key := cams[i].Snapshot.Key
|
||||
cams[i].Snapshot.Key = ""
|
||||
switch {
|
||||
case s.Blob == nil:
|
||||
cams[i].Snapshot.Reason = "This system is not storing images."
|
||||
case key == "" && cams[i].SnapshotAt != "":
|
||||
// Held by this server, because the deployment has no object
|
||||
// storage. Served from an endpoint rather than a signed link:
|
||||
// there is no third party to delegate to, the bytes are in our own
|
||||
// database, and an unauthenticated URL to somebody's shop floor
|
||||
// would be a new way in for no gain.
|
||||
cams[i].Snapshot = Image{
|
||||
Available: true,
|
||||
URL: "/api/cameras/" + cams[i].ID + "/snapshot.jpg",
|
||||
ExpiresIn: int(snapshotTTL.Seconds()),
|
||||
// Says out loud that this URL needs the session's bearer.
|
||||
// Clients used to infer it from the URL being relative, which
|
||||
// is true today and stops being true the first time object
|
||||
// storage is served from this same host.
|
||||
Auth: true,
|
||||
}
|
||||
case key == "":
|
||||
cams[i].Snapshot.Reason = "No picture from this camera yet."
|
||||
case s.Blob == nil:
|
||||
// A key from a bucket this server can no longer reach. Distinct
|
||||
// from "no picture yet": one is waiting, the other is misconfigured.
|
||||
cams[i].Snapshot.Reason = "This system is not storing images."
|
||||
default:
|
||||
url, err := s.Blob.PresignGet(key, snapshotTTL)
|
||||
if err != nil {
|
||||
@@ -73,9 +93,8 @@ func (s *Server) handleCreateCamera(w http.ResponseWriter, r *http.Request) {
|
||||
"Your account cannot change camera settings.")
|
||||
return
|
||||
}
|
||||
siteID := r.PathValue("site")
|
||||
if !looksLikeUUID(siteID) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
|
||||
siteID, ok := s.resolveSite(w, r, r.PathValue("site"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in CameraInput
|
||||
@@ -111,9 +130,8 @@ func (s *Server) handleUpdateCamera(w http.ResponseWriter, r *http.Request) {
|
||||
"Your account cannot change camera settings.")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
||||
id, ok := s.resolveCamera(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
existing, err := s.Store.CameraByID(r.Context(), p.ClientID, id)
|
||||
@@ -174,9 +192,8 @@ func (s *Server) handleDeleteCamera(w http.ResponseWriter, r *http.Request) {
|
||||
"Your account cannot change camera settings.")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
||||
id, ok := s.resolveCamera(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cam, err := s.Store.DeleteCamera(r.Context(), p.ClientID, id)
|
||||
|
||||
@@ -33,9 +33,8 @@ func (s *Server) handleRequestCheck(w http.ResponseWriter, r *http.Request) {
|
||||
"Your account cannot run camera checks.")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
||||
id, ok := s.resolveCamera(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req CheckRequest
|
||||
@@ -129,9 +128,8 @@ func (s *Server) handleAgentCheckResult(w http.ResponseWriter, r *http.Request,
|
||||
// and printing it next to a real failure buries the real failure.
|
||||
func (s *Server) handleSiteCheck(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
siteID := r.PathValue("site")
|
||||
if !looksLikeUUID(siteID) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
|
||||
siteID, ok := s.resolveSite(w, r, r.PathValue("site"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sites, err := s.Store.SiteHealth(r.Context(), p.ClientID)
|
||||
|
||||
@@ -25,9 +25,8 @@ func (s *Server) handleIssueEnrolmentCode(w http.ResponseWriter, r *http.Request
|
||||
"Your account cannot set up shop computers. Ask a manager or the owner.")
|
||||
return
|
||||
}
|
||||
site := r.PathValue("site")
|
||||
if !looksLikeUUID(site) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
|
||||
site, ok := s.resolveSite(w, r, r.PathValue("site"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in NewEnrolmentCodeInput
|
||||
|
||||
186
server/internal/api/handlers_faces.go
Normal file
186
server/internal/api/handlers_faces.go
Normal file
@@ -0,0 +1,186 @@
|
||||
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())}
|
||||
}
|
||||
}
|
||||
@@ -86,36 +86,40 @@ const (
|
||||
// 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
|
||||
}
|
||||
if s.Blob == nil {
|
||||
writeErr(w, http.StatusNotFound, "images_disabled",
|
||||
"This server does not store images.")
|
||||
id, ok := s.resolveVisitor(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key, err := s.Store.VisitorImageKey(r.Context(), p.ClientID, id)
|
||||
if err != nil || key == "" {
|
||||
writeErr(w, http.StatusNotFound, "no_image",
|
||||
"There is no photo for this customer.")
|
||||
return
|
||||
}
|
||||
url, err := s.Blob.PresignGet(key, viewTTL)
|
||||
if err != nil {
|
||||
s.serverError(w, "presign read", err)
|
||||
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, map[string]any{
|
||||
"url": url, "expires_in": int(viewTTL.Seconds()),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, img)
|
||||
}
|
||||
|
||||
// handleForgetVisitor is the erasure path.
|
||||
@@ -135,9 +139,8 @@ func (s *Server) handleForgetVisitor(w http.ResponseWriter, r *http.Request) {
|
||||
"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.")
|
||||
id, ok := s.resolveVisitor(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,8 +149,21 @@ func (s *Server) handleForgetVisitor(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
|
||||
172
server/internal/api/handlers_live.go
Normal file
172
server/internal/api/handlers_live.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// liveMaxFrame bounds one frame. The agent re-encodes to ~640 px before
|
||||
// sending, so a frame is tens of KB; 1 MB is a ceiling, not a target.
|
||||
liveMaxFrame = 1 << 20
|
||||
// liveSession caps one push. A browser tab left open for a week must not
|
||||
// leave a shop uploading for a week; the agent simply asks again while
|
||||
// anyone is still watching, so the cap costs a reconnect, not the stream.
|
||||
liveSession = 5 * time.Minute
|
||||
// liveWaitForWork is how long the agent's poll is held open. Long enough
|
||||
// that an idle site makes ~2 requests a minute; short enough to sit well
|
||||
// inside any proxy's idle timeout.
|
||||
liveWaitForWork = 25 * time.Second
|
||||
)
|
||||
|
||||
// handleWatchLive streams one camera's frames to a signed-in user over SSE.
|
||||
//
|
||||
// SSE rather than serving MJPEG directly, for the same reason the snapshot is
|
||||
// not a signed link: an <img> cannot send an Authorization header, and minting
|
||||
// a URL that works without a session - for LIVE video of a shop floor, no less
|
||||
// - would be a much worse trade than the 33% base64 costs.
|
||||
func (s *Server) handleWatchLive(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
id, ok := s.resolveCamera(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Ownership is checked HERE, once, before anything is streamed. Everything
|
||||
// after this point is keyed on a camera id, and a hub does not know whose
|
||||
// camera it is holding.
|
||||
if _, _, err := s.Store.CameraRef(r.Context(), p.ClientID, id); err != nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
s.serverError(w, "live", errors.New("this server cannot stream"))
|
||||
return
|
||||
}
|
||||
|
||||
frames, release := s.live().Watch(id)
|
||||
defer release()
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream")
|
||||
h.Set("Cache-Control", "no-store")
|
||||
h.Set("Connection", "keep-alive")
|
||||
// Without this a proxy buffers the stream into one response that arrives
|
||||
// when the connection closes - which for live video means never.
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// Told up front, so a viewer can say "waiting for the shop PC" rather than
|
||||
// showing an empty box while the agent is still being asked.
|
||||
fmt.Fprint(w, "event: waiting\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
ctx := r.Context()
|
||||
// Refreshed as we go rather than once at the start: this is what tells the
|
||||
// agent somebody is still there, and a viewer that has gone away stops a
|
||||
// shop uploading within seconds without having to announce anything.
|
||||
keep := time.NewTicker(liveIdle / 3)
|
||||
defer keep.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-keep.C:
|
||||
s.live().Keep(id)
|
||||
case frame, ok := <-frames:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.live().Keep(id)
|
||||
if _, err := fmt.Fprintf(w, "event: frame\ndata: %s\n\n",
|
||||
base64.StdEncoding.EncodeToString(frame)); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleAgentLiveWanted is the shop PC asking whether anyone is watching.
|
||||
//
|
||||
// Held open rather than answered immediately: an agent polling every few
|
||||
// seconds would put a floor under how quickly a live view can start, and one
|
||||
// polling slowly would put a ceiling on it. Holding the request means pressing
|
||||
// "Live" reaches the shop PC at once, and an idle site costs about two requests
|
||||
// a minute.
|
||||
func (s *Server) handleAgentLiveWanted(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
||||
ids, err := s.Store.SiteCameraIDs(r.Context(), ap.SiteID)
|
||||
if err != nil {
|
||||
s.serverError(w, "live wanted", err)
|
||||
return
|
||||
}
|
||||
if wanted := s.live().WantedAmong(ids); len(wanted) > 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"cameras": wanted})
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-s.live().Bell(ids):
|
||||
case <-time.After(liveWaitForWork):
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"cameras": s.live().WantedAmong(ids)})
|
||||
}
|
||||
|
||||
// handleAgentPushLive receives frames for as long as somebody is watching.
|
||||
//
|
||||
// One request carrying many frames, each prefixed with its length, rather than
|
||||
// a request per frame: at a few frames a second the per-request overhead and
|
||||
// the TLS handshakes would cost more than the pictures.
|
||||
func (s *Server) handleAgentPushLive(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
||||
id := r.PathValue("camera")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
||||
return
|
||||
}
|
||||
// The camera must belong to the AGENT's own site. Without this an agent
|
||||
// could push its own pictures into another site's live view - a camera id
|
||||
// is not a secret, and the agent supplies this one.
|
||||
siteID, _, err := s.Store.CameraRefBySite(r.Context(), ap.SiteID, id)
|
||||
if err != nil || siteID != ap.SiteID {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
||||
return
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(liveSession)
|
||||
body := r.Body
|
||||
var header [4]byte
|
||||
frames := 0
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
if _, err := io.ReadFull(body, header[:]); err != nil {
|
||||
break
|
||||
}
|
||||
n := binary.BigEndian.Uint32(header[:])
|
||||
if n == 0 || n > liveMaxFrame {
|
||||
// A length this side cannot trust ends the stream rather than
|
||||
// allocating what it was told to.
|
||||
writeErr(w, http.StatusBadRequest, "bad_frame", "Frame size out of range.")
|
||||
return
|
||||
}
|
||||
frame := make([]byte, n)
|
||||
if _, err := io.ReadFull(body, frame); err != nil {
|
||||
break
|
||||
}
|
||||
frames++
|
||||
if !s.live().Publish(id, frame) {
|
||||
// Nobody is watching any more. Saying so in the response is what
|
||||
// stops the shop uploading; the agent goes back to waiting.
|
||||
break
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"frames": frames})
|
||||
}
|
||||
@@ -26,11 +26,10 @@ func (s *Server) handleVisitors(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleVisitorHistory(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
// 404, not 400: to the caller a malformed id and an id that does not
|
||||
// exist are the same thing - the customer is not there.
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
|
||||
// 404, not 400: to the caller a reference that is malformed and one that
|
||||
// names nobody are the same thing - the customer is not there.
|
||||
id, ok := s.resolveVisitor(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := s.Store.VisitorHistory(r.Context(), p.ClientID, id,
|
||||
@@ -65,11 +64,11 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// The path wins over the body. Trusting the body would let a client PUT to
|
||||
// one customer's URL and write to another's record.
|
||||
body.VisitorID = r.PathValue("id")
|
||||
if !looksLikeUUID(body.VisitorID) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
|
||||
visitorID, ok := s.resolveVisitor(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body.VisitorID = visitorID
|
||||
body.FullName = clip(trim(body.FullName), 200)
|
||||
body.Phone = clip(trim(body.Phone), 40)
|
||||
body.Email = auth.NormalizeEmail(body.Email)
|
||||
@@ -124,10 +123,11 @@ func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, "visitor_id is required")
|
||||
return
|
||||
}
|
||||
if !looksLikeUUID(body.VisitorID) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
|
||||
visitorID, ok := s.resolveVisitor(w, r, body.VisitorID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body.VisitorID = visitorID
|
||||
if body.Amount < 0 {
|
||||
// A refund is a different record with a different meaning, not a
|
||||
// negative sale. Allowing it here would quietly deflate the revenue
|
||||
|
||||
@@ -25,7 +25,21 @@ func (s *Server) reportQuery(r *http.Request) (ReportQuery, error) {
|
||||
|
||||
// The tenant comes from the session. A client_id parameter would be a
|
||||
// cross-tenant read waiting for somebody to try it.
|
||||
out := ReportQuery{ClientID: p.ClientID, SiteID: trim(q.Get("site"))}
|
||||
out := ReportQuery{ClientID: p.ClientID}
|
||||
|
||||
// A shop may be named by uuid or by its slug. Resolved here, where an
|
||||
// unknown one is a 400 the caller can read, rather than in Postgres where
|
||||
// a malformed uuid is a cast error and surfaces as a 500.
|
||||
if raw := siteParam(r); raw != "" {
|
||||
id, err := s.siteIDFor(r.Context(), p.ClientID, raw)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("could not look up that shop: %w", err)
|
||||
}
|
||||
if id == "" {
|
||||
return out, fmt.Errorf("no shop called %q", raw)
|
||||
}
|
||||
out.SiteID = id
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
from, err := parseDay(q.Get("from"), now.AddDate(0, 0, -29))
|
||||
|
||||
80
server/internal/api/handlers_sessions.go
Normal file
80
server/internal/api/handlers_sessions.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Which devices are signed in, and signing one of them out.
|
||||
//
|
||||
// This is the point of opaque tokens in a table rather than JWTs, and until now
|
||||
// the product had the cost of that choice without the benefit. The argument
|
||||
// recorded for it was that this system puts customer data on shop-floor PCs and
|
||||
// staff phones that get lost, resold and shared between people, so "log that
|
||||
// device out, now" has to actually work - and there was no endpoint that could
|
||||
// list what was signed in, let alone stop one.
|
||||
//
|
||||
// It matters most on mobile, which is why it arrives with it: a phone is the
|
||||
// device most likely to leave the building in somebody's pocket.
|
||||
|
||||
func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
rows, err := s.Store.UserSessions(r.Context(), p.UserID)
|
||||
if err != nil {
|
||||
s.serverError(w, "list sessions", err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []DeviceSession{}
|
||||
}
|
||||
// Marked here rather than in SQL: which session is "this one" is a property
|
||||
// of the request, and the store has no business knowing about requests.
|
||||
for i := range rows {
|
||||
rows[i].Current = rows[i].ID == p.SessionID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// handleRevokeSession signs one device out.
|
||||
//
|
||||
// A person may only revoke their OWN sessions - the store scopes the update by
|
||||
// user id, so a session id, which is not a secret and travels in the list
|
||||
// above, cannot be used to sign somebody else out. Removing a colleague's
|
||||
// access is a different question with a different answer: deactivate them
|
||||
// through the team endpoint, which revokes every session they have.
|
||||
func (s *Server) handleRevokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such device.")
|
||||
return
|
||||
}
|
||||
if err := s.Store.RevokeUserSession(r.Context(), p.UserID, id); err != nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such device.")
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "auth.session.revoke", Entity: "session", EntityID: id,
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRevokeOtherSessions is "sign out everywhere else".
|
||||
//
|
||||
// It deliberately keeps the caller's own session. Somebody who has just lost a
|
||||
// phone should not also be signed out of the device in their hand, in the
|
||||
// middle of dealing with it.
|
||||
func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
n, err := s.Store.RevokeOtherSessions(r.Context(), p.UserID, p.SessionID)
|
||||
if err != nil {
|
||||
s.serverError(w, "revoke sessions", err)
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "auth.session.revoke_others", Entity: "session",
|
||||
Detail: map[string]any{"count": n},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"signed_out": n})
|
||||
}
|
||||
118
server/internal/api/handlers_snapshots.go
Normal file
118
server/internal/api/handlers_snapshots.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxSnapshotBytes caps what a shop PC may store per camera.
|
||||
//
|
||||
// A camera frame downscaled to 1280 px is ~100 KB; 2 MB is generous for a
|
||||
// 4K still and small enough that a misbehaving or compromised agent cannot use
|
||||
// this endpoint as free storage. One row per camera means it cannot accumulate
|
||||
// either - the cap is about a single request, the primary key about the total.
|
||||
const maxSnapshotBytes = 2 << 20
|
||||
|
||||
// snapshotMaxAge is how long a browser may reuse a camera picture. The agent
|
||||
// refreshes them every 60 s, so anything longer shows a stale shop floor and
|
||||
// anything shorter re-fetches a picture that has not changed.
|
||||
const snapshotMaxAge = 30 * time.Second
|
||||
|
||||
// handlePutSnapshot stores the latest frame from one of this site's cameras.
|
||||
//
|
||||
// This is the path for a deployment with NO object storage. Where a bucket is
|
||||
// configured the agent keeps using the presigned-URL route, which never puts a
|
||||
// picture through this process at all; both exist because they are right for
|
||||
// different deployments, not because one supersedes the other.
|
||||
//
|
||||
// The body is the JPEG itself rather than JSON with base64: it avoids a third
|
||||
// of the bytes and a decode step, and there is exactly one thing being sent.
|
||||
func (s *Server) handlePutSnapshot(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
||||
cameraID := r.PathValue("camera")
|
||||
if cameraID == "" {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
||||
return
|
||||
}
|
||||
|
||||
// http.MaxBytesReader, not a Content-Length check: a length header is
|
||||
// whatever the client says it is, and this has to bound what is actually
|
||||
// read into memory.
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxSnapshotBytes+1))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "too_large",
|
||||
fmt.Sprintf("A snapshot must be under %d KB.", maxSnapshotBytes/1024))
|
||||
return
|
||||
}
|
||||
if len(body) > maxSnapshotBytes {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "too_large",
|
||||
fmt.Sprintf("A snapshot must be under %d KB.", maxSnapshotBytes/1024))
|
||||
return
|
||||
}
|
||||
// Checked against the bytes, not the Content-Type header. This endpoint
|
||||
// stores whatever it is given and hands it back to a browser later, 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 snapshot must be a JPEG")
|
||||
return
|
||||
}
|
||||
|
||||
switch err := s.Store.PutCameraSnapshot(r.Context(),
|
||||
ap.ClientID, ap.SiteID, cameraID, body); {
|
||||
case err == nil:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case errors.Is(err, ErrNoSnapshot):
|
||||
// Head office has not adopted this camera yet. Not the agent's fault
|
||||
// and not worth retrying: the next sync adopts it.
|
||||
writeErr(w, http.StatusNotFound, "not_found",
|
||||
"That camera is not set up at head office yet.")
|
||||
default:
|
||||
s.serverError(w, "store snapshot", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetSnapshot serves a camera's stored picture to a signed-in user.
|
||||
//
|
||||
// Deliberately NOT a signed link like the bucket path: there is no third party
|
||||
// to delegate to here, the bytes are in this server's own database, and minting
|
||||
// a URL that works without a session in order to serve them would be adding an
|
||||
// unauthenticated path to reach a picture of somebody's shop floor for no gain.
|
||||
func (s *Server) handleGetSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
||||
return
|
||||
}
|
||||
img, at, err := s.Store.CameraSnapshot(r.Context(), p.ClientID, id)
|
||||
if errors.Is(err, ErrNoSnapshot) {
|
||||
writeErr(w, http.StatusNotFound, "no_image", "No picture from this camera yet.")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.serverError(w, "read snapshot", err)
|
||||
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(snapshotMaxAge.Seconds())))
|
||||
w.Header().Set("Last-Modified", at.UTC().Format(http.TimeFormat))
|
||||
// A picture of a shop floor is not something to hand to another origin's
|
||||
// script, and nothing here needs to.
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(img)
|
||||
}
|
||||
|
||||
// isJPEG checks the magic bytes: SOI marker at the front, EOI at the back.
|
||||
func isJPEG(b []byte) bool {
|
||||
if len(b) < 4 {
|
||||
return false
|
||||
}
|
||||
return b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF
|
||||
}
|
||||
521
server/internal/api/handlers_team.go
Normal file
521
server/internal/api/handlers_team.go
Normal file
@@ -0,0 +1,521 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// Adding people to a company: invitations, registration, and the team list.
|
||||
//
|
||||
// Registration is by INVITATION, and that is the same decision handlers_admin.go
|
||||
// records for creating a company: an endpoint a stranger can call to create an
|
||||
// account is a far larger thing to secure than one reachable only through
|
||||
// somebody who already has one. What was missing was not the openness - it was
|
||||
// that a tenant could not add a SECOND person at all except by somebody with a
|
||||
// shell on the server running `provision user`. A shop with an owner and four
|
||||
// staff either shared one password between five people or raised a ticket per
|
||||
// person, and a phone app for shop-floor staff could not exist while there was
|
||||
// only ever one account to sign in as.
|
||||
//
|
||||
// So: a manager mints a code, hands it over, and the holder chooses their own
|
||||
// password. The code carries the address and the role; the request carries only
|
||||
// the password and a name. That split is load-bearing and is why this is not
|
||||
// simply "create a user with these fields" - see handleRegister.
|
||||
|
||||
const (
|
||||
// Long enough to reach somebody who is not at work today, short enough that
|
||||
// a code left in a chat thread is worthless before anyone scrolls back to
|
||||
// it. An expired invitation costs one click to reissue.
|
||||
invitationTTL = 7 * 24 * time.Hour
|
||||
maxInvitation = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// handleInvite mints one invitation.
|
||||
//
|
||||
// Manager and above. Not staff: the holder of a code gets an account inside
|
||||
// this company, so it is a credential, not a convenience.
|
||||
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot invite people to this company.")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
Days int `json:"expires_in_days"`
|
||||
}
|
||||
if err := decode(w, r, &body); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
email := auth.NormalizeEmail(body.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
badRequest(w, "an email address is required - it is what they will sign in with")
|
||||
return
|
||||
}
|
||||
role := strings.ToLower(trim(body.Role))
|
||||
if role == "" {
|
||||
role = "staff"
|
||||
}
|
||||
// 'admin' is absent on purpose. A platform administrator is defined by
|
||||
// having no company at all, so an invitation could never mint a real one -
|
||||
// what it could do is create the tenant-scoped row with role='admin' that
|
||||
// adminOnly exists to reject, and a role nothing can use is a trap rather
|
||||
// than a feature.
|
||||
switch role {
|
||||
case "owner", "manager", "staff":
|
||||
default:
|
||||
badRequest(w, "role must be owner, manager or staff")
|
||||
return
|
||||
}
|
||||
// Only an owner may create another owner. A manager promoting somebody past
|
||||
// themselves is an escalation, and it is the one shape of this endpoint
|
||||
// that would matter if a manager account were ever taken over.
|
||||
if role == "owner" && p.Role != "owner" && p.Role != "admin" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Only an owner can invite another owner.")
|
||||
return
|
||||
}
|
||||
|
||||
ttl := invitationTTL
|
||||
if body.Days > 0 {
|
||||
ttl = time.Duration(body.Days) * 24 * time.Hour
|
||||
if ttl > maxInvitation {
|
||||
ttl = maxInvitation
|
||||
}
|
||||
}
|
||||
|
||||
code, err := auth.NewEnrolmentCode()
|
||||
if err != nil {
|
||||
s.serverError(w, "mint invitation", err)
|
||||
return
|
||||
}
|
||||
inv, err := s.Store.CreateInvitation(r.Context(), NewInvitation{
|
||||
ClientID: p.ClientID,
|
||||
Email: email,
|
||||
FullName: clip(trim(body.FullName), 200),
|
||||
Role: role,
|
||||
CodeHash: auth.HashToken(auth.NormalizeCode(code)),
|
||||
InvitedBy: p.UserID,
|
||||
ExpiresAt: s.now().Add(ttl),
|
||||
})
|
||||
if err != nil {
|
||||
s.serverError(w, "create invitation", err)
|
||||
return
|
||||
}
|
||||
// The plaintext exists here and in this response, and nowhere else. Like
|
||||
// every other secret this system mints, it is shown once: one a support
|
||||
// engineer can look up later is one anybody with support access can redeem.
|
||||
inv.Code = code
|
||||
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "team.invite", Entity: "invitation", EntityID: inv.ID,
|
||||
Detail: map[string]any{"email": email, "role": role},
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, inv)
|
||||
}
|
||||
|
||||
func (s *Server) handleInvitations(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot see this company's invitations.")
|
||||
return
|
||||
}
|
||||
rows, err := s.Store.PendingInvitations(r.Context(), p.ClientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "list invitations", err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []Invitation{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot withdraw invitations.")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such invitation.")
|
||||
return
|
||||
}
|
||||
if err := s.Store.RevokeInvitation(r.Context(), p.ClientID, id); err != nil {
|
||||
// Already used or already withdrawn. Reported rather than swallowed:
|
||||
// "I cancelled it" and "somebody had already joined with it" need
|
||||
// opposite next steps from whoever pressed the button.
|
||||
writeErr(w, http.StatusNotFound, "not_found",
|
||||
"That invitation is no longer pending.")
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "team.invite.revoke", Entity: "invitation", EntityID: id,
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleInvitationPreview lets a client show what a code is for before asking
|
||||
// somebody to choose a password.
|
||||
//
|
||||
// Unauthenticated, because the holder has no account yet - that is the whole
|
||||
// point - and it discloses only what the code itself already asserts: the
|
||||
// company, the address it was issued for, and the role. Unknown, expired, spent
|
||||
// and revoked are one identical answer, exactly as enrolment already does:
|
||||
// telling them apart only helps somebody guessing codes, and the holder's next
|
||||
// step is the same in all four cases.
|
||||
func (s *Server) handleInvitationPreview(w http.ResponseWriter, r *http.Request) {
|
||||
code := auth.NormalizeCode(r.URL.Query().Get("code"))
|
||||
if code == "" {
|
||||
badRequest(w, "a code is required")
|
||||
return
|
||||
}
|
||||
prev, err := s.Store.InvitationByCode(r.Context(), auth.HashToken(code))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "invalid_code",
|
||||
"That invitation code is not valid. Ask for a new one.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, prev)
|
||||
}
|
||||
|
||||
// handleRegister turns a code into an account and signs the person in.
|
||||
//
|
||||
// Unauthenticated for the same reason `POST /api/agent/enrol` is: whoever is
|
||||
// doing this has no account yet, and requiring one first would mean shipping a
|
||||
// password to everybody who needs one.
|
||||
//
|
||||
// The email and the role come from the INVITATION, never from this body. A code
|
||||
// forwarded to a colleague must not become an account for them, and a staff
|
||||
// invitation must not be redeemed as an owner - which is exactly what a
|
||||
// caller-supplied role would allow. The only things the request decides are the
|
||||
// password and the display name.
|
||||
//
|
||||
// It returns a Session, identical in shape to login. A new member's next screen
|
||||
// is the app, not a sign-in form they have to fill in with the password they
|
||||
// chose four seconds ago.
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
FullName string `json:"full_name"`
|
||||
Password string `json:"password"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
if err := decode(w, r, &body); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
code := auth.NormalizeCode(body.Code)
|
||||
if code == "" {
|
||||
badRequest(w, "an invitation code is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Throttled on the code, by IP. Redeeming is the one unauthenticated write
|
||||
// in this package that creates a row, so an unbounded one is a way to grind
|
||||
// through the code space and to fill a table while doing it.
|
||||
_, perIP := s.throttles()
|
||||
ipKey := clientIP(r)
|
||||
if !perIP.Allow(ipKey) {
|
||||
writeErr(w, http.StatusTooManyRequests, "too_many_attempts",
|
||||
"Too many attempts. Wait a few minutes and try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.CheckPasswordPolicy(body.Password); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
s.serverError(w, "hash password", err)
|
||||
return
|
||||
}
|
||||
|
||||
rec, err := s.Store.RedeemInvitation(r.Context(), auth.HashToken(code),
|
||||
clip(trim(body.FullName), 200), hash)
|
||||
if err != nil {
|
||||
perIP.Fail(ipKey)
|
||||
if msg, ok := conflictMessage(err); ok {
|
||||
// The address already has an account somewhere on the platform.
|
||||
// Worth saying plainly: the fix is to sign in, not to try again.
|
||||
writeErr(w, http.StatusConflict, "conflict", msg)
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusNotFound, "invalid_code",
|
||||
"That invitation code is not valid. Ask for a new one.")
|
||||
return
|
||||
}
|
||||
perIP.Reset(ipKey)
|
||||
|
||||
sess, err := s.mint(r, rec, body.Device)
|
||||
if err != nil {
|
||||
// The account exists and the invitation is spent. Say so rather than
|
||||
// implying nothing happened - the recovery is to sign in, and telling
|
||||
// them to redeem again would fail forever.
|
||||
s.logf("ERROR register: created %s but could not start a session: %v", rec.ID, err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error",
|
||||
"Your account was created but we could not sign you in. Please sign in.")
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: rec.ClientID, ActorID: rec.ID, ActorKind: "user",
|
||||
Action: "team.register", Entity: "user", EntityID: rec.ID,
|
||||
Detail: map[string]any{"role": rec.Role, "device": trim(body.Device)},
|
||||
})
|
||||
s.logf("registered %s (%s) into client %s", rec.Email, rec.Role, rec.ClientID)
|
||||
writeJSON(w, http.StatusCreated, sess)
|
||||
}
|
||||
|
||||
func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"This account does not belong to a company.")
|
||||
return
|
||||
}
|
||||
rows, err := s.Store.Team(r.Context(), p.ClientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "list team", err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []TeamMember{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// handleUpdateTeamMember changes a role, or turns an account off.
|
||||
//
|
||||
// Deactivating is the "they have left" button, and the store revokes their
|
||||
// sessions in the same transaction: an access token lives twelve hours, so
|
||||
// without that, removing somebody's access would remove it sometime tomorrow.
|
||||
func (s *Server) handleUpdateTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot change who works here.")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such team member.")
|
||||
return
|
||||
}
|
||||
|
||||
var up TeamUpdate
|
||||
if err := decode(w, r, &up); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if up.Role == nil && up.Active == nil {
|
||||
badRequest(w, "nothing to change - send a role, an active flag, or both")
|
||||
return
|
||||
}
|
||||
if up.Role != nil {
|
||||
role := strings.ToLower(trim(*up.Role))
|
||||
switch role {
|
||||
case "owner", "manager", "staff":
|
||||
default:
|
||||
badRequest(w, "role must be owner, manager or staff")
|
||||
return
|
||||
}
|
||||
if role == "owner" && p.Role != "owner" && p.Role != "admin" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Only an owner can make somebody else an owner.")
|
||||
return
|
||||
}
|
||||
up.Role = &role
|
||||
}
|
||||
|
||||
// The company must keep an owner. Losing the last one leaves a tenant
|
||||
// nobody can administer, and the only way back is a shell on the server -
|
||||
// which is the thing this whole surface exists to stop needing.
|
||||
demoting := up.Role != nil && *up.Role != "owner"
|
||||
disabling := up.Active != nil && !*up.Active
|
||||
if demoting || disabling {
|
||||
if last, err := s.lastOwner(r, id); err != nil {
|
||||
s.serverError(w, "count owners", err)
|
||||
return
|
||||
} else if last {
|
||||
writeErr(w, http.StatusConflict, "last_owner",
|
||||
"This is the company's only owner. Make somebody else an owner first.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m, err := s.Store.UpdateTeamMember(r.Context(), p.ClientID, id, up)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such team member.")
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "team.update", Entity: "user", EntityID: id,
|
||||
Detail: map[string]any{"role": m.Role, "active": m.Active},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
// lastOwner reports whether the named member is the only active owner left.
|
||||
func (s *Server) lastOwner(r *http.Request, userID string) (bool, error) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
rows, err := s.Store.Team(r.Context(), p.ClientID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
owners, isOwner := 0, false
|
||||
for _, m := range rows {
|
||||
if m.Role == "owner" && m.Active {
|
||||
owners++
|
||||
if m.ID == userID {
|
||||
isOwner = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return isOwner && owners == 1, nil
|
||||
}
|
||||
|
||||
// handleCreateMember is a manager creating a salesperson's login directly and
|
||||
// handing it over - the path for somebody being set up before their first
|
||||
// shift, without a phone in hand.
|
||||
//
|
||||
// Same rules as an invitation for who may create whom: manager and above, and
|
||||
// only an owner mints an owner. Same rule as the platform admin creating a
|
||||
// merchant for the password: generated unless given, returned exactly once.
|
||||
func (s *Server) handleCreateMember(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Only a manager or owner can add team members.")
|
||||
return
|
||||
}
|
||||
|
||||
var in NewMemberInput
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
in.Email = auth.NormalizeEmail(in.Email)
|
||||
if in.Email == "" || !strings.Contains(in.Email, "@") {
|
||||
badRequest(w, "an email address is required - it is what they will sign in with")
|
||||
return
|
||||
}
|
||||
in.FullName = clip(trim(in.FullName), 200)
|
||||
in.Role = strings.ToLower(trim(in.Role))
|
||||
if in.Role == "" {
|
||||
in.Role = "staff"
|
||||
}
|
||||
switch in.Role {
|
||||
case "owner", "manager", "staff":
|
||||
default:
|
||||
badRequest(w, "role must be owner, manager or staff")
|
||||
return
|
||||
}
|
||||
if in.Role == "owner" && p.Role != "owner" && p.Role != "admin" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Only an owner can create another owner.")
|
||||
return
|
||||
}
|
||||
|
||||
password := in.Password
|
||||
if password == "" {
|
||||
generated, err := auth.RandomPassword()
|
||||
if err != nil {
|
||||
s.serverError(w, "generate password", err)
|
||||
return
|
||||
}
|
||||
password = generated
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
// The policy message ("at least 8 characters") is written for the
|
||||
// person who typed it, so it goes out as-is.
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m, err := s.Store.CreateMember(r.Context(), p.ClientID, in, hash)
|
||||
if err != nil {
|
||||
if msg, ok := conflictMessage(err); ok {
|
||||
writeErr(w, http.StatusConflict, "conflict", msg)
|
||||
return
|
||||
}
|
||||
s.serverError(w, "create member", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "team.create", Entity: "user", EntityID: m.ID,
|
||||
Detail: map[string]any{"email": m.Email, "role": m.Role},
|
||||
})
|
||||
// The plaintext exists here and in this response, and nowhere else.
|
||||
writeJSON(w, http.StatusCreated, NewMemberResult{TeamMember: m, Password: password})
|
||||
}
|
||||
|
||||
// handleResetPassword is a manager resetting a member's password: the
|
||||
// salesperson forgot it, or lost the phone it was on. Returns the new one
|
||||
// once, and signs the member out everywhere - see the store for why those are
|
||||
// one operation.
|
||||
//
|
||||
// Deliberately not self-service and not "send an email": a shop-floor account
|
||||
// often has no mailbox anyone checks, and the person who can vouch for the
|
||||
// salesperson standing in front of them is their manager.
|
||||
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Only a manager or owner can reset a team member's password.")
|
||||
return
|
||||
}
|
||||
userID := r.PathValue("id")
|
||||
|
||||
var in PasswordReset
|
||||
if err := decodeOptional(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
password := in.Password
|
||||
if password == "" {
|
||||
generated, err := auth.RandomPassword()
|
||||
if err != nil {
|
||||
s.serverError(w, "generate password", err)
|
||||
return
|
||||
}
|
||||
password = generated
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m, err := s.Store.ResetMemberPassword(r.Context(), p.ClientID, userID, hash)
|
||||
if err != nil {
|
||||
// A user id from another tenant matches nothing, so it reads as 404 -
|
||||
// a tenant user has no business learning the id was real.
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such team member.")
|
||||
return
|
||||
}
|
||||
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "team.reset_password", Entity: "user", EntityID: m.ID,
|
||||
Detail: map[string]any{"email": m.Email},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, PasswordReset{Password: password})
|
||||
}
|
||||
184
server/internal/api/live.go
Normal file
184
server/internal/api/live.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LiveHub carries camera frames from a shop PC to whoever is watching.
|
||||
//
|
||||
// The shop PC's engine serves MJPEG on its own loopback, behind a router with
|
||||
// no inbound route, so head office cannot pull it. What head office CAN do is
|
||||
// answer the agent's outbound requests - which is the whole shape of this
|
||||
// product already - so the agent asks "is anyone watching?", and pushes frames
|
||||
// up for as long as somebody is.
|
||||
//
|
||||
// This is NOT true video. It is a few frames a second of re-encoded JPEG, which
|
||||
// is what an outbound HTTP relay can carry honestly. Real 25 fps video needs
|
||||
// WebRTC and a TURN server; this needs neither, and for "is that camera pointed
|
||||
// at the right place, and is someone in the shop" a few frames a second is what
|
||||
// the question actually requires.
|
||||
//
|
||||
// It is deliberately the OPPOSITE of the arrivals Hub, which is a doorbell that
|
||||
// pushes nothing because nothing may be lost. Here a dropped frame is the
|
||||
// correct outcome: a slow viewer must never stall the pump or accumulate a
|
||||
// backlog of stale pictures, because the only frame worth having is the newest
|
||||
// one. So each viewer gets a one-slot buffer and a full slot is overwritten.
|
||||
type LiveHub struct {
|
||||
mu sync.Mutex
|
||||
cameras map[string]*liveCamera
|
||||
}
|
||||
|
||||
type liveCamera struct {
|
||||
viewers map[chan []byte]struct{}
|
||||
// wanted is refreshed by every watching viewer. The agent stops pushing
|
||||
// when it lapses, which is what keeps a shop's uplink idle when nobody is
|
||||
// looking - the entire cost argument for this feature.
|
||||
wanted time.Time
|
||||
// bell fires when the first viewer arrives, so an agent long-polling for
|
||||
// work is answered immediately instead of on its next tick.
|
||||
bell chan struct{}
|
||||
}
|
||||
|
||||
// liveIdle is how long a camera stays "wanted" after the last viewer refreshed
|
||||
// it. Longer than the viewer's refresh interval so an ordinary pause between
|
||||
// refreshes does not stop the stream, short enough that a browser that
|
||||
// vanished stops a shop uploading within seconds.
|
||||
const liveIdle = 12 * time.Second
|
||||
|
||||
func NewLiveHub() *LiveHub {
|
||||
return &LiveHub{cameras: map[string]*liveCamera{}}
|
||||
}
|
||||
|
||||
// Watch registers a viewer and returns its frame channel plus a release func.
|
||||
func (h *LiveHub) Watch(cameraID string) (<-chan []byte, func()) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
c := h.cameras[cameraID]
|
||||
if c == nil {
|
||||
c = &liveCamera{viewers: map[chan []byte]struct{}{}, bell: make(chan struct{}, 1)}
|
||||
h.cameras[cameraID] = c
|
||||
}
|
||||
ch := make(chan []byte, 1)
|
||||
c.viewers[ch] = struct{}{}
|
||||
c.wanted = time.Now().Add(liveIdle)
|
||||
select {
|
||||
case c.bell <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return ch, func() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if cam := h.cameras[cameraID]; cam != nil {
|
||||
delete(cam.viewers, ch)
|
||||
if len(cam.viewers) == 0 {
|
||||
// Dropped entirely rather than left empty: an estate's worth of
|
||||
// cameras nobody is watching would otherwise accumulate here
|
||||
// for the life of the process.
|
||||
delete(h.cameras, cameraID)
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep extends a camera's interest window. Called by each viewer as it reads,
|
||||
// so interest expires on its own when a browser goes away without saying so -
|
||||
// which is the normal way a tab closes.
|
||||
func (h *LiveHub) Keep(cameraID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if c := h.cameras[cameraID]; c != nil {
|
||||
c.wanted = time.Now().Add(liveIdle)
|
||||
}
|
||||
}
|
||||
|
||||
// Wanted reports whether anyone is watching this camera right now.
|
||||
func (h *LiveHub) Wanted(cameraID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
c := h.cameras[cameraID]
|
||||
return c != nil && len(c.viewers) > 0 && time.Now().Before(c.wanted)
|
||||
}
|
||||
|
||||
// WantedAmong filters a site's cameras down to the ones being watched. The
|
||||
// agent asks with the cameras it has, so this never has to know a site's
|
||||
// inventory.
|
||||
func (h *LiveHub) WantedAmong(ids []string) []string {
|
||||
var out []string
|
||||
for _, id := range ids {
|
||||
if h.Wanted(id) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Bell returns a channel that fires when a viewer starts watching one of these
|
||||
// cameras, so an agent waiting for work wakes at once rather than on a tick.
|
||||
// A nil channel blocks forever, which is the right behaviour for the caller's
|
||||
// select when none of the cameras is known here yet.
|
||||
func (h *LiveHub) Bell(ids []string) <-chan struct{} {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
if c := h.cameras[id]; c != nil {
|
||||
return c.bell
|
||||
}
|
||||
}
|
||||
// Register a placeholder for the first camera so a later Watch can ring
|
||||
// something. Cheap: one struct per camera an agent asked about.
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
c := &liveCamera{viewers: map[chan []byte]struct{}{}, bell: make(chan struct{}, 1)}
|
||||
h.cameras[ids[0]] = c
|
||||
return c.bell
|
||||
}
|
||||
|
||||
// Publish hands one frame to every viewer of a camera and reports whether any
|
||||
// remain. The agent uses that answer to stop pushing.
|
||||
//
|
||||
// A viewer whose slot is full has its pending frame REPLACED, never queued. The
|
||||
// newest frame is the only one worth having, and a queue here would show a
|
||||
// viewer an ever-growing delay behind the shop rather than dropping back to
|
||||
// live.
|
||||
func (h *LiveHub) Publish(cameraID string, frame []byte) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
c := h.cameras[cameraID]
|
||||
if c == nil || len(c.viewers) == 0 {
|
||||
return false
|
||||
}
|
||||
for ch := range c.viewers {
|
||||
select {
|
||||
case ch <- frame:
|
||||
default:
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case ch <- frame:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Now().Before(c.wanted)
|
||||
}
|
||||
|
||||
|
||||
// live returns the hub, creating it on first use.
|
||||
//
|
||||
// Lazily, and stored on the Server, so a server built without one still works:
|
||||
// unlike the arrivals doorbell there is no degraded mode to fall back to here,
|
||||
// and a nil map panic on an endpoint somebody forgot to wire is the worst way
|
||||
// to find out.
|
||||
func (s *Server) live() *LiveHub {
|
||||
s.liveOnce.Do(func() {
|
||||
if s.Live == nil {
|
||||
s.Live = NewLiveHub()
|
||||
}
|
||||
})
|
||||
return s.Live
|
||||
}
|
||||
131
server/internal/api/live_test.go
Normal file
131
server/internal/api/live_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
const liveCam = "22222222-3333-4444-5555-666666666666"
|
||||
|
||||
// The whole cost argument for this feature: a camera nobody is watching must
|
||||
// cost a shop nothing at all. If Wanted were ever optimistic, every shop PC in
|
||||
// an estate would upload continuously.
|
||||
func TestNobodyWatchingMeansNothingIsWanted(t *testing.T) {
|
||||
h := NewLiveHub()
|
||||
if h.Wanted(liveCam) {
|
||||
t.Fatal("a camera nobody has asked for was reported as wanted")
|
||||
}
|
||||
if got := h.WantedAmong([]string{liveCam, "other"}); len(got) != 0 {
|
||||
t.Fatalf("wanted %v with no viewers", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAViewerMakesACameraWantedAndReleasingStopsIt(t *testing.T) {
|
||||
h := NewLiveHub()
|
||||
_, release := h.Watch(liveCam)
|
||||
if !h.Wanted(liveCam) {
|
||||
t.Fatal("a watched camera was not wanted")
|
||||
}
|
||||
release()
|
||||
if h.Wanted(liveCam) {
|
||||
t.Fatal("the camera stayed wanted after the last viewer left")
|
||||
}
|
||||
}
|
||||
|
||||
// The opposite policy to the arrivals Hub, and deliberately so. There nothing
|
||||
// may be lost; here the only frame worth having is the newest one, and a queue
|
||||
// would show a viewer an ever-growing delay behind the shop instead of dropping
|
||||
// back to live.
|
||||
func TestASlowViewerGetsTheNewestFrameNotTheOldest(t *testing.T) {
|
||||
h := NewLiveHub()
|
||||
frames, release := h.Watch(liveCam)
|
||||
defer release()
|
||||
|
||||
for _, f := range []string{"one", "two", "three"} {
|
||||
h.Publish(liveCam, []byte(f))
|
||||
}
|
||||
select {
|
||||
case got := <-frames:
|
||||
if string(got) != "three" {
|
||||
t.Fatalf("a slow viewer was served %q, want the newest frame", got)
|
||||
}
|
||||
default:
|
||||
t.Fatal("nothing was delivered")
|
||||
}
|
||||
}
|
||||
|
||||
// Publish reporting false is what stops the shop PC uploading. If it kept
|
||||
// saying true the agent would push into an empty room until the session cap.
|
||||
func TestPublishReportsWhenTheLastViewerHasGone(t *testing.T) {
|
||||
h := NewLiveHub()
|
||||
_, release := h.Watch(liveCam)
|
||||
if !h.Publish(liveCam, []byte("frame")) {
|
||||
t.Fatal("publish said to stop while somebody was watching")
|
||||
}
|
||||
release()
|
||||
if h.Publish(liveCam, []byte("frame")) {
|
||||
t.Fatal("publish did not say to stop after the last viewer left")
|
||||
}
|
||||
}
|
||||
|
||||
// A browser that vanishes without saying so - the normal way a tab closes -
|
||||
// must stop the upload on its own.
|
||||
func TestInterestExpiresWithoutBeingRefreshed(t *testing.T) {
|
||||
h := NewLiveHub()
|
||||
frames, release := h.Watch(liveCam)
|
||||
defer release()
|
||||
_ = frames
|
||||
|
||||
h.mu.Lock()
|
||||
h.cameras[liveCam].wanted = time.Now().Add(-time.Second)
|
||||
h.mu.Unlock()
|
||||
|
||||
if h.Wanted(liveCam) {
|
||||
t.Fatal("interest did not lapse")
|
||||
}
|
||||
if h.Publish(liveCam, []byte("frame")) {
|
||||
t.Fatal("publish kept the shop uploading for a viewer that had gone")
|
||||
}
|
||||
}
|
||||
|
||||
// The relay is keyed on a camera id and a hub does not know whose camera it is
|
||||
// holding, so ownership has to be proved before anything streams. Otherwise a
|
||||
// camera id - which is not a secret - would be enough to watch another
|
||||
// company's shop floor.
|
||||
func TestAnotherTenantCannotWatchYourCamera(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addCameraRef(liveCam, "client-1", "site-1", "cam1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cameras/"+liveCam+"/live", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), principalKey,
|
||||
auth.Principal{ClientID: "someone-else", Role: "owner"}))
|
||||
srv.handleWatchLive(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// An agent may only push into its OWN site's camera. The agent supplies this
|
||||
// id, and a camera id is not a secret.
|
||||
func TestAnAgentCannotPushIntoAnotherSitesCamera(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addCameraRef(liveCam, "client-1", "site-1", "cam1")
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-1", SiteID: "site-2"})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/api/agent/cameras/"+liveCam+"/live", nil)
|
||||
req.Header.Set("Authorization", "Bearer agent-token")
|
||||
srv.Routes().ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
192
server/internal/api/refs.go
Normal file
192
server/internal/api/refs.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Public references: the names people use for the things this API addresses.
|
||||
//
|
||||
// Every id in the schema is a uuid and stays one. A uuid is the right primary
|
||||
// key here - ids are minted in places that cannot ask a database for the next
|
||||
// value, and eleven tables reference them - but it is the wrong thing to put in
|
||||
// front of a person. "Which customer?" "3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c."
|
||||
// Nobody says that, writes it on a card, or reads it back down a phone without
|
||||
// getting it wrong.
|
||||
//
|
||||
// The fix is not a new key. Three of the four things anyone addresses by URL
|
||||
// ALREADY had a human name that this API simply refused to accept:
|
||||
//
|
||||
// site slug "chennai" - in the schema since 001
|
||||
// camera camera_id "Office1" - and it is what visits.camera_id holds
|
||||
// visitor V-<number> "V-42" - added in 012
|
||||
// user email - already the login
|
||||
//
|
||||
// So a caller may use either form anywhere an id is taken. A uuid resolves with
|
||||
// no lookup at all, exactly as before; only a non-uuid costs a query. That
|
||||
// keeps this additive: nothing that worked yesterday changes, including every
|
||||
// URL a client has already stored.
|
||||
//
|
||||
// The visitor number is per TENANT, which is what makes it safe to show. A
|
||||
// global sequence would tell any customer who signs up how many people the
|
||||
// whole platform has ever seen, from their own first visitor number.
|
||||
|
||||
// VisitorRefPrefix is deliberately a letter and a dash rather than bare digits.
|
||||
// It is what makes "V-42" recognisable as a customer rather than an order, a
|
||||
// till or a visit, and it is why a reference pasted into the wrong route fails
|
||||
// to parse instead of quietly matching a different record with that number.
|
||||
const VisitorRefPrefix = "V-"
|
||||
|
||||
// VisitorRef renders a customer number for display and for URLs.
|
||||
func VisitorRef(number int64) string {
|
||||
if number <= 0 {
|
||||
return ""
|
||||
}
|
||||
return VisitorRefPrefix + strconv.FormatInt(number, 10)
|
||||
}
|
||||
|
||||
// ParseVisitorRef accepts "V-42", "v-42" and bare "42".
|
||||
//
|
||||
// Bare digits are accepted because a shop assistant reading a number off a
|
||||
// screen will type the number, and refusing it teaches them to distrust the
|
||||
// field. There is nothing for it to collide with: a uuid is checked first and
|
||||
// is never all digits.
|
||||
func ParseVisitorRef(s string) (int64, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
if len(s) > 2 && (s[0] == 'V' || s[0] == 'v') && s[1] == '-' {
|
||||
s = s[2:]
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// looksLikeName bounds a slug or camera id before it reaches SQL. Not a
|
||||
// validity check - the lookup decides that - only a guard so a path segment
|
||||
// full of junk is answered as "no such thing" without a round trip.
|
||||
func looksLikeName(s string) bool {
|
||||
if s == "" || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
ok := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Each reference has two resolvers: an inner one that answers ("", nil) for
|
||||
// "no such thing", and an outer one that writes the response itself so a call
|
||||
// site stays the same three lines the uuid check was. Both exist because a
|
||||
// query parameter is validated where a 400 is the right answer and a path
|
||||
// segment where a 404 is - splitting them lets one implementation serve both.
|
||||
//
|
||||
// A reference that does not resolve is 404 on a path, never 400, for the reason
|
||||
// the old shape check gave: to the caller, a malformed reference and one that
|
||||
// names nothing are the same thing - it is not there.
|
||||
|
||||
func (s *Server) visitorIDFor(ctx context.Context, clientID, raw string) (string, error) {
|
||||
if looksLikeUUID(raw) {
|
||||
return raw, nil
|
||||
}
|
||||
number, ok := ParseVisitorRef(raw)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return s.Store.VisitorIDByNumber(ctx, clientID, number)
|
||||
}
|
||||
|
||||
func (s *Server) siteIDFor(ctx context.Context, clientID, raw string) (string, error) {
|
||||
if looksLikeUUID(raw) {
|
||||
return raw, nil
|
||||
}
|
||||
if !looksLikeName(raw) {
|
||||
return "", nil
|
||||
}
|
||||
return s.Store.SiteIDBySlug(ctx, clientID, strings.ToLower(raw))
|
||||
}
|
||||
|
||||
func (s *Server) cameraIDFor(ctx context.Context, clientID, raw string) (string, error) {
|
||||
if looksLikeUUID(raw) {
|
||||
return raw, nil
|
||||
}
|
||||
if !looksLikeName(raw) {
|
||||
return "", nil
|
||||
}
|
||||
return s.Store.CameraIDByRef(ctx, clientID, raw)
|
||||
}
|
||||
|
||||
func (s *Server) resolveVisitor(w http.ResponseWriter, r *http.Request, raw string) (string, bool) {
|
||||
return s.resolve(w, r, raw, s.visitorIDFor, "customer",
|
||||
"That customer no longer exists.")
|
||||
}
|
||||
|
||||
func (s *Server) resolveSite(w http.ResponseWriter, r *http.Request, raw string) (string, bool) {
|
||||
return s.resolve(w, r, raw, s.siteIDFor, "site",
|
||||
"That shop no longer exists.")
|
||||
}
|
||||
|
||||
func (s *Server) resolveCamera(w http.ResponseWriter, r *http.Request, raw string) (string, bool) {
|
||||
return s.resolve(w, r, raw, s.cameraIDFor, "camera",
|
||||
"That camera no longer exists.")
|
||||
}
|
||||
|
||||
// resolveSiteFilter is the query-string form: 400, not 404.
|
||||
//
|
||||
// The distinction is not pedantry. `/api/visits` exists and answered - what was
|
||||
// wrong was the filter the caller attached to it, and a 404 there reads as "the
|
||||
// arrivals feed is gone", which is a very different thing to go and investigate.
|
||||
// A path segment names the resource itself, so an unknown one IS a 404.
|
||||
func (s *Server) resolveSiteFilter(w http.ResponseWriter, r *http.Request, raw string) (string, bool) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
id, err := s.siteIDFor(r.Context(), p.ClientID, raw)
|
||||
if err != nil {
|
||||
s.serverError(w, "resolve site", err)
|
||||
return "", false
|
||||
}
|
||||
if id == "" {
|
||||
badRequest(w, "no shop called "+strconv.Quote(raw))
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (s *Server) resolve(w http.ResponseWriter, r *http.Request, raw string,
|
||||
lookup func(context.Context, string, string) (string, error),
|
||||
what, gone string) (string, bool) {
|
||||
|
||||
p := PrincipalFrom(r.Context())
|
||||
id, err := lookup(r.Context(), p.ClientID, raw)
|
||||
if err != nil {
|
||||
s.serverError(w, "resolve "+what, err)
|
||||
return "", false
|
||||
}
|
||||
if id == "" {
|
||||
writeErr(w, http.StatusNotFound, "not_found", gone)
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// siteParam reads "which shop" off a query string.
|
||||
//
|
||||
// Reports have always taken `site` and the arrivals feed `site_id`, which is a
|
||||
// wart rather than a rule - and an unknown query parameter is silently ignored,
|
||||
// so getting it the wrong way round returns the whole estate instead of an
|
||||
// error. Both names are accepted everywhere now; `site` is the documented one.
|
||||
func siteParam(r *http.Request) string {
|
||||
if v := trim(r.URL.Query().Get("site")); v != "" {
|
||||
return v
|
||||
}
|
||||
return trim(r.URL.Query().Get("site_id"))
|
||||
}
|
||||
142
server/internal/api/refs_test.go
Normal file
142
server/internal/api/refs_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVisitorRefRoundTrips(t *testing.T) {
|
||||
for _, n := range []int64{1, 42, 999999} {
|
||||
ref := VisitorRef(n)
|
||||
got, ok := ParseVisitorRef(ref)
|
||||
if !ok || got != n {
|
||||
t.Fatalf("VisitorRef(%d) = %q, parsed back as (%d, %v)", n, ref, got, ok)
|
||||
}
|
||||
}
|
||||
if got := VisitorRef(0); got != "" {
|
||||
t.Fatalf("an unnumbered visitor must render as no reference, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A shop assistant reading "Visitor 42" off a screen types 42. Refusing that
|
||||
// teaches them the field is unreliable, so bare digits are accepted - and there
|
||||
// is nothing for them to collide with, because a uuid is checked first and is
|
||||
// never all digits.
|
||||
func TestVisitorRefAcceptsWhatSomebodyWouldActuallyType(t *testing.T) {
|
||||
for _, in := range []string{"V-42", "v-42", "42", " V-42 "} {
|
||||
n, ok := ParseVisitorRef(in)
|
||||
if !ok || n != 42 {
|
||||
t.Fatalf("ParseVisitorRef(%q) = (%d, %v), want 42", in, n, ok)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "V-", "V-0", "-1", "V-x", "abc", "4 2",
|
||||
"3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c"} {
|
||||
if _, ok := ParseVisitorRef(in); ok {
|
||||
t.Fatalf("ParseVisitorRef(%q) accepted a reference it should not", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeNameRejectsJunkBeforeItReachesSQL(t *testing.T) {
|
||||
for _, ok := range []string{"chennai", "Office1", "cam_2", "a.b-c"} {
|
||||
if !looksLikeName(ok) {
|
||||
t.Fatalf("%q should be a usable name", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "a b", "a/b", "a'b", "../etc", "%", string(make([]byte, 65))} {
|
||||
if looksLikeName(bad) {
|
||||
t.Fatalf("%q should not reach a query", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The point of the whole scheme: a customer is addressable by the number staff
|
||||
// read on screen, and the uuid that clients already stored keeps working.
|
||||
func TestACustomerIsReachableByNumberAndByUUID(t *testing.T) {
|
||||
const id = "3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c"
|
||||
srv, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.visitors = []Customer{{ID: id, Ref: "V-42", Label: "Visitor 42"}}
|
||||
fs.history = []VisitRow{{Site: "Chennai"}}
|
||||
tok := login(t, srv, "manager@acme.com", "correct horse battery").Token
|
||||
|
||||
for _, path := range []string{"/api/visitors/" + id + "/history",
|
||||
"/api/visitors/V-42/history", "/api/visitors/42/history"} {
|
||||
rec := do(t, srv, http.MethodGet, path, tok, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d, want 200", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reference that names nobody is 404 on a path, and a filter that names no
|
||||
// shop is 400 on a query string. The difference matters: `/api/visits` answered
|
||||
// fine and what was wrong was the filter, so a 404 there would send somebody
|
||||
// looking for a missing arrivals feed.
|
||||
func TestAnUnknownReferenceIs404OnAPathAnd400OnAFilter(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
tok := login(t, srv, "manager@acme.com", "correct horse battery").Token
|
||||
|
||||
rec := do(t, srv, http.MethodGet, "/api/visitors/V-999/history", tok, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown customer = %d, want 404", rec.Code)
|
||||
}
|
||||
rec = do(t, srv, http.MethodGet, "/api/visits?site=nowhere", tok, nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown shop filter = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Reports have always taken `site` and the arrivals feed `site_id`. Both work
|
||||
// everywhere now, because an unknown query parameter is silently ignored - so
|
||||
// getting it the wrong way round returned the whole estate rather than an
|
||||
// error, which is a wrong number nobody would question.
|
||||
func TestBothSiteParameterNamesAreAccepted(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.sites = []SiteHealth{{SiteID: "11111111-1111-4111-8111-111111111111", Slug: "chennai"}}
|
||||
tok := login(t, srv, "manager@acme.com", "correct horse battery").Token
|
||||
|
||||
for _, q := range []string{"site=chennai", "site_id=chennai"} {
|
||||
fs.arrivalQ = ArrivalQuery{}
|
||||
if rec := do(t, srv, http.MethodGet, "/api/visits?"+q, tok, nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/visits?%s = %d", q, rec.Code)
|
||||
}
|
||||
if got := fs.arrivalQ.SiteID; got != "11111111-1111-4111-8111-111111111111" {
|
||||
t.Fatalf("%s resolved to %q", q, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An arrival names its shop three ways, and the one a client can filter by must
|
||||
// be among them. Reading "TeNext Chennai" off a row and then having no way to
|
||||
// ask for that shop except by uuid is the exact gap the reference scheme
|
||||
// exists to close.
|
||||
func TestAnArrivalCarriesTheShopReferenceItCanBeFilteredBy(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.arrivals = []Arrival{{
|
||||
VisitID: "c64dc53f-c7f0-4e61-a3b4-9a230f52b3a3", Seq: 265,
|
||||
SiteID: "7c9bb456-e0c6-436d-ba01-5d6cc4e3f466",
|
||||
Site: "TeNext Chennai", SiteSlug: "chennai",
|
||||
}}
|
||||
tok := login(t, srv, "manager@acme.com", "correct horse battery").Token
|
||||
|
||||
rec := do(t, srv, http.MethodGet, "/api/visits", tok, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `"site_slug":"chennai"`) {
|
||||
t.Fatalf("an arrival must carry the shop's reference: %s", body)
|
||||
}
|
||||
|
||||
// seq is a plain bigserial, so it counts every visit on the PLATFORM. It
|
||||
// must not travel: that is the total footfall of every customer we have,
|
||||
// on every row of every tenant's feed.
|
||||
if strings.Contains(body, `"seq"`) {
|
||||
t.Fatalf("the platform-wide visit counter leaked into the feed: %s", body)
|
||||
}
|
||||
}
|
||||
147
server/internal/api/sessions_test.go
Normal file
147
server/internal/api/sessions_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// "Log that device out, now" is the entire argument for keeping sessions in a
|
||||
// table instead of issuing JWTs. These are the tests that the argument is
|
||||
// actually cashed in.
|
||||
|
||||
func sessionList(t *testing.T, s *Server, token string) []DeviceSession {
|
||||
t.Helper()
|
||||
rec := do(t, s, "GET", "/api/auth/sessions", token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("sessions: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out []DeviceSession
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loginAs(t *testing.T, s *Server, email, password, device string) Session {
|
||||
t.Helper()
|
||||
rec := do(t, s, "POST", "/api/auth/login", "", map[string]string{
|
||||
"email": email, "password": password, "device": device})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var sess Session
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &sess); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
func TestAPersonCanSeeAndSignOutTheirOwnDevices(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
phone := loginAs(t, s, "manager@acme.com", "correct horse battery", "Pixel 8")
|
||||
till := loginAs(t, s, "manager@acme.com", "correct horse battery", "Shop PC")
|
||||
|
||||
rows := sessionList(t, s, till.Token)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want two devices, got %d: %+v", len(rows), rows)
|
||||
}
|
||||
var phoneID string
|
||||
for _, r := range rows {
|
||||
if r.Device == "Pixel 8" {
|
||||
phoneID = r.ID
|
||||
}
|
||||
// The device making the request must be labelled, or somebody signs
|
||||
// themselves out of the machine in their hand without meaning to.
|
||||
if r.Device == "Shop PC" && !r.Current {
|
||||
t.Error("the calling session is not marked current")
|
||||
}
|
||||
if r.Device == "Pixel 8" && r.Current {
|
||||
t.Error("another device is marked current")
|
||||
}
|
||||
}
|
||||
if phoneID == "" {
|
||||
t.Fatalf("the phone is not in the list: %+v", rows)
|
||||
}
|
||||
|
||||
if rec := do(t, s, "DELETE", "/api/auth/sessions/"+phoneID, till.Token, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Immediately, not when the access token happens to expire. A lost phone is
|
||||
// the case this exists for and twelve hours is not an answer.
|
||||
if rec := do(t, s, "GET", "/api/auth/me", phone.Token, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("the revoked device is still signed in, got %d", rec.Code)
|
||||
}
|
||||
if rec := do(t, s, "GET", "/api/auth/me", till.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("the calling device was signed out too, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A session id travels in the list above and is not a secret. The store scopes
|
||||
// the revoke by user id so one cannot be used to sign a colleague out.
|
||||
func TestOneUserCannotRevokeAnothersSession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedMember(fs, acmeStaffID, "sam@acme.com", "Sam", "staff")
|
||||
|
||||
victim := loginAs(t, s, "sam@acme.com", "correct horse battery", "Sam's phone")
|
||||
attacker := loginAs(t, s, "manager@acme.com", "correct horse battery", "Laptop")
|
||||
|
||||
// The id is obtained the way an attacker would have to: it is not in the
|
||||
// attacker's own list at all, so this uses the real one directly.
|
||||
var victimID string
|
||||
for _, r := range sessionList(t, s, victim.Token) {
|
||||
victimID = r.ID
|
||||
}
|
||||
if rec := do(t, s, "DELETE", "/api/auth/sessions/"+victimID, attacker.Token, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("one user revoked another's session, got %d", rec.Code)
|
||||
}
|
||||
if rec := do(t, s, "GET", "/api/auth/me", victim.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Fatal("the victim was signed out by somebody else")
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody who has just lost a phone must not also be signed out of the device
|
||||
// they are holding while they deal with it.
|
||||
func TestSignOutEverywhereElseKeepsTheCurrentDevice(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
lost := loginAs(t, s, "manager@acme.com", "correct horse battery", "Lost phone")
|
||||
old := loginAs(t, s, "manager@acme.com", "correct horse battery", "Old tablet")
|
||||
here := loginAs(t, s, "manager@acme.com", "correct horse battery", "Laptop")
|
||||
|
||||
rec := do(t, s, "POST", "/api/auth/sessions/revoke-others", here.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke others: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
SignedOut int `json:"signed_out"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.SignedOut != 2 {
|
||||
t.Errorf("want two devices signed out, got %d", out.SignedOut)
|
||||
}
|
||||
for name, tok := range map[string]string{"lost phone": lost.Token, "old tablet": old.Token} {
|
||||
if rec := do(t, s, "GET", "/api/auth/me", tok, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s is still signed in, got %d", name, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := do(t, s, "GET", "/api/auth/me", here.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Fatal("signing out everywhere else signed out this device too")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRoutesNeedASession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/auth/sessions"},
|
||||
{"DELETE", "/api/auth/sessions/" + acmeStaffID},
|
||||
{"POST", "/api/auth/sessions/revoke-others"},
|
||||
} {
|
||||
if rec := do(t, s, c.method, c.path, "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s: want 401, got %d", c.method, c.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
102
server/internal/api/snapshots_test.go
Normal file
102
server/internal/api/snapshots_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A minimal but real JPEG header: SOI + APP0. The endpoint checks the bytes,
|
||||
// not the Content-Type header, so a test that sends anything else is not
|
||||
// testing the same path a shop PC uses.
|
||||
func jpegBytes(padTo int) []byte {
|
||||
b := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0}
|
||||
for len(b) < padTo {
|
||||
b = append(b, 0x00)
|
||||
}
|
||||
return append(b, 0xFF, 0xD9)
|
||||
}
|
||||
|
||||
func TestAnAgentCanStoreItsCameraPicture(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-1", SiteID: "site-1"})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut,
|
||||
"/api/agent/cameras/entrance/snapshot", bytes.NewReader(jpegBytes(64)))
|
||||
req.Header.Set("Authorization", "Bearer agent-token")
|
||||
srv.Routes().ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("status %d: %s", rr.Code, rr.Body)
|
||||
}
|
||||
if len(fs.snapshots["entrance"]) == 0 {
|
||||
t.Fatal("nothing was stored")
|
||||
}
|
||||
// The tenant and site come from the AGENT's credential, never the request.
|
||||
// A camera id a caller can set must not be able to choose whose camera it
|
||||
// decorates.
|
||||
if fs.lastSnapshotClient != "client-1" || fs.lastSnapshotSite != "site-1" {
|
||||
t.Fatalf("stored against %s/%s", fs.lastSnapshotClient, fs.lastSnapshotSite)
|
||||
}
|
||||
}
|
||||
|
||||
// This endpoint stores whatever it is handed and serves it back to a browser
|
||||
// later, so the one thing it must not become is a way to park arbitrary content
|
||||
// under a URL this server will serve.
|
||||
func TestOnlyAJPEGIsAccepted(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-1", SiteID: "site-1"})
|
||||
|
||||
for _, body := range []string{
|
||||
"<html><script>alert(1)</script></html>",
|
||||
"GIF89a",
|
||||
"",
|
||||
} {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut,
|
||||
"/api/agent/cameras/entrance/snapshot", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer agent-token")
|
||||
// Claiming to be a JPEG must not help: the check is on the bytes.
|
||||
req.Header.Set("Content-Type", "image/jpeg")
|
||||
srv.Routes().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("body %q was accepted with status %d", body, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One row per camera is what makes this safe to keep in the database at all,
|
||||
// but a single oversized request still has to be bounded - it is read into
|
||||
// memory before anything else looks at it.
|
||||
func TestAnOversizedSnapshotIsRefused(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-1", SiteID: "site-1"})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut,
|
||||
"/api/agent/cameras/entrance/snapshot",
|
||||
bytes.NewReader(jpegBytes(maxSnapshotBytes+1024)))
|
||||
req.Header.Set("Authorization", "Bearer agent-token")
|
||||
srv.Routes().ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status %d", rr.Code)
|
||||
}
|
||||
if len(fs.snapshots) != 0 {
|
||||
t.Fatal("an oversized snapshot was stored anyway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnauthenticatedAgentCannotStoreAPicture(t *testing.T) {
|
||||
srv, _ := newServer(t)
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut,
|
||||
"/api/agent/cameras/entrance/snapshot", bytes.NewReader(jpegBytes(64)))
|
||||
srv.Routes().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status %d", rr.Code)
|
||||
}
|
||||
}
|
||||
201
server/internal/api/team_members_test.go
Normal file
201
server/internal/api/team_members_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The second way a salesperson gets a login: their manager creates it and hands
|
||||
// it over. Everything here is a property of the one rule that path lives by -
|
||||
// the password is shown once, to the manager, and to nobody afterwards.
|
||||
|
||||
func createMember(t *testing.T, s *Server, token string, body map[string]any) (int, NewMemberResult, string) {
|
||||
t.Helper()
|
||||
rec := do(t, s, "POST", "/api/team/members", token, body)
|
||||
var out NewMemberResult
|
||||
if rec.Code == http.StatusCreated {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return rec.Code, out, rec.Body.String()
|
||||
}
|
||||
|
||||
func TestAManagerCanCreateALoginAndHandItOver(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
code, out, body := createMember(t, s, mgr.Token, map[string]any{
|
||||
"email": "Priya@Acme.com", "full_name": "Priya R", "role": "staff"})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create: got %d, body %s", code, body)
|
||||
}
|
||||
// Generated, not blank, and long enough to be a credential rather than a
|
||||
// suggestion. The manager reads this off the screen onto a card.
|
||||
if len(out.Password) < 12 {
|
||||
t.Fatalf("password should be generated when not given, got %q", out.Password)
|
||||
}
|
||||
if out.Email != "priya@acme.com" || out.Role != "staff" || !out.Active {
|
||||
t.Fatalf("member not as created: %+v", out.TeamMember)
|
||||
}
|
||||
|
||||
// The whole point: the salesperson can sign in with what the manager was
|
||||
// shown, right now, on their own phone.
|
||||
sess := login(t, s, "priya@acme.com", out.Password)
|
||||
if sess.User.Client != "Acme Retail" || sess.User.Role != "staff" {
|
||||
t.Fatalf("the new member landed somewhere odd: %+v", sess.User)
|
||||
}
|
||||
}
|
||||
|
||||
// The password is returned by the request that set it and by nothing else. A
|
||||
// credential a manager can look up later is one anybody at that screen can
|
||||
// read off, and the team list is on screen all day.
|
||||
func TestThePasswordIsShownOnceAndNeverListed(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
_, out, _ := createMember(t, s, mgr.Token, map[string]any{"email": "sam@acme.com"})
|
||||
|
||||
rec := do(t, s, "GET", "/api/team", mgr.Token, nil)
|
||||
if strings.Contains(rec.Body.String(), out.Password) {
|
||||
t.Fatal("the team list carries a password")
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), `"password"`) {
|
||||
t.Fatal("the team list has a password field at all")
|
||||
}
|
||||
}
|
||||
|
||||
// Same shape of permission as an invitation, on purpose: the two paths create
|
||||
// the same thing, so a manager must not be able to do through one what they
|
||||
// are refused through the other.
|
||||
func TestStaffCannotCreateAndAManagerCannotCreateAnOwner(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedMember(fs, acmeStaffID, "staff@acme.com", "Sam", "staff")
|
||||
seedMember(fs, acmeOwnerID, "owner@acme.com", "Olu", "owner")
|
||||
|
||||
staff := login(t, s, "staff@acme.com", "correct horse battery")
|
||||
if code, _, _ := createMember(t, s, staff.Token, map[string]any{"email": "x@acme.com"}); code != http.StatusForbidden {
|
||||
t.Fatalf("staff creating a login: want 403, got %d", code)
|
||||
}
|
||||
|
||||
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
if code, _, _ := createMember(t, s, mgr.Token, map[string]any{"email": "boss@acme.com", "role": "owner"}); code != http.StatusForbidden {
|
||||
t.Fatalf("manager minting an owner: want 403, got %d", code)
|
||||
}
|
||||
|
||||
owner := login(t, s, "owner@acme.com", "correct horse battery")
|
||||
if code, _, body := createMember(t, s, owner.Token, map[string]any{"email": "boss@acme.com", "role": "owner"}); code != http.StatusCreated {
|
||||
t.Fatalf("owner minting an owner: want 201, got %d %s", code, body)
|
||||
}
|
||||
|
||||
// Never admin. A platform admin is defined by having no company, so this
|
||||
// could only ever mint the tenant-scoped role='admin' row that adminOnly
|
||||
// exists to reject.
|
||||
if code, _, _ := createMember(t, s, owner.Token, map[string]any{"email": "root@acme.com", "role": "admin"}); code != http.StatusBadRequest {
|
||||
t.Fatalf("role=admin: want 400, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAddressThatAlreadyExistsIsAConflictNotAFault(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
code, _, body := createMember(t, s, mgr.Token, map[string]any{"email": "manager@acme.com"})
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("want 409, got %d %s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "already has an account") {
|
||||
t.Fatalf("the message should say what to do about it: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A manager may choose the password, but not a bad one. The floor is the same
|
||||
// as everywhere else, and the policy message goes to them unchanged.
|
||||
func TestAChosenPasswordStillMeetsTheFloor(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
code, _, body := createMember(t, s, mgr.Token, map[string]any{"email": "a@acme.com", "password": "short"})
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d %s", code, body)
|
||||
}
|
||||
|
||||
code, out, _ := createMember(t, s, mgr.Token, map[string]any{"email": "b@acme.com", "password": "chosen-by-manager"})
|
||||
if code != http.StatusCreated || out.Password != "chosen-by-manager" {
|
||||
t.Fatalf("a valid chosen password should be used and echoed once, got %d %q", code, out.Password)
|
||||
}
|
||||
}
|
||||
|
||||
// Why a manager resets a password: the salesperson forgot it, or lost the
|
||||
// phone it was saved on. In the second case the phone is the problem, so the
|
||||
// reset that fixes the first must also fix the second.
|
||||
func TestAResetSignsTheOldPhoneOutAndTheNewPasswordIn(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedMember(fs, acmeStaffID, "priya@acme.com", "Priya", "staff")
|
||||
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
lostPhone := login(t, s, "priya@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/team/"+acmeStaffID+"/password", mgr.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("reset: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out PasswordReset
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Password) < 12 {
|
||||
t.Fatalf("reset should hand back a generated password, got %q", out.Password)
|
||||
}
|
||||
|
||||
// The lost phone is out.
|
||||
if rec := do(t, s, "GET", "/api/auth/me", lostPhone.Token, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("the old session should be revoked by a reset, got %d", rec.Code)
|
||||
}
|
||||
// The old password is dead.
|
||||
if rec := do(t, s, "POST", "/api/auth/login", "", map[string]any{
|
||||
"email": "priya@acme.com", "password": "correct horse battery"}); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("the old password still works after a reset, got %d", rec.Code)
|
||||
}
|
||||
// The new one is alive.
|
||||
login(t, s, "priya@acme.com", out.Password)
|
||||
}
|
||||
|
||||
// A user id is not a secret and this endpoint hands out a credential, so it
|
||||
// must not be reachable across tenants - and it must read as "no such person",
|
||||
// not as "that id is real but not yours".
|
||||
func TestAResetCannotReachAnotherCompanysStaff(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.addUser("theirs@other.com", "correct horse battery", UserRecord{
|
||||
ID: acmeOtherID, ClientID: "client-other", ClientName: "Other Ltd",
|
||||
FullName: "Theo", Role: "staff", Active: true,
|
||||
})
|
||||
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/team/"+acmeOtherID+"/password", mgr.Token, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-tenant reset: want 404, got %d", rec.Code)
|
||||
}
|
||||
// And nothing happened to them.
|
||||
login(t, s, "theirs@other.com", "correct horse battery")
|
||||
}
|
||||
|
||||
func TestStaffCannotResetAnyonesPassword(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedMember(fs, acmeStaffID, "staff@acme.com", "Sam", "staff")
|
||||
staff := login(t, s, "staff@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/team/"+acmeStaffID+"/password", staff.Token, nil)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("want 403, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
349
server/internal/api/team_test.go
Normal file
349
server/internal/api/team_test.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Registration is by invitation, and almost everything worth testing here is a
|
||||
// property of that choice: what the code decides versus what the request
|
||||
// decides, and who is allowed to mint one.
|
||||
|
||||
// Real user ids are uuids and the id-addressed routes check the shape before
|
||||
// spending a database round trip. A fixture using "u5" would 404 on the guard
|
||||
// rather than on the rule under test - which is a test that passes for the
|
||||
// wrong reason, and would keep passing if tenant scoping were removed.
|
||||
const (
|
||||
acmeStaffID = "11111111-1111-4111-8111-111111111111"
|
||||
acmeOwnerID = "22222222-2222-4222-8222-222222222222"
|
||||
acmeOtherID = "33333333-3333-4333-8333-333333333333"
|
||||
)
|
||||
|
||||
func seedMember(fs *fakeStore, id, email, name, role string) {
|
||||
fs.addUser(email, "correct horse battery", UserRecord{
|
||||
ID: id, ClientID: "client-acme", ClientName: "Acme Retail",
|
||||
FullName: name, Role: role, Active: true,
|
||||
})
|
||||
}
|
||||
|
||||
func invite(t *testing.T, s *Server, token string, body map[string]any) Invitation {
|
||||
t.Helper()
|
||||
rec := do(t, s, "POST", "/api/team/invitations", token, body)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("invite: got %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var inv Invitation
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &inv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return inv
|
||||
}
|
||||
|
||||
func TestAnInvitationBecomesAnAccountAndASession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
inv := invite(t, s, sess.Token, map[string]any{
|
||||
"email": "Nikhil@Acme.com", "full_name": "Nikhil", "role": "staff"})
|
||||
if inv.Code == "" {
|
||||
t.Fatal("the response that mints a code must carry it - it is not recoverable later")
|
||||
}
|
||||
// Normalised on the way in, so the address somebody types at sign-in is the
|
||||
// one that was invited whatever case they used.
|
||||
if inv.Email != "nikhil@acme.com" {
|
||||
t.Errorf("email should be normalised, got %q", inv.Email)
|
||||
}
|
||||
|
||||
rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password", "device": "Pixel 8"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("register: got %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out Session
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A session, not just a 201. Sending somebody who has just chosen a
|
||||
// password to a sign-in form to type it again is the sort of thing that
|
||||
// gets blamed on the password.
|
||||
if out.Token == "" || out.RefreshToken == "" {
|
||||
t.Fatal("registration should sign the new member in")
|
||||
}
|
||||
if out.User.Email != "nikhil@acme.com" || out.User.Role != "staff" {
|
||||
t.Errorf("wrong account: %+v", out.User)
|
||||
}
|
||||
if out.User.ClientID != "client-acme" {
|
||||
t.Errorf("joined the wrong company: %q", out.User.ClientID)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "$2a$") {
|
||||
t.Error("password hash leaked into the registration response")
|
||||
}
|
||||
|
||||
// And the account works.
|
||||
again := login(t, s, "nikhil@acme.com", "a-good-long-password")
|
||||
if again.User.ID != out.User.ID {
|
||||
t.Error("registered account cannot sign in as itself")
|
||||
}
|
||||
}
|
||||
|
||||
// The single most important test in this file. A code is forwarded, pasted into
|
||||
// a chat, screenshotted; if the body could name the address or the role, one
|
||||
// staff invitation would be an owner account for anybody who saw it.
|
||||
func TestTheCodeDecidesTheAddressAndTheRoleNotTheRequest(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
inv := invite(t, s, sess.Token, map[string]any{
|
||||
"email": "nikhil@acme.com", "role": "staff"})
|
||||
|
||||
// Unknown fields are refused outright, which is the strongest form of this:
|
||||
// a client cannot even ask.
|
||||
rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password",
|
||||
"email": "attacker@example.com", "role": "owner"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("a body naming an address or a role must be refused, got %d: %s",
|
||||
rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// And redeemed properly, the account is still staff at the invited address.
|
||||
rec = do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password"})
|
||||
var out Session
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.User.Email != "nikhil@acme.com" || out.User.Role != "staff" {
|
||||
t.Fatalf("the invitation did not decide the account: %+v", out.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInvitationIsSingleUse(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
inv := invite(t, s, sess.Token, map[string]any{"email": "one@acme.com"})
|
||||
|
||||
first := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password"})
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first redemption: %d %s", first.Code, first.Body.String())
|
||||
}
|
||||
second := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "another-long-password"})
|
||||
if second.Code == http.StatusCreated {
|
||||
t.Fatal("a spent invitation created a second account")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARevokedInvitationCannotBeRedeemed(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
inv := invite(t, s, sess.Token, map[string]any{"email": "gone@acme.com"})
|
||||
|
||||
if rec := do(t, s, "DELETE", "/api/team/invitations/"+inv.ID, sess.Token, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password"})
|
||||
if rec.Code == http.StatusCreated {
|
||||
t.Fatal("a withdrawn invitation still worked")
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown, expired, spent and revoked are one answer. The difference only ever
|
||||
// helps somebody guessing, and the holder's next step is identical in all four.
|
||||
func TestAnInvalidCodeSaysNothingAboutWhy(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
inv := invite(t, s, sess.Token, map[string]any{"email": "used@acme.com"})
|
||||
_ = do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password"})
|
||||
|
||||
spent := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password"})
|
||||
invented := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": "AAAAAA-BBBBBB-CCCCCC-DDDDDD", "password": "a-good-long-password"})
|
||||
|
||||
if spent.Code != invented.Code || spent.Body.String() != invented.Body.String() {
|
||||
t.Fatalf("a spent code is distinguishable from an invented one:\n%d %s\n%d %s",
|
||||
spent.Code, spent.Body.String(), invented.Code, invented.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffCannotInviteAndAManagerCannotMintAnOwner(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedMember(fs, acmeStaffID, "sam@acme.com", "Sam", "staff")
|
||||
seedUser(fs)
|
||||
|
||||
staff := login(t, s, "sam@acme.com", "correct horse battery")
|
||||
if rec := do(t, s, "POST", "/api/team/invitations", staff.Token,
|
||||
map[string]any{"email": "x@acme.com"}); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("staff should not be able to invite, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// A manager promoting somebody past themselves is an escalation, and it is
|
||||
// the shape of this endpoint that would matter if a manager account were
|
||||
// ever taken over.
|
||||
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
if rec := do(t, s, "POST", "/api/team/invitations", mgr.Token,
|
||||
map[string]any{"email": "boss@acme.com", "role": "owner"}); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("a manager minted an owner invitation, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 'admin' is a platform administrator, which is defined by having no company at
|
||||
// all. An invitation always carries one, so the role could never work - what it
|
||||
// could do is create the tenant-scoped row with role='admin' that adminOnly
|
||||
// exists to reject.
|
||||
func TestAnInvitationCannotMintAPlatformAdmin(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/team/invitations", sess.Token,
|
||||
map[string]any{"email": "root@acme.com", "role": "admin"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("admin should not be an invitable role, got %d: %s",
|
||||
rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInvitationIsScopedToTheInvitersCompany(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.addUser("other@beta.com", "correct horse battery", UserRecord{
|
||||
ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd",
|
||||
FullName: "Bo", Role: "manager", Active: true,
|
||||
})
|
||||
acme := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
beta := login(t, s, "other@beta.com", "correct horse battery")
|
||||
|
||||
inv := invite(t, s, acme.Token, map[string]any{"email": "new@acme.com"})
|
||||
|
||||
// Beta cannot see it...
|
||||
rec := do(t, s, "GET", "/api/team/invitations", beta.Token, nil)
|
||||
if strings.Contains(rec.Body.String(), "new@acme.com") {
|
||||
t.Fatalf("another tenant can see Acme's invitations: %s", rec.Body.String())
|
||||
}
|
||||
// ...nor withdraw it.
|
||||
if rec := do(t, s, "DELETE", "/api/team/invitations/"+inv.ID, beta.Token, nil); rec.Code == http.StatusNoContent {
|
||||
t.Fatal("another tenant withdrew Acme's invitation")
|
||||
}
|
||||
}
|
||||
|
||||
// The preview is unauthenticated by necessity - the holder has no account yet -
|
||||
// so what it discloses is the whole question.
|
||||
func TestThePreviewShowsWhatToJoinAndNothingElse(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
inv := invite(t, s, sess.Token, map[string]any{
|
||||
"email": "nikhil@acme.com", "full_name": "Nikhil", "role": "manager"})
|
||||
|
||||
rec := do(t, s, "GET", "/api/auth/invitation?code="+inv.Code, "", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("preview: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var prev InvitationPreview
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &prev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prev.Role != "manager" || prev.Email != "nikhil@acme.com" {
|
||||
t.Errorf("preview should say what is being joined: %+v", prev)
|
||||
}
|
||||
// It must not become a way to read a company's staff list or anything else
|
||||
// about it beyond the one line the code already asserts.
|
||||
if strings.Contains(rec.Body.String(), "manager@acme.com") {
|
||||
t.Error("the preview disclosed the inviter's address")
|
||||
}
|
||||
|
||||
if rec := do(t, s, "GET", "/api/auth/invitation?code=NOPE", "", nil); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("an invented code should 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationEnforcesThePasswordFloor(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
inv := invite(t, s, sess.Token, map[string]any{"email": "short@acme.com"})
|
||||
|
||||
rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "short"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("a short password was accepted, got %d", rec.Code)
|
||||
}
|
||||
// And the invitation is NOT spent by a rejected attempt - otherwise one
|
||||
// mistyped password would cost the person their invitation.
|
||||
ok := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password"})
|
||||
if ok.Code != http.StatusCreated {
|
||||
t.Fatalf("a failed attempt burned the invitation: %d %s", ok.Code, ok.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- team --
|
||||
|
||||
func TestDeactivatingSomebodySignsThemOutNow(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedMember(fs, acmeStaffID, "leaver@acme.com", "Lee", "staff")
|
||||
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
leaver := login(t, s, "leaver@acme.com", "correct horse battery")
|
||||
|
||||
if rec := do(t, s, "GET", "/api/auth/me", leaver.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("the leaver should be signed in to begin with, got %d", rec.Code)
|
||||
}
|
||||
|
||||
rec := do(t, s, "PATCH", "/api/team/"+acmeStaffID, mgr.Token, map[string]any{"active": false})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("deactivate: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// The whole point. An access token lives twelve hours, so without revoking
|
||||
// the session, "remove their access" would remove it sometime tomorrow -
|
||||
// which is not what anybody pressing that button believes they have done.
|
||||
if rec := do(t, s, "GET", "/api/auth/me", leaver.Token, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("a deactivated account is still signed in, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLastOwnerCannotRemoveThemselves(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedMember(fs, acmeOwnerID, "boss@acme.com", "Bea", "owner")
|
||||
sess := login(t, s, "boss@acme.com", "correct horse battery")
|
||||
|
||||
for _, body := range []map[string]any{{"active": false}, {"role": "staff"}} {
|
||||
rec := do(t, s, "PATCH", "/api/team/"+acmeOwnerID, sess.Token, body)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("the only owner removed themselves with %v: %d %s",
|
||||
body, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamIsScopedToTheCallersCompany(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.addUser("other@beta.com", "correct horse battery", UserRecord{
|
||||
ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd",
|
||||
FullName: "Bo", Role: "manager", Active: true,
|
||||
})
|
||||
seedMember(fs, acmeOtherID, "asha@acme.com", "Asha", "manager")
|
||||
beta := login(t, s, "other@beta.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/team", beta.Token, nil)
|
||||
if strings.Contains(rec.Body.String(), "manager@acme.com") {
|
||||
t.Fatalf("another tenant's staff are visible: %s", rec.Body.String())
|
||||
}
|
||||
// And a uuid guessed from elsewhere changes nothing.
|
||||
// A real, well-formed id belonging to the OTHER tenant. The 404 must come
|
||||
// from the client scope in the UPDATE, not from the shape check above it.
|
||||
if rec := do(t, s, "PATCH", "/api/team/"+acmeOtherID, beta.Token,
|
||||
map[string]any{"role": "staff"}); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("cross-tenant team edit was not refused, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,11 @@ type SalesReport struct {
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
// Ref is the customer number - "V-42" - and is accepted anywhere this
|
||||
// customer's id is. It ships alongside the uuid rather than replacing it
|
||||
// because a client that stored a uuid must keep working; see refs.go.
|
||||
Ref string `json:"ref,omitempty"`
|
||||
Label string `json:"label"`
|
||||
FullName string `json:"full_name"`
|
||||
Phone string `json:"phone"`
|
||||
@@ -234,13 +238,26 @@ type AgentPrincipal struct {
|
||||
type Arrival struct {
|
||||
VisitID string `json:"visit_id"`
|
||||
// Seq is this visit's position in the feed - assigned by the server when it
|
||||
// learned of the visit, not by the camera. Exposed because a client that
|
||||
// wants to know whether it has fallen behind can compare two of them; the
|
||||
// cursor remains the supported way to page.
|
||||
Seq int64 `json:"seq"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
SiteID string `json:"site_id"`
|
||||
Site string `json:"site"`
|
||||
// learned of the visit, not by the camera. It drives the cursor and the
|
||||
// ordering, and it is `json:"-"` on purpose.
|
||||
//
|
||||
// `visits.seq` is a plain bigserial, so it counts every visit on the
|
||||
// PLATFORM, not this tenant's. Sending it put the total footfall of every
|
||||
// customer we have on every row of every feed - the same German-tank
|
||||
// estimate that decided `visitors.number` had to be per client, and a
|
||||
// number no tenant should be able to read off another. It used to ship as
|
||||
// a convenience for "have I fallen behind"; nothing ever read it, and the
|
||||
// cursor - opaque and version-prefixed for exactly this reason - already
|
||||
// answers that.
|
||||
Seq int64 `json:"-"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
SiteID string `json:"site_id"`
|
||||
Site string `json:"site"`
|
||||
// SiteSlug is the shop's reference - "chennai" - and is what `?site=`
|
||||
// takes. Without it a client could read the shop's NAME off an arrival and
|
||||
// still had no way to filter by that shop except the uuid, which is the
|
||||
// gap the whole reference scheme exists to close.
|
||||
SiteSlug string `json:"site_slug,omitempty"`
|
||||
CameraID string `json:"camera_id"`
|
||||
IsNew bool `json:"is_new_visitor"`
|
||||
Similarity float64 `json:"similarity,omitempty"`
|
||||
@@ -252,6 +269,9 @@ type Arrival struct {
|
||||
// appear in the feed - a shop watching arrivals would otherwise see fewer
|
||||
// people than walked in.
|
||||
VisitorID string `json:"visitor_id,omitempty"`
|
||||
// VisitorRef is the same person as "V-42": what staff read on screen and
|
||||
// type into a search box. Empty exactly when VisitorID is.
|
||||
VisitorRef string `json:"visitor_ref,omitempty"`
|
||||
// Label is the system's own name ("Visitor 12"); Name is what a human
|
||||
// typed. Both are sent so the client does not have to guess which is
|
||||
// present, and so a screen can show the real name and still let staff
|
||||
@@ -283,6 +303,16 @@ type Image struct {
|
||||
Available bool `json:"available"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
// Auth says the URL is one of ours and needs this session's bearer token,
|
||||
// rather than a presigned object-store link that carries its own signature.
|
||||
//
|
||||
// It exists because the two are genuinely different to fetch and a client
|
||||
// cannot tell them apart by looking. A browser <img> can load the signed
|
||||
// one and CANNOT load this one, so the web app fetches it and hands over an
|
||||
// object URL; a mobile image view can attach the header and load it
|
||||
// directly. Guessing from whether the URL is absolute would work today and
|
||||
// break the first time object storage lives on the same host.
|
||||
Auth bool `json:"auth,omitempty"`
|
||||
// Reason is user-facing prose, present only when Available is false.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Key is the object-store key, carried from the store to the handler that
|
||||
@@ -440,6 +470,11 @@ type CameraInput struct {
|
||||
// thing stopping a tenant response carrying camera passwords would be
|
||||
// remembering to blank a field, on every path, forever.
|
||||
type AgentCamera struct {
|
||||
// ID is head office's uuid. Carried alongside CameraID because the two
|
||||
// name the same camera to different halves of the system: head office
|
||||
// addresses it by uuid, the engine on the shop PC only knows the name in
|
||||
// CameraID, and the live relay has to translate between them.
|
||||
ID string `json:"id"`
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
Host string `json:"host"`
|
||||
@@ -583,3 +618,132 @@ type CheckStep struct {
|
||||
Detail string `json:"detail"`
|
||||
Advice string `json:"advice,omitempty"`
|
||||
}
|
||||
|
||||
// ==================================================== team and invitations ==
|
||||
|
||||
// NewInvitation is an invitation about to be written. Only the hash crosses
|
||||
// this boundary; the plaintext code exists in the handler and in the one
|
||||
// response that returns it, and nowhere else.
|
||||
type NewInvitation struct {
|
||||
ClientID string
|
||||
Email string
|
||||
FullName string
|
||||
Role string
|
||||
CodeHash []byte
|
||||
InvitedBy string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Invitation is a pending invitation as a manager sees it. It carries no code:
|
||||
// the plaintext is returned exactly once, by the request that created it, and
|
||||
// is not recoverable afterwards. A code a support engineer can look up later is
|
||||
// a code anyone with support access can redeem.
|
||||
type Invitation struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Role string `json:"role"`
|
||||
InvitedBy string `json:"invited_by,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// Code is present ONLY on the response that mints it.
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// InvitationPreview is what an unauthenticated client may learn from a code it
|
||||
// already holds: which company, for which address, in what role.
|
||||
//
|
||||
// Enough to render "Join TeNext Retail as a manager" before asking somebody to
|
||||
// choose a password, and no more. Unknown, expired, spent and revoked codes are
|
||||
// all one answer, for the reason enrolment already records: the difference only
|
||||
// helps somebody guessing, and the holder's next step is identical in all four
|
||||
// cases.
|
||||
type InvitationPreview struct {
|
||||
Client string `json:"client_name"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// Registration is a redeemed invitation turning into an account. The email and
|
||||
// role come from the INVITATION, never from the request body: a code forwarded
|
||||
// to somebody else must not become an account for them, and a staff invitation
|
||||
// must not be redeemed into an owner.
|
||||
type Registration struct {
|
||||
Code string
|
||||
FullName string
|
||||
Password string
|
||||
Device string
|
||||
}
|
||||
|
||||
// TeamMember is one person in a company, as the team screen lists them.
|
||||
type TeamMember struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
Active bool `json:"active"`
|
||||
LastLoginAt string `json:"last_login_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// TeamUpdate changes one member. Both fields are optional; a nil means "leave
|
||||
// this alone", which is what lets one endpoint serve "make them a manager" and
|
||||
// "they have left" without either silently doing the other.
|
||||
type TeamUpdate struct {
|
||||
Role *string `json:"role,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
}
|
||||
|
||||
// NewMemberInput is a staff account created directly by a manager, with a
|
||||
// password the manager hands over.
|
||||
//
|
||||
// The other path - an invitation the salesperson redeems on their own phone -
|
||||
// is better when it fits: the manager never touches the password. It does not
|
||||
// fit a salesperson being set up before their first shift, without a phone in
|
||||
// hand, by somebody who wants to write a login on a card and be done. This is
|
||||
// that path, and it mirrors how the platform admin creates a merchant owner:
|
||||
// same generated password, same shown-once rule.
|
||||
type NewMemberInput struct {
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
// Password is optional. Empty means "generate one", which is the better
|
||||
// default for the same reason it is on the admin side.
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// NewMemberResult is the member plus the one moment their password is readable.
|
||||
type NewMemberResult struct {
|
||||
TeamMember
|
||||
// Password is shown once. It is bcrypt-hashed on the way in and is not
|
||||
// recoverable afterwards.
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// PasswordReset is both the optional request ("use this one") and the response
|
||||
// ("here is the one that was set") for a manager resetting a member's password.
|
||||
type PasswordReset struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// ==================================================== devices and sessions ==
|
||||
|
||||
// DeviceSession is one signed-in device, as its owner sees it.
|
||||
//
|
||||
// This list is the point of opaque tokens rather than JWTs. The whole argument
|
||||
// for a session table was that "log that device out, now" has to actually work
|
||||
// on a product that puts customer data on shop-floor PCs and staff phones that
|
||||
// get lost, resold and shared - and until this existed there was no way to ask
|
||||
// what was signed in, let alone stop it.
|
||||
type DeviceSession struct {
|
||||
ID string `json:"id"`
|
||||
Device string `json:"device"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
// Current marks the session making this request, so a client can label it
|
||||
// and can warn before somebody signs themselves out of the device in their
|
||||
// hand.
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
@@ -71,6 +71,21 @@ func UseTestCost() func() {
|
||||
return func() { bcryptCost = previous; DummyHash = previousDummy }
|
||||
}
|
||||
|
||||
// RandomPassword mints a credential for somebody else - a merchant owner
|
||||
// created by the platform admin, a salesperson created by their manager, a
|
||||
// reset. 80 bits as 16 lowercase base32 characters: long enough that guessing
|
||||
// it is not a plan, and a shape a person can read down a phone line without
|
||||
// spelling out case. One generator rather than one per caller, so nobody
|
||||
// later writes a shorter one for the "less important" account.
|
||||
func RandomPassword() (string, error) {
|
||||
b := make([]byte, 10)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(base32.StdEncoding.
|
||||
WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
||||
}
|
||||
|
||||
func HashPassword(plain string) (string, error) {
|
||||
if err := CheckPasswordPolicy(plain); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -2,10 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
@@ -28,7 +25,7 @@ func (s *Store) CreateClientWithOwner(ctx context.Context, in api.NewClientInput
|
||||
if password == "" {
|
||||
// Generated rather than defaulted. An operator inventing a password for
|
||||
// somebody else invents a weak one and then sends it over chat.
|
||||
p, err := randomPassword()
|
||||
p, err := auth.RandomPassword()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -107,11 +104,3 @@ func (s *Store) ListClients(ctx context.Context) ([]api.ClientRow, error) {
|
||||
// base32 without padding, matching the rest of this system's generated
|
||||
// secrets: it gets read down a phone line and pasted into a form, and base64's
|
||||
// + / = survive neither.
|
||||
func randomPassword() (string, error) {
|
||||
b := make([]byte, 10) // 80 bits -> 16 characters
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(base32.StdEncoding.
|
||||
WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ import (
|
||||
// mean the first poll of a feed and every poll after it returned different
|
||||
// shapes, which is the kind of bug that only shows up under load.
|
||||
const arrivalColumns = `
|
||||
vi.id::text, vi.seq, vi.occurred_at, vi.site_id::text, si.name, vi.camera_id,
|
||||
vi.id::text, vi.seq, vi.occurred_at, vi.site_id::text, si.name, si.slug, vi.camera_id,
|
||||
vi.is_new_visitor, vi.similarity, vi.quality, vi.attributes, vi.image_key,
|
||||
COALESCE(vi.visitor_id::text, ''),
|
||||
COALESCE(vs.number, 0),
|
||||
COALESCE(vs.label, ''),
|
||||
COALESCE(p.full_name, '')`
|
||||
|
||||
@@ -111,11 +112,13 @@ func (s *Store) Arrivals(ctx context.Context, q api.ArrivalQuery) ([]api.Arrival
|
||||
var at time.Time
|
||||
var sim, qual *float64
|
||||
var imageKey string
|
||||
if err := rows.Scan(&a.VisitID, &a.Seq, &at, &a.SiteID, &a.Site, &a.CameraID,
|
||||
var number int64
|
||||
if err := rows.Scan(&a.VisitID, &a.Seq, &at, &a.SiteID, &a.Site, &a.SiteSlug, &a.CameraID,
|
||||
&a.IsNew, &sim, &qual, &a.Attributes, &imageKey,
|
||||
&a.VisitorID, &a.Label, &a.Name); err != nil {
|
||||
&a.VisitorID, &number, &a.Label, &a.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.VisitorRef = api.VisitorRef(number)
|
||||
a.OccurredAt = at.UTC().Format(time.RFC3339Nano)
|
||||
if sim != nil {
|
||||
a.Similarity = *sim
|
||||
|
||||
@@ -39,6 +39,38 @@ func liveStore(t *testing.T) *Store {
|
||||
return st
|
||||
}
|
||||
|
||||
// dropTenant removes a seeded tenant when the test that made it finishes.
|
||||
//
|
||||
// Without this these tests are a slow leak. Every one of them seeds its own
|
||||
// tenant - deliberately, so they can run in any order and so the isolation
|
||||
// assertions have a real neighbour - and none of them ever removed it. A dev
|
||||
// database reached 242 abandoned tenants against the single real one, which is
|
||||
// not merely untidy: the platform admin's Companies screen lists every client,
|
||||
// so the one real company was buried under pages of `walk1788761685056287000`.
|
||||
//
|
||||
// Registered against the CLIENT rather than each table because every foreign
|
||||
// key onto clients is ON DELETE CASCADE, so one delete takes the sites,
|
||||
// visitors, visits, face images, embeddings, cameras and agents with it. A
|
||||
// per-table list would rot the first time a migration adds a table, and it
|
||||
// would rot silently - which is the shape of the bug it is cleaning up after.
|
||||
//
|
||||
// t.Cleanup runs LIFO and liveStore registers st.Close before any seeding, so
|
||||
// the delete still has a live pool when it runs.
|
||||
func dropTenant(t *testing.T, st *Store, clientID string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := st.pool.Exec(ctx,
|
||||
`DELETE FROM clients WHERE id = $1::uuid`, clientID); err != nil {
|
||||
// Reported rather than ignored: a tenant left behind is the very
|
||||
// thing this exists to prevent, and swallowing the error would let
|
||||
// the leak return with nothing to show for it.
|
||||
t.Errorf("cleanup tenant %s: %v", clientID, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// seedTenant builds a client, a site and n visits, and returns the client id.
|
||||
// Every test gets its own tenant so they can run in any order without a
|
||||
// truncate between them - and so the isolation assertions below have a real
|
||||
@@ -52,6 +84,7 @@ func seedTenant(t *testing.T, st *Store, name string, n int, withImages bool) (c
|
||||
if err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
dropTenant(t, st, clientID)
|
||||
err = st.pool.QueryRow(ctx, `
|
||||
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
|
||||
RETURNING id::text`, clientID, name+" Main", name+"-main").Scan(&siteID)
|
||||
@@ -63,9 +96,9 @@ func seedTenant(t *testing.T, st *Store, name string, n int, withImages bool) (c
|
||||
for i := 0; i < n; i++ {
|
||||
var visitorID string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO visitors (client_id, label, first_seen_at)
|
||||
VALUES ($1::uuid, $2, $3) RETURNING id::text`,
|
||||
clientID, fmt.Sprintf("Visitor %d", i), start).Scan(&visitorID); err != nil {
|
||||
INSERT INTO visitors (client_id, number, label, first_seen_at)
|
||||
VALUES ($1::uuid, $2, $3, $4) RETURNING id::text`,
|
||||
clientID, i+1, fmt.Sprintf("Visitor %d", i+1), start).Scan(&visitorID); err != nil {
|
||||
t.Fatalf("seed visitor: %v", err)
|
||||
}
|
||||
key := ""
|
||||
|
||||
@@ -199,7 +199,7 @@ func (s *Store) DeleteCamera(ctx context.Context, clientID, id string) (api.Came
|
||||
// yet" by absence, and would re-adopt what was just deleted.
|
||||
func (s *Store) AgentCameras(ctx context.Context, siteID string) ([]api.AgentCamera, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT camera_id, label, host, port, path, username, password_enc,
|
||||
SELECT id::text, camera_id, label, host, port, path, username, password_enc,
|
||||
max_width, tuning, enabled, revision, (deleted_at IS NOT NULL)
|
||||
FROM site_cameras
|
||||
WHERE site_id = $1::uuid
|
||||
@@ -213,7 +213,7 @@ func (s *Store) AgentCameras(ctx context.Context, siteID string) ([]api.AgentCam
|
||||
for rows.Next() {
|
||||
var c api.AgentCamera
|
||||
var sealed []byte
|
||||
if err := rows.Scan(&c.CameraID, &c.Label, &c.Host, &c.Port, &c.Path,
|
||||
if err := rows.Scan(&c.ID, &c.CameraID, &c.Label, &c.Host, &c.Port, &c.Path,
|
||||
&c.Username, &sealed, &c.MaxWidth, &c.Tuning, &c.Enabled,
|
||||
&c.Revision, &c.Deleted); err != nil {
|
||||
return nil, err
|
||||
|
||||
187
server/internal/store/api_faces.go
Normal file
187
server/internal/store/api_faces.go
Normal file
@@ -0,0 +1,187 @@
|
||||
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
|
||||
}
|
||||
262
server/internal/store/api_faces_live_test.go
Normal file
262
server/internal/store/api_faces_live_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/contract"
|
||||
"github.com/loyaly/behavision-server/internal/ingest"
|
||||
)
|
||||
|
||||
// Face images held by this server, against a real database.
|
||||
//
|
||||
// The prune is the whole reason this is allowed to live in Postgres at all -
|
||||
// migration 011 argues it explicitly against 009's "face images grow with every
|
||||
// visitor who ever walks in" - so it is the one behaviour that must be proved
|
||||
// against the real thing rather than a fake that would simply agree with me.
|
||||
|
||||
func seedAgentSite(t *testing.T, st *Store, name string) ingest.Site {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var site ingest.Site
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO clients (name, slug) VALUES ($1, $1) RETURNING id::text`,
|
||||
name).Scan(&site.ClientID); err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
dropTenant(t, st, site.ClientID)
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
|
||||
RETURNING id::text`, site.ClientID, name, name).Scan(&site.SiteID); err != nil {
|
||||
t.Fatalf("seed site: %v", err)
|
||||
}
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO agents (client_id, site_id, mqtt_username)
|
||||
VALUES ($1::uuid, $2::uuid, $3)
|
||||
RETURNING id::text`, site.ClientID, site.SiteID, name).Scan(&site.AgentID); err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
site.Slug = name
|
||||
return site
|
||||
}
|
||||
|
||||
func embedding(seed float32) []float32 {
|
||||
v := make([]float32, contract.EmbeddingDim)
|
||||
for i := range v {
|
||||
v[i] = seed
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// The bound: one person seen many times leaves ONE stored image, not one per
|
||||
// visit. Without this the table grows with footfall, which is precisely the
|
||||
// property that keeps face images out of the database everywhere else.
|
||||
func TestLiveOnlyOneFaceSurvivesPerVisitor(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
site := seedAgentSite(t, st, "faces-"+stamp())
|
||||
|
||||
var keys []string
|
||||
for i := 0; i < 5; i++ {
|
||||
key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID,
|
||||
[]byte(fmt.Sprintf("jpeg-%d", i)))
|
||||
if err != nil {
|
||||
t.Fatalf("store face %d: %v", i, err)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
|
||||
// The SAME person every time: one embedding, so the matcher resolves
|
||||
// them to one visitor.
|
||||
ok, err := st.RecordVisit(ctx, site, &contract.Visit{
|
||||
EventID: fmt.Sprintf("%s-%d", site.Slug, i),
|
||||
OccurredAt: time.Now().UTC().Add(time.Duration(i) * time.Second),
|
||||
CameraID: "door",
|
||||
IsNew: i == 0,
|
||||
Quality: 0.8,
|
||||
Similarity: 0.9,
|
||||
Embedding: embedding(0.05),
|
||||
ImageKey: key,
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("visit %d: ok=%v err=%v", i, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
var stored int
|
||||
if err := st.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM visit_faces WHERE client_id = $1::uuid`,
|
||||
site.ClientID).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != 1 {
|
||||
t.Fatalf("five visits by one person left %d stored faces - the table "+
|
||||
"grows with footfall, which is exactly what migration 011 promises "+
|
||||
"it does not", stored)
|
||||
}
|
||||
|
||||
// And it is the NEWEST that survived: every surface shows a customer's
|
||||
// latest view, so keeping an older one would quietly show a stale face.
|
||||
var surviving string
|
||||
if err := st.pool.QueryRow(ctx,
|
||||
`SELECT 'db:' || id::text FROM visit_faces WHERE client_id = $1::uuid`,
|
||||
site.ClientID).Scan(&surviving); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if surviving != keys[len(keys)-1] {
|
||||
t.Errorf("kept %s, want the newest %s", surviving, keys[len(keys)-1])
|
||||
}
|
||||
|
||||
// The superseded keys are blanked, not left dangling. A visit advertising
|
||||
// an image that is not there renders as a broken picture on the one screen
|
||||
// meant to show it.
|
||||
var dangling int
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM visits v
|
||||
WHERE v.client_id = $1::uuid AND v.image_key LIKE 'db:%'
|
||||
AND NOT EXISTS (SELECT 1 FROM visit_faces f
|
||||
WHERE 'db:' || f.id::text = v.image_key)`,
|
||||
site.ClientID).Scan(&dangling); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dangling != 0 {
|
||||
t.Errorf("%d visits point at a face that is gone", dangling)
|
||||
}
|
||||
|
||||
// image_deleted_at is the record of an ERASURE and is what an auditor
|
||||
// reads. Ordinary housekeeping must not write into it.
|
||||
var marked int
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM visits
|
||||
WHERE client_id = $1::uuid AND image_deleted_at IS NOT NULL`,
|
||||
site.ClientID).Scan(&marked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if marked != 0 {
|
||||
t.Errorf("%d visits were marked as erased by a routine prune", marked)
|
||||
}
|
||||
}
|
||||
|
||||
// Two different people keep one face each. The prune must be scoped to the
|
||||
// person, not to the site - otherwise every new arrival would delete the
|
||||
// previous customer's photo.
|
||||
func TestLiveThePruneIsPerPersonNotPerSite(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
site := seedAgentSite(t, st, "faces2-"+stamp())
|
||||
|
||||
for i, seed := range []float32{0.05, -0.05} {
|
||||
key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID,
|
||||
[]byte(fmt.Sprintf("person-%d", i)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := st.RecordVisit(ctx, site, &contract.Visit{
|
||||
EventID: fmt.Sprintf("%s-p%d", site.Slug, i),
|
||||
OccurredAt: time.Now().UTC(),
|
||||
CameraID: "door",
|
||||
IsNew: true,
|
||||
Quality: 0.8,
|
||||
Embedding: embedding(seed),
|
||||
ImageKey: key,
|
||||
}); err != nil || !ok {
|
||||
t.Fatalf("visit: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
var stored int
|
||||
if err := st.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM visit_faces WHERE client_id = $1::uuid`,
|
||||
site.ClientID).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != 2 {
|
||||
t.Fatalf("two people should keep one face each, got %d", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// An agent uploads a face BEFORE the server has decided who it is, so a row is
|
||||
// briefly unreferenced by design - and permanently so if the visit that would
|
||||
// have claimed it never arrives. That is a stored photograph of a real person
|
||||
// that nothing points at, which erasure could never reach because it is found
|
||||
// through the visitor and this row has none.
|
||||
func TestLiveAnUnclaimedFaceIsSweptAway(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
site := seedAgentSite(t, st, "faces3-"+stamp())
|
||||
|
||||
key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID, []byte("orphan"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Age it past the sweep window rather than sleeping.
|
||||
if _, err := st.pool.Exec(ctx, `
|
||||
UPDATE visit_faces SET captured_at = now() - interval '3 days'
|
||||
WHERE 'db:' || id::text = $1`, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := st.SweepOrphanFaces(ctx, "1 day")
|
||||
if err != nil {
|
||||
t.Fatalf("sweep: %v", err)
|
||||
}
|
||||
if n < 1 {
|
||||
t.Fatal("the orphan was not swept")
|
||||
}
|
||||
if _, err := st.VisitFace(ctx, site.ClientID, key); err == nil {
|
||||
t.Fatal("the orphan is still readable")
|
||||
}
|
||||
}
|
||||
|
||||
// A face a visit DOES point at must survive the sweep, however old it is. A
|
||||
// regular customer's photo is exactly the row that gets old.
|
||||
func TestLiveTheSweepKeepsAClaimedFace(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
site := seedAgentSite(t, st, "faces4-"+stamp())
|
||||
|
||||
key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID, []byte("kept"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := st.RecordVisit(ctx, site, &contract.Visit{
|
||||
EventID: site.Slug + "-keep", OccurredAt: time.Now().UTC(),
|
||||
CameraID: "door", IsNew: true, Quality: 0.8,
|
||||
Embedding: embedding(0.07), ImageKey: key,
|
||||
}); err != nil || !ok {
|
||||
t.Fatalf("visit: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, err := st.pool.Exec(ctx, `
|
||||
UPDATE visit_faces SET captured_at = now() - interval '400 days'
|
||||
WHERE 'db:' || id::text = $1`, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := st.SweepOrphanFaces(ctx, "1 day"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.VisitFace(ctx, site.ClientID, key); err != nil {
|
||||
t.Fatalf("a claimed face was swept away: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An image key travels in API responses. A caller who kept one, or guessed one,
|
||||
// must get nothing rather than another company's customer.
|
||||
func TestLiveAFaceIsNotReadableByAnotherTenant(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
a := seedAgentSite(t, st, "facesa-"+stamp())
|
||||
b := seedAgentSite(t, st, "facesb-"+stamp())
|
||||
|
||||
key, err := st.PutVisitFace(ctx, a.ClientID, a.SiteID, []byte("private"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.VisitFace(ctx, b.ClientID, key); err == nil {
|
||||
t.Fatal("another tenant read a stored face")
|
||||
}
|
||||
if _, err := st.VisitFace(ctx, a.ClientID, key); err != nil {
|
||||
t.Fatalf("the owning tenant could not read its own face: %v", err)
|
||||
}
|
||||
}
|
||||
57
server/internal/store/api_immutable_live_test.go
Normal file
57
server/internal/store/api_immutable_live_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A reference clients are told to use must not be able to change underneath
|
||||
// them.
|
||||
//
|
||||
// 012 turned three descriptive columns into IDENTIFIERS other people store: in
|
||||
// agent.json on a shop counter, in a saved URL, in a scheduled report. All
|
||||
// three were already treated as stable and none of it was enforced - the
|
||||
// camera case was half-enforced in one handler and nowhere else, which is the
|
||||
// shape of a rule that holds until somebody adds a second write path.
|
||||
//
|
||||
// Only a real database can test this: the rule is a trigger, and an in-memory
|
||||
// fake would happily agree with any implementation.
|
||||
func TestLiveAReferenceCannotBeRenamed(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
site := seedAgentSite(t, st, "frozen-"+stamp())
|
||||
|
||||
if _, err := st.pool.Exec(ctx, `
|
||||
INSERT INTO site_cameras (client_id, site_id, camera_id, label, host)
|
||||
VALUES ($1::uuid, $2::uuid, 'Office1', 'Front door', '10.0.0.5')`,
|
||||
site.ClientID, site.SiteID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, c := range []struct{ what, sql string }{
|
||||
{"a shop's slug", `UPDATE sites SET slug = 'moved' WHERE id = $1::uuid`},
|
||||
{"a camera's id", `UPDATE site_cameras SET camera_id = 'Office2' WHERE site_id = $1::uuid`},
|
||||
} {
|
||||
_, err := st.pool.Exec(ctx, c.sql, site.SiteID)
|
||||
if err == nil {
|
||||
t.Fatalf("%s was renamed - it is a reference other systems store", c.what)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot be changed") {
|
||||
t.Fatalf("%s: unexpected error %v", c.what, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The DISPLAY name is not frozen and must not be. It is what a person
|
||||
// reads, nothing keys on it, and a system that cannot fix a typo in a
|
||||
// shop's name has confused the two.
|
||||
if _, err := st.pool.Exec(ctx,
|
||||
`UPDATE sites SET name = 'Renamed Shop' WHERE id = $1::uuid`, site.SiteID); err != nil {
|
||||
t.Fatalf("a shop's display name must stay editable: %v", err)
|
||||
}
|
||||
if _, err := st.pool.Exec(ctx,
|
||||
`UPDATE site_cameras SET label = 'Back door' WHERE site_id = $1::uuid`,
|
||||
site.SiteID); err != nil {
|
||||
t.Fatalf("a camera's label must stay editable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,27 @@ func likePattern(q string) string {
|
||||
return "%" + r.Replace(q) + "%"
|
||||
}
|
||||
|
||||
// searchNumber is the customer number behind a query, or 0 for a query that is
|
||||
// not one.
|
||||
//
|
||||
// The reference is what staff now READ on screen - "V-13" - so it is what they
|
||||
// paste into the search box, and matching only `label ILIKE '%V-13%'` finds
|
||||
// nothing at all, because the stored label says "Visitor 13". A search that
|
||||
// comes back empty for the identifier the product just showed you is worse
|
||||
// than no search at all.
|
||||
func searchNumber(query string) int64 {
|
||||
n, ok := api.ParseVisitorRef(query)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Store) SearchVisitors(ctx context.Context, clientID, query string, limit int) (
|
||||
[]api.Customer, error) {
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT v.id::text, v.label,
|
||||
SELECT v.id::text, v.number, v.label,
|
||||
COALESCE(p.full_name, ''), COALESCE(p.phone, ''), COALESCE(p.email, ''),
|
||||
v.visit_count, v.first_seen_at, v.last_seen_at,
|
||||
(p.id IS NOT NULL),
|
||||
@@ -39,13 +55,15 @@ func (s *Store) SearchVisitors(ctx context.Context, clientID, query string, limi
|
||||
LEFT JOIN visitor_profiles p
|
||||
ON p.visitor_id = v.id AND p.client_id = v.client_id
|
||||
WHERE v.client_id = $1 AND v.deleted_at IS NULL
|
||||
AND ($2 = '' OR v.label ILIKE $3 ESCAPE '\'
|
||||
AND ($2 = '' OR v.number = $5
|
||||
OR v.label ILIKE $3 ESCAPE '\'
|
||||
OR p.full_name ILIKE $3 ESCAPE '\'
|
||||
OR p.phone ILIKE $3 ESCAPE '\'
|
||||
OR p.email ILIKE $3 ESCAPE '\')
|
||||
ORDER BY v.last_seen_at DESC NULLS LAST, v.first_seen_at DESC
|
||||
LIMIT $4`,
|
||||
clientID, strings.TrimSpace(query), likePattern(strings.TrimSpace(query)), limit)
|
||||
clientID, strings.TrimSpace(query), likePattern(strings.TrimSpace(query)),
|
||||
limit, searchNumber(query))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -56,10 +74,12 @@ func (s *Store) SearchVisitors(ctx context.Context, clientID, query string, limi
|
||||
var c api.Customer
|
||||
var first time.Time
|
||||
var last *time.Time
|
||||
if err := rows.Scan(&c.ID, &c.Label, &c.FullName, &c.Phone, &c.Email,
|
||||
var number int64
|
||||
if err := rows.Scan(&c.ID, &number, &c.Label, &c.FullName, &c.Phone, &c.Email,
|
||||
&c.VisitCount, &first, &last, &c.HasProfile, &c.HasConsent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Ref = api.VisitorRef(number)
|
||||
c.FirstSeenAt = first.UTC().Format(time.RFC3339)
|
||||
if last != nil {
|
||||
c.LastSeenAt = last.UTC().Format(time.RFC3339)
|
||||
|
||||
81
server/internal/store/api_refs.go
Normal file
81
server/internal/store/api_refs.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Resolving a public reference to the uuid it names.
|
||||
//
|
||||
// Every id in this schema is a uuid and stays one. These three exist because a
|
||||
// uuid is not something a person can say, type or recognise, and three of the
|
||||
// four things anyone addresses by URL already HAD a human name that the API
|
||||
// simply refused to accept: a site has a slug, a camera has the id the engine
|
||||
// knows it by (and the one that lands in `visits.camera_id`), and a visitor now
|
||||
// has a per-tenant number. See api/refs.go for the formats.
|
||||
//
|
||||
// All three answer "" with a nil error when nothing matches. A found id is
|
||||
// never empty, so the two cases cannot be confused, and a mistyped reference is
|
||||
// a 404 rather than an error the handler has to classify.
|
||||
|
||||
// SiteIDBySlug resolves a site slug within one tenant.
|
||||
func (s *Store) SiteIDBySlug(ctx context.Context, clientID, slug string) (string, error) {
|
||||
var id string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id::text FROM sites
|
||||
WHERE client_id = $1::uuid AND slug = $2`,
|
||||
clientID, slug).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// CameraIDByRef resolves the engine's own camera id - "Office1" - to the row
|
||||
// uuid, within one tenant.
|
||||
//
|
||||
// A camera id is unique per SITE, not per tenant, so two shops may each have an
|
||||
// "Office1". Ambiguity is resolved as no match rather than by picking one:
|
||||
// silently acting on whichever row sorted first would edit or delete the wrong
|
||||
// shop's camera. A caller in that position has the uuid, or can scope by site.
|
||||
func (s *Store) CameraIDByRef(ctx context.Context, clientID, ref string) (string, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text FROM site_cameras
|
||||
WHERE client_id = $1::uuid AND camera_id = $2 AND deleted_at IS NULL
|
||||
LIMIT 2`, clientID, ref)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var found []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
found = append(found, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(found) != 1 {
|
||||
return "", nil
|
||||
}
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
// VisitorIDByNumber resolves the number behind "V-42" within one tenant.
|
||||
func (s *Store) VisitorIDByNumber(ctx context.Context, clientID string, number int64) (string, error) {
|
||||
var id string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id::text FROM visitors
|
||||
WHERE client_id = $1::uuid AND number = $2`,
|
||||
clientID, number).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
182
server/internal/store/api_refs_live_test.go
Normal file
182
server/internal/store/api_refs_live_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/contract"
|
||||
"github.com/loyaly/behavision-server/internal/ingest"
|
||||
)
|
||||
|
||||
// The name a shop assistant actually reads.
|
||||
//
|
||||
// Before 012 this was `'Visitor ' || left(id::text, 8)`, so the arrivals feed
|
||||
// said "Visitor 3446ec35" - a string nobody can say out loud, write on a card
|
||||
// or type into a search box. The number is what fixes that, and it has to be
|
||||
// right at the point it is WRITTEN: `label` is a stored column that staff can
|
||||
// overwrite and that SearchVisitors matches on, so formatting around it in the
|
||||
// front end would have left the stored data wrong on three surfaces.
|
||||
//
|
||||
// Only a real database proves this. The counter lives on `clients` and is taken
|
||||
// with UPDATE ... RETURNING inside the visit transaction - the same semantics
|
||||
// that silently broke the face prune in 011 by returning the value it had just
|
||||
// written. Here that is exactly what is wanted, and an in-memory fake would
|
||||
// agree with any implementation.
|
||||
// distinctFace returns a vector pointing along its own axis, so any two of them
|
||||
// are orthogonal - cosine 0, far below any match threshold.
|
||||
//
|
||||
// `embedding(seed)` fills every dimension with one value, so after L2
|
||||
// normalisation 0.31 and 0.62 are the SAME direction and the matcher correctly
|
||||
// calls them one person. That is right for the face tests it was written for
|
||||
// and useless here, where the whole point is several different people.
|
||||
func distinctFace(i int) []float32 {
|
||||
v := make([]float32, contract.EmbeddingDim)
|
||||
v[i%contract.EmbeddingDim] = 1
|
||||
return v
|
||||
}
|
||||
|
||||
func TestLiveVisitorNumbersStartAtOneForEveryTenant(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Two tenants, so a number that leaked across them would show up as a gap.
|
||||
for _, tenant := range []string{"num-a-" + stamp(), "num-b-" + stamp()} {
|
||||
site := seedAgentSite(t, st, tenant)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
// A different face each time, so each becomes its own visitor.
|
||||
ok, err := st.RecordVisit(ctx, site, &contract.Visit{
|
||||
EventID: fmt.Sprintf("%s-%d", tenant, i),
|
||||
OccurredAt: time.Now().UTC().Add(time.Duration(i) * time.Second),
|
||||
CameraID: "door",
|
||||
IsNew: true,
|
||||
Quality: 0.8,
|
||||
Embedding: distinctFace(i),
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("%s visit %d: ok=%v err=%v", tenant, i, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := st.pool.Query(ctx, `
|
||||
SELECT number, label FROM visitors
|
||||
WHERE client_id = $1::uuid ORDER BY number`, site.ClientID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for rows.Next() {
|
||||
var n int64
|
||||
var label string
|
||||
if err := rows.Scan(&n, &label); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, fmt.Sprintf("%d=%s", n, label))
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
want := []string{"1=Visitor 1", "2=Visitor 2", "3=Visitor 3"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s: got %v, want %v", tenant, got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("%s: got %v, want %v", tenant, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A number is a public reference, so it must resolve only within the tenant it
|
||||
// belongs to. Both tenants have a V-1; asking as one must never return the
|
||||
// other's customer.
|
||||
func TestLiveAVisitorNumberResolvesOnlyWithinItsOwnTenant(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mine := seedAgentSite(t, st, "ref-mine-"+stamp())
|
||||
theirs := seedAgentSite(t, st, "ref-theirs-"+stamp())
|
||||
|
||||
for i, s := range []ingest.Site{mine, theirs} {
|
||||
if ok, err := st.RecordVisit(ctx, s, &contract.Visit{
|
||||
EventID: s.Slug + "-1",
|
||||
OccurredAt: time.Now().UTC(),
|
||||
CameraID: "door",
|
||||
IsNew: true,
|
||||
Quality: 0.8,
|
||||
Embedding: distinctFace(i),
|
||||
}); err != nil || !ok {
|
||||
t.Fatalf("seed visit: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
mineID, err := st.VisitorIDByNumber(ctx, mine.ClientID, 1)
|
||||
if err != nil || mineID == "" {
|
||||
t.Fatalf("V-1 in my own tenant: %q %v", mineID, err)
|
||||
}
|
||||
theirsID, err := st.VisitorIDByNumber(ctx, theirs.ClientID, 1)
|
||||
if err != nil || theirsID == "" {
|
||||
t.Fatalf("V-1 in the other tenant: %q %v", theirsID, err)
|
||||
}
|
||||
if mineID == theirsID {
|
||||
t.Fatal("V-1 resolved to the same customer for two different tenants")
|
||||
}
|
||||
|
||||
// And a number nobody has is a miss, not an error - which is what lets the
|
||||
// handler answer 404 without classifying an error first.
|
||||
got, err := st.VisitorIDByNumber(ctx, mine.ClientID, 999999)
|
||||
if err != nil || got != "" {
|
||||
t.Fatalf("unknown number: got %q, err %v - want an empty miss", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A site slug and a camera id are the other two references, and both already
|
||||
// existed in the schema; only the API refused to accept them.
|
||||
func TestLiveSiteAndCameraResolveByTheirOwnNames(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
site := seedAgentSite(t, st, "names-"+stamp())
|
||||
|
||||
id, err := st.SiteIDBySlug(ctx, site.ClientID, site.Slug)
|
||||
if err != nil || id != site.SiteID {
|
||||
t.Fatalf("slug %q resolved to %q (want %q), err %v",
|
||||
site.Slug, id, site.SiteID, err)
|
||||
}
|
||||
if got, err := st.SiteIDBySlug(ctx, site.ClientID, "no-such-shop"); err != nil || got != "" {
|
||||
t.Fatalf("unknown slug: got %q, err %v", got, err)
|
||||
}
|
||||
|
||||
var camUUID string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO site_cameras (client_id, site_id, camera_id, label, host)
|
||||
VALUES ($1::uuid, $2::uuid, 'Office1', 'Front door', '10.0.0.5')
|
||||
RETURNING id::text`, site.ClientID, site.SiteID).Scan(&camUUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.CameraIDByRef(ctx, site.ClientID, "Office1")
|
||||
if err != nil || got != camUUID {
|
||||
t.Fatalf("camera by name: got %q (want %q), err %v", got, camUUID, err)
|
||||
}
|
||||
|
||||
// Two shops in one tenant may each have an "Office1". Acting on whichever
|
||||
// row sorted first would edit the wrong shop's camera, so ambiguity must
|
||||
// resolve to nothing rather than to a guess.
|
||||
var secondSite string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, 'Second', $2)
|
||||
RETURNING id::text`, site.ClientID, site.Slug+"-2").Scan(&secondSite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.pool.Exec(ctx, `
|
||||
INSERT INTO site_cameras (client_id, site_id, camera_id, label, host)
|
||||
VALUES ($1::uuid, $2::uuid, 'Office1', 'Other door', '10.0.0.6')`,
|
||||
site.ClientID, secondSite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := st.CameraIDByRef(ctx, site.ClientID, "Office1"); err != nil || got != "" {
|
||||
t.Fatalf("an ambiguous camera name resolved to %q - it must resolve to "+
|
||||
"nothing rather than pick one", got)
|
||||
}
|
||||
}
|
||||
132
server/internal/store/api_snapshots.go
Normal file
132
server/internal/store/api_snapshots.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrNoSnapshot means this camera has no stored picture. It is an ordinary
|
||||
// state - a camera added a minute ago has none - so callers report it as
|
||||
// absence rather than as a failure.
|
||||
var ErrNoSnapshot = errors.New("no snapshot for this camera")
|
||||
|
||||
// PutCameraSnapshot stores the latest frame from one of a site's cameras.
|
||||
//
|
||||
// The camera is resolved by (site_id, camera_id) IN THE INSERT, so an agent
|
||||
// physically cannot store a picture against another site's camera even if it
|
||||
// sends one - the same rule as every other agent-authenticated write here.
|
||||
// `camera_id` is what the ENGINE knows the camera by, because that is the only
|
||||
// name the shop PC has.
|
||||
func (s *Store) PutCameraSnapshot(ctx context.Context,
|
||||
clientID, siteID, cameraID string, jpeg []byte) error {
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
|
||||
var id string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id::text FROM site_cameras
|
||||
WHERE site_id = $1::uuid AND camera_id = $2 AND deleted_at IS NULL`,
|
||||
siteID, cameraID).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Head office has not been told about this camera yet, or it was
|
||||
// removed. Neither is an error the agent can act on: the next sync
|
||||
// adopts it and the snapshot after that lands.
|
||||
return ErrNoSnapshot
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO camera_snapshots (camera_id, client_id, site_id, image, bytes, captured_at)
|
||||
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, now())
|
||||
ON CONFLICT (camera_id) DO UPDATE
|
||||
SET image = EXCLUDED.image, bytes = EXCLUDED.bytes,
|
||||
captured_at = EXCLUDED.captured_at`,
|
||||
id, clientID, siteID, jpeg, len(jpeg)); err != nil {
|
||||
return fmt.Errorf("store snapshot: %w", err)
|
||||
}
|
||||
|
||||
// snapshot_at is what tells the camera list a picture exists at all, and it
|
||||
// is written in the SAME transaction as the bytes. Set apart, a camera
|
||||
// could advertise a picture that is not there - which renders as a broken
|
||||
// image on the one screen whose job is to show the camera.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE site_cameras SET snapshot_at = now() WHERE id = $1::uuid`,
|
||||
id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// CameraSnapshot returns a camera's stored picture, scoped to the tenant.
|
||||
func (s *Store) CameraSnapshot(ctx context.Context, clientID, cameraID string) (
|
||||
[]byte, time.Time, error) {
|
||||
|
||||
var img []byte
|
||||
var at time.Time
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT image, captured_at FROM camera_snapshots
|
||||
WHERE camera_id = $1::uuid AND client_id = $2::uuid`,
|
||||
cameraID, clientID).Scan(&img, &at)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, time.Time{}, ErrNoSnapshot
|
||||
}
|
||||
return img, at, err
|
||||
}
|
||||
|
||||
// CameraRef resolves one of a tenant's cameras to its site and the name the
|
||||
// engine knows it by.
|
||||
//
|
||||
// Its job is to prove ownership before a live stream starts. Everything after
|
||||
// that point is keyed on a camera id, and a hub relaying frames does not know
|
||||
// whose camera it is holding — so this is the only place that can decide.
|
||||
func (s *Store) CameraRef(ctx context.Context, clientID, cameraID string) (string, string, error) {
|
||||
return s.cameraRef(ctx, `client_id = $2::uuid`, cameraID, clientID)
|
||||
}
|
||||
|
||||
// CameraRefBySite is the same question asked by an agent, which is
|
||||
// authenticated for a site rather than a tenant.
|
||||
func (s *Store) CameraRefBySite(ctx context.Context, siteID, cameraID string) (string, string, error) {
|
||||
return s.cameraRef(ctx, `site_id = $2::uuid`, cameraID, siteID)
|
||||
}
|
||||
|
||||
func (s *Store) cameraRef(ctx context.Context, scope, cameraID, owner string) (string, string, error) {
|
||||
var siteID, engineID string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT site_id::text, camera_id FROM site_cameras
|
||||
WHERE id = $1::uuid AND `+scope+` AND deleted_at IS NULL`,
|
||||
cameraID, owner).Scan(&siteID, &engineID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", ErrNoSnapshot
|
||||
}
|
||||
return siteID, engineID, err
|
||||
}
|
||||
|
||||
// SiteCameraIDs lists a site's camera uuids, for the agent's live poll.
|
||||
func (s *Store) SiteCameraIDs(ctx context.Context, siteID string) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text FROM site_cameras
|
||||
WHERE site_id = $1::uuid AND deleted_at IS NULL AND enabled`, siteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
416
server/internal/store/api_team.go
Normal file
416
server/internal/store/api_team.go
Normal file
@@ -0,0 +1,416 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
)
|
||||
|
||||
// Adding people to a company, and taking them out again.
|
||||
//
|
||||
// Registration here is by invitation only. `handlers_team.go` carries the
|
||||
// product argument; what matters at this layer is that every statement is
|
||||
// scoped by the CALLER'S client id, taken from their session, so a manager
|
||||
// cannot invite somebody into, list, or remove a member of a company that is
|
||||
// not theirs by guessing a uuid.
|
||||
|
||||
// CreateInvitation writes a pending invitation for one company.
|
||||
//
|
||||
// The client id is not trusted from a caller anywhere above this, but it is
|
||||
// still joined against `clients` here rather than inserted blind: a foreign-key
|
||||
// violation surfaces as an opaque 500, and a row that names a company which has
|
||||
// since been deleted is worse than a clean refusal.
|
||||
func (s *Store) CreateInvitation(ctx context.Context, in api.NewInvitation) (api.Invitation, error) {
|
||||
var out api.Invitation
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO invitations (client_id, email, full_name, role, code_hash,
|
||||
invited_by, expires_at)
|
||||
SELECT c.id, $2, $3, $4, $5, $6::uuid, $7
|
||||
FROM clients c
|
||||
WHERE c.id = $1::uuid
|
||||
RETURNING id::text, email, full_name, role,
|
||||
to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
in.ClientID, in.Email, in.FullName, in.Role, in.CodeHash,
|
||||
nullUUID(in.InvitedBy), in.ExpiresAt,
|
||||
).Scan(&out.ID, &out.Email, &out.FullName, &out.Role,
|
||||
&out.ExpiresAt, &out.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return api.Invitation{}, errors.New("no such company")
|
||||
}
|
||||
if err != nil {
|
||||
return api.Invitation{}, fmt.Errorf("create invitation: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PendingInvitations lists the invitations that have been sent and not yet
|
||||
// taken up. Spent and revoked rows are history and are deliberately not here:
|
||||
// the question this list answers is "who is still waiting to join".
|
||||
func (s *Store) PendingInvitations(ctx context.Context, clientID string) ([]api.Invitation, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT i.id::text, i.email, i.full_name, i.role,
|
||||
COALESCE(u.full_name, u.email, ''),
|
||||
to_char(i.expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
to_char(i.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
FROM invitations i
|
||||
LEFT JOIN app_users u ON u.id = i.invited_by
|
||||
WHERE i.client_id = $1::uuid
|
||||
AND i.used_at IS NULL AND i.revoked_at IS NULL
|
||||
AND i.expires_at > now()
|
||||
ORDER BY i.created_at DESC`, clientID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list invitations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []api.Invitation
|
||||
for rows.Next() {
|
||||
var v api.Invitation
|
||||
if err := rows.Scan(&v.ID, &v.Email, &v.FullName, &v.Role,
|
||||
&v.InvitedBy, &v.ExpiresAt, &v.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeInvitation withdraws one before it is used.
|
||||
//
|
||||
// Scoped by client in the UPDATE, and it refuses an already-spent invitation
|
||||
// rather than silently doing nothing: "I revoked it" and "somebody had already
|
||||
// joined with it" need opposite follow-up actions from whoever asked.
|
||||
func (s *Store) RevokeInvitation(ctx context.Context, clientID, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE invitations SET revoked_at = now()
|
||||
WHERE id = $2::uuid AND client_id = $1::uuid
|
||||
AND used_at IS NULL AND revoked_at IS NULL`, clientID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke invitation: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errors.New("no such pending invitation")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvitationByCode is the unauthenticated preview: what a holder may learn
|
||||
// about a code they already have.
|
||||
//
|
||||
// Every way of not being valid returns the same error, so this cannot be used
|
||||
// to tell an expired code from an invented one.
|
||||
func (s *Store) InvitationByCode(ctx context.Context, hash []byte) (api.InvitationPreview, error) {
|
||||
var out api.InvitationPreview
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT c.name, i.email, i.full_name, i.role
|
||||
FROM invitations i
|
||||
JOIN clients c ON c.id = i.client_id
|
||||
WHERE i.code_hash = $1
|
||||
AND i.used_at IS NULL AND i.revoked_at IS NULL
|
||||
AND i.expires_at > now()`, hash,
|
||||
).Scan(&out.Client, &out.Email, &out.FullName, &out.Role)
|
||||
if err != nil {
|
||||
return api.InvitationPreview{}, errors.New("that invitation is not valid")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RedeemInvitation turns a code into an account, in ONE transaction.
|
||||
//
|
||||
// Two properties, and both were learned elsewhere in this system:
|
||||
//
|
||||
// - Single use is enforced BY the update. `used_at IS NULL` and the write are
|
||||
// one statement, so two people racing on one invitation cannot both win.
|
||||
// Check-then-update would be exactly that race, and the loser would get a
|
||||
// second account rather than an error.
|
||||
// - The account and the redemption commit together. A spent invitation with
|
||||
// no user behind it is an invitation nobody can use and nobody can see is
|
||||
// broken; a user with the invitation still open is a second account waiting
|
||||
// to be created by anyone who was forwarded the code.
|
||||
//
|
||||
// The email and the role come from the ROW, never from the request. A code
|
||||
// passed on to a colleague must not become an account for them, and a staff
|
||||
// invitation must not be redeemed as an owner.
|
||||
func (s *Store) RedeemInvitation(ctx context.Context, hash []byte,
|
||||
fullName, passwordHash string) (api.UserRecord, error) {
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return api.UserRecord{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
||||
|
||||
var clientID, email, role, invitedName string
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE invitations SET used_at = now()
|
||||
WHERE code_hash = $1
|
||||
AND used_at IS NULL AND revoked_at IS NULL AND expires_at > now()
|
||||
RETURNING client_id::text, email, role, full_name`, hash,
|
||||
).Scan(&clientID, &email, &role, &invitedName)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return api.UserRecord{}, errors.New("that invitation is not valid")
|
||||
}
|
||||
if err != nil {
|
||||
return api.UserRecord{}, fmt.Errorf("redeem invitation: %w", err)
|
||||
}
|
||||
|
||||
if fullName == "" {
|
||||
// The inviter may have typed a name; use it rather than leaving a
|
||||
// blank row that every screen then renders as an email address.
|
||||
fullName = invitedName
|
||||
}
|
||||
|
||||
var rec api.UserRecord
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5)
|
||||
RETURNING id::text, email, full_name, role`,
|
||||
clientID, email, passwordHash, fullName, role,
|
||||
).Scan(&rec.ID, &rec.Email, &rec.FullName, &rec.Role)
|
||||
if err != nil {
|
||||
return api.UserRecord{}, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
var clientName string
|
||||
if err := tx.QueryRow(ctx, `SELECT name FROM clients WHERE id = $1::uuid`,
|
||||
clientID).Scan(&clientName); err != nil {
|
||||
return api.UserRecord{}, err
|
||||
}
|
||||
|
||||
// Recorded against the new account, not the inviter: this is the moment a
|
||||
// person gained access, and the row should name who did.
|
||||
rec.ClientID, rec.ClientName, rec.Active, rec.Found = clientID, clientName, true, true
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return api.UserRecord{}, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Team lists the people in one company.
|
||||
func (s *Store) Team(ctx context.Context, clientID string) ([]api.TeamMember, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text, email, full_name, role, active,
|
||||
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
|
||||
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
FROM app_users
|
||||
WHERE client_id = $1::uuid
|
||||
ORDER BY active DESC, full_name, email`, clientID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list team: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []api.TeamMember
|
||||
for rows.Next() {
|
||||
var m api.TeamMember
|
||||
if err := rows.Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
||||
&m.LastLoginAt, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateTeamMember changes a role, or deactivates somebody who has left.
|
||||
//
|
||||
// Deactivating REVOKES their sessions in the same transaction. Leaving them
|
||||
// live would mean "remove their access" removed it in twelve hours' time,
|
||||
// whenever their access token happened to expire - which is not what anybody
|
||||
// pressing that button believes they have just done, and is precisely the case
|
||||
// an opaque-token session table exists to handle.
|
||||
func (s *Store) UpdateTeamMember(ctx context.Context, clientID, userID string,
|
||||
up api.TeamUpdate) (api.TeamMember, error) {
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return api.TeamMember{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
||||
|
||||
var m api.TeamMember
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE app_users
|
||||
SET role = COALESCE($3, role),
|
||||
active = COALESCE($4, active)
|
||||
WHERE id = $2::uuid AND client_id = $1::uuid
|
||||
RETURNING id::text, email, full_name, role, active,
|
||||
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
|
||||
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
clientID, userID, up.Role, up.Active,
|
||||
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
||||
&m.LastLoginAt, &m.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return api.TeamMember{}, errors.New("no such team member")
|
||||
}
|
||||
if err != nil {
|
||||
return api.TeamMember{}, fmt.Errorf("update team member: %w", err)
|
||||
}
|
||||
|
||||
if up.Active != nil && !*up.Active {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE sessions SET revoked_at = now()
|
||||
WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil {
|
||||
return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return api.TeamMember{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// OwnerCount counts the active owners of a company.
|
||||
//
|
||||
// Used to refuse the change that locks a company out of its own account: the
|
||||
// last owner may not demote or deactivate themselves. There is no support path
|
||||
// back from that except a shell on the server, which is the thing this whole
|
||||
// surface exists to stop needing.
|
||||
func (s *Store) OwnerCount(ctx context.Context, clientID string) (int, error) {
|
||||
var n int
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM app_users
|
||||
WHERE client_id = $1::uuid AND role = 'owner' AND active`, clientID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ============================================================== sessions ====
|
||||
|
||||
// UserSessions lists one person's live sessions, newest first.
|
||||
func (s *Store) UserSessions(ctx context.Context, userID string) ([]api.DeviceSession, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text, device,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
COALESCE(to_char(last_used_at AT TIME ZONE 'UTC',
|
||||
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
||||
to_char(refresh_expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
FROM sessions
|
||||
WHERE user_id = $1::uuid AND revoked_at IS NULL
|
||||
AND refresh_expires_at > now()
|
||||
ORDER BY COALESCE(last_used_at, created_at) DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []api.DeviceSession
|
||||
for rows.Next() {
|
||||
var d api.DeviceSession
|
||||
if err := rows.Scan(&d.ID, &d.Device, &d.CreatedAt,
|
||||
&d.LastUsedAt, &d.ExpiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeUserSession signs one device out.
|
||||
//
|
||||
// Scoped by user_id in the UPDATE, so a session id - which is not a secret and
|
||||
// travels in a list - cannot be used to sign somebody else out.
|
||||
func (s *Store) RevokeUserSession(ctx context.Context, userID, sessionID string) error {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE sessions SET revoked_at = now()
|
||||
WHERE id = $2::uuid AND user_id = $1::uuid AND revoked_at IS NULL`,
|
||||
userID, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke session: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errors.New("no such session")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeOtherSessions is the "sign out everywhere else" button.
|
||||
//
|
||||
// It keeps the caller's own session deliberately: somebody who has just lost a
|
||||
// phone should not also be signed out of the device they are holding, which
|
||||
// would leave them re-authenticating in the middle of an emergency.
|
||||
func (s *Store) RevokeOtherSessions(ctx context.Context, userID, keepSessionID string) (int, error) {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE sessions SET revoked_at = now()
|
||||
WHERE user_id = $1::uuid AND id <> $2::uuid AND revoked_at IS NULL`,
|
||||
userID, keepSessionID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("revoke sessions: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// CreateMember inserts an active account into a tenant.
|
||||
//
|
||||
// The email uniqueness constraint is global (migration 007), and a clash here
|
||||
// is an ordinary typing mistake - somebody already has that address - so it
|
||||
// surfaces as a conflict the manager can act on, not a 500.
|
||||
func (s *Store) CreateMember(ctx context.Context, clientID string,
|
||||
in api.NewMemberInput, hash string) (api.TeamMember, error) {
|
||||
|
||||
var m api.TeamMember
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5)
|
||||
RETURNING id::text, email, full_name, role, active, '',
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
clientID, in.Email, hash, in.FullName, in.Role,
|
||||
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
||||
&m.LastLoginAt, &m.CreatedAt)
|
||||
if err != nil {
|
||||
return api.TeamMember{}, fmt.Errorf("create member: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ResetMemberPassword replaces a member's password and signs them out
|
||||
// everywhere, in one transaction.
|
||||
//
|
||||
// The two go together because of why a manager resets a password at all: the
|
||||
// salesperson forgot it, or lost the phone it was saved on. In the second case
|
||||
// the old sessions are the problem, and a reset that left them valid would
|
||||
// look complete while changing nothing that mattered. Scoped to the caller's
|
||||
// tenant in the UPDATE itself, so a user id from another company matches no
|
||||
// row rather than being reset.
|
||||
func (s *Store) ResetMemberPassword(ctx context.Context, clientID, userID,
|
||||
hash string) (api.TeamMember, error) {
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return api.TeamMember{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
||||
|
||||
var m api.TeamMember
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE app_users SET password_hash = $3
|
||||
WHERE id = $2::uuid AND client_id = $1::uuid
|
||||
RETURNING id::text, email, full_name, role, active,
|
||||
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
|
||||
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
||||
clientID, userID, hash,
|
||||
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
||||
&m.LastLoginAt, &m.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return api.TeamMember{}, errors.New("no such team member")
|
||||
}
|
||||
if err != nil {
|
||||
return api.TeamMember{}, fmt.Errorf("reset password: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE sessions SET revoked_at = now()
|
||||
WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil {
|
||||
return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return api.TeamMember{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
96
server/internal/store/api_team_live_test.go
Normal file
96
server/internal/store/api_team_live_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// The in-memory fake agrees with whatever SQL I wrote. These run the two new
|
||||
// statements against Postgres: the RETURNING list has to scan, the tenant
|
||||
// scope has to hold, and a reset has to actually revoke the sessions row.
|
||||
|
||||
func TestLiveAManagerCreatedLoginRoundTrips(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
clientID, _ := seedTenant(t, st, "mem"+stamp(), 0, false)
|
||||
|
||||
hash, err := auth.HashPassword("a-perfectly-good-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := st.CreateMember(ctx, clientID, api.NewMemberInput{
|
||||
Email: "priya@" + stamp() + ".test", FullName: "Priya R", Role: "staff",
|
||||
}, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if m.ID == "" || !m.Active || m.Role != "staff" || m.CreatedAt == "" {
|
||||
t.Fatalf("member not as created: %+v", m)
|
||||
}
|
||||
// LastLoginAt is RETURNED as '' for a brand-new row; it must scan into a
|
||||
// string, not fail as an untyped literal.
|
||||
if m.LastLoginAt != "" {
|
||||
t.Fatalf("a new member has never logged in, got %q", m.LastLoginAt)
|
||||
}
|
||||
|
||||
// Findable by the login path, in the right tenant, with the hash intact.
|
||||
rec, err := st.UserByEmail(ctx, m.Email)
|
||||
if err != nil || !rec.Found {
|
||||
t.Fatalf("new member not findable: %v found=%v", err, rec.Found)
|
||||
}
|
||||
if rec.ClientID != clientID || !auth.VerifyPassword(rec.PasswordHash, "a-perfectly-good-password") {
|
||||
t.Fatalf("landed wrong: client=%s verify=%v", rec.ClientID, auth.VerifyPassword(rec.PasswordHash, "a-perfectly-good-password"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveAResetIsTenantScopedAndRevokesSessions(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
mine, _ := seedTenant(t, st, "rsa"+stamp(), 0, false)
|
||||
theirs, _ := seedTenant(t, st, "rsb"+stamp(), 0, false)
|
||||
|
||||
oldHash, _ := auth.HashPassword("old-password-here")
|
||||
m, err := st.CreateMember(ctx, mine, api.NewMemberInput{
|
||||
Email: "sam@" + stamp() + ".test", FullName: "Sam", Role: "staff"}, oldHash)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Give them a live session to lose.
|
||||
if _, err := st.pool.Exec(ctx, `
|
||||
INSERT INTO sessions (user_id, client_id, access_hash, refresh_hash,
|
||||
access_expires_at, refresh_expires_at, device)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, now() + interval '1 hour',
|
||||
now() + interval '30 days', 'lost phone')`,
|
||||
m.ID, mine, []byte("a"+stamp()), []byte("r"+stamp())); err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
|
||||
// Another tenant's manager cannot reset them, and it reads as no such row.
|
||||
newHash, _ := auth.HashPassword("new-password-here")
|
||||
if _, err := st.ResetMemberPassword(ctx, theirs, m.ID, newHash); err == nil {
|
||||
t.Fatal("a reset from another tenant should find nobody")
|
||||
}
|
||||
|
||||
// Their own tenant can, and it takes the session with it.
|
||||
if _, err := st.ResetMemberPassword(ctx, mine, m.ID, newHash); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
var live int
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM sessions WHERE user_id = $1::uuid AND revoked_at IS NULL`,
|
||||
m.ID).Scan(&live); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if live != 0 {
|
||||
t.Fatalf("%d session(s) survived a password reset", live)
|
||||
}
|
||||
rec, _ := st.UserByEmail(ctx, m.Email)
|
||||
if !auth.VerifyPassword(rec.PasswordHash, "new-password-here") ||
|
||||
auth.VerifyPassword(rec.PasswordHash, "old-password-here") {
|
||||
t.Fatal("the hash did not change to the new password")
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,14 @@ func (s *Store) RecordVisit(ctx context.Context, site ingest.Site,
|
||||
visitorID, v.OccurredAt, site.ClientID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Now that we know who this was, drop any face this server was holding
|
||||
// for them from an earlier visit. Only ever one survives per person,
|
||||
// which is what bounds visit_faces to the customer base rather than to
|
||||
// footfall - see migration 011. A bucket deployment writes no such keys
|
||||
// and this does nothing.
|
||||
if err := pruneVisitorFaces(ctx, tx, site.ClientID, visitorID, visitID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx,
|
||||
@@ -214,18 +222,34 @@ func (s *Store) matchOrCreateVisitor(ctx context.Context, tx pgx.Tx,
|
||||
}
|
||||
|
||||
// New person for this client.
|
||||
//
|
||||
// The number comes off the tenant's own counter rather than being derived
|
||||
// from the uuid, because it is what a human will read, say and search for:
|
||||
// "Visitor 42", not "Visitor 3446ec35". UPDATE ... RETURNING yields the
|
||||
// value AFTER the update, which is what is wanted here, and it row-locks
|
||||
// the client for the length of the insert so two shops cannot take the
|
||||
// same number. That lock is free - this runs only for a face nobody in the
|
||||
// estate has ever seen, not once per visit.
|
||||
var number int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE clients SET visitor_seq = visitor_seq + 1
|
||||
WHERE id = $1 RETURNING visitor_seq`,
|
||||
site.ClientID).Scan(&number); err != nil {
|
||||
return "", fmt.Errorf("next visitor number: %w", err)
|
||||
}
|
||||
// The label is formatted here rather than as `'Visitor ' || $2::text` in
|
||||
// the statement: reusing one parameter as a bigint and as a string operand
|
||||
// makes Postgres deduce two types for it and refuse the whole insert
|
||||
// ("inconsistent types deduced for parameter $2"). It compiled, it passed
|
||||
// every in-memory test, and it failed on the first real database.
|
||||
var newID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO visitors (client_id, label, first_seen_at)
|
||||
VALUES ($1, '', $2) RETURNING id::text`,
|
||||
site.ClientID, v.OccurredAt).Scan(&newID); err != nil {
|
||||
INSERT INTO visitors (client_id, number, label, first_seen_at)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id::text`,
|
||||
site.ClientID, number, fmt.Sprintf("Visitor %d", number),
|
||||
v.OccurredAt).Scan(&newID); err != nil {
|
||||
return "", fmt.Errorf("create visitor: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE visitors SET label = 'Visitor ' || left(id::text, 8)
|
||||
WHERE id = $1 AND label = ''`, newID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO visitor_embeddings
|
||||
(visitor_id, client_id, model, embedding, quality, source_site_id)
|
||||
|
||||
46
server/internal/web/dist/assets/index-BI5JLIeo.js
vendored
Normal file
46
server/internal/web/dist/assets/index-BI5JLIeo.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
server/internal/web/dist/assets/index-Bgt5SnW3.css
vendored
Normal file
1
server/internal/web/dist/assets/index-Bgt5SnW3.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
server/internal/web/dist/index.html
vendored
4
server/internal/web/dist/index.html
vendored
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="/assets/index-C8M-zRAi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-pUqVBCLm.css">
|
||||
<script type="module" crossorigin src="/assets/index-BI5JLIeo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bgt5SnW3.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
45
server/migrations/009_camera_snapshots.sql
Normal file
45
server/migrations/009_camera_snapshots.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- The latest still frame from each camera, held by the server itself.
|
||||
--
|
||||
-- Camera snapshots already worked, through the same presigned-URL path face
|
||||
-- images use: the agent asks for a URL, PUTs the JPEG to object storage, and
|
||||
-- the server presigns a short-lived link when somebody looks. That is the right
|
||||
-- design for face images - one per visit, unbounded, and they must never touch
|
||||
-- a shop PC's disk or the server's.
|
||||
--
|
||||
-- It is the wrong design for the ONE case where a deployment has no object
|
||||
-- storage at all. Head office then reports "This system is not storing images"
|
||||
-- for every camera, forever, on a screen whose whole point is to SHOW the
|
||||
-- camera. A self-hosted customer who does not want an S3 bucket, and every
|
||||
-- local install, got a wall of empty tiles.
|
||||
--
|
||||
-- What makes this safe to put in the database, when face images are not:
|
||||
--
|
||||
-- * ONE ROW PER CAMERA. The primary key is the camera, so a snapshot
|
||||
-- replaces its predecessor. An estate's storage is (cameras x ~100 KB)
|
||||
-- and does not grow with time or with footfall. Face images grow with
|
||||
-- every visitor who ever walks in, which is why they stay in a bucket.
|
||||
-- * It is a picture of a shop floor, not a face crop attached to an
|
||||
-- identity. It carries no template and is not tied to a person.
|
||||
-- * ON DELETE CASCADE from the camera. Removing a camera removes its
|
||||
-- picture, with no second place to remember to clean up.
|
||||
--
|
||||
-- When object storage IS configured nothing changes: the bucket path stays
|
||||
-- primary and this table is not written.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS camera_snapshots (
|
||||
camera_id uuid PRIMARY KEY REFERENCES site_cameras(id) ON DELETE CASCADE,
|
||||
-- Denormalised deliberately, like every other table here: a cross-tenant
|
||||
-- read should require a wrong WHERE clause rather than a forgotten join.
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
image bytea NOT NULL,
|
||||
bytes integer NOT NULL,
|
||||
captured_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS camera_snapshots_client_idx
|
||||
ON camera_snapshots (client_id);
|
||||
|
||||
COMMIT;
|
||||
60
server/migrations/010_invitations.sql
Normal file
60
server/migrations/010_invitations.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- Adding a second person to a company.
|
||||
--
|
||||
-- Until now a tenant had exactly the users `provision user` had created on the
|
||||
-- server's own command line. That is not a gap in a UI, it is a gap in the
|
||||
-- product: a shop with an owner and four staff either shares one password
|
||||
-- between five people or raises a support ticket to add each of them, and a
|
||||
-- mobile app for shop floor staff cannot exist at all when there is only one
|
||||
-- account to sign in with.
|
||||
--
|
||||
-- Registration is by INVITATION, never open signup. That is the same line
|
||||
-- `handlers_admin.go` already draws for creating a company: an endpoint a
|
||||
-- stranger can call to create an account is a much larger thing to secure than
|
||||
-- one reachable only through a manager who already has one, and a self-created
|
||||
-- account in a tenant is a row nobody asked for holding a place in a table
|
||||
-- every query joins against.
|
||||
--
|
||||
-- The single-use guarantee is the same one enrolment codes use, and for the
|
||||
-- same reason: it lives in the UPDATE (`used_at IS NULL` and the write are one
|
||||
-- statement), never in a check followed by a write, so two people racing on one
|
||||
-- invitation cannot both win.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invitations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
-- The address the invitation was issued FOR. It becomes the account's
|
||||
-- address on redemption and is not caller-supplied at that point: letting
|
||||
-- the redeemer choose would turn one invitation into an account for
|
||||
-- anybody who was forwarded the email.
|
||||
email text NOT NULL,
|
||||
full_name text NOT NULL DEFAULT '',
|
||||
-- 'admin' is deliberately NOT allowed. A platform administrator is defined
|
||||
-- by having no client at all, so an invitation - which always carries one -
|
||||
-- could never mint a real one; what it could do is put the string 'admin'
|
||||
-- on a tenant-scoped row, and `adminOnly` guards against exactly that
|
||||
-- combination existing. Refusing it here means it cannot be created in the
|
||||
-- first place.
|
||||
role text NOT NULL DEFAULT 'staff',
|
||||
-- Only the hash. An invitation is a credential for as long as it is
|
||||
-- unused, so a database dump must not contain a working one - the same
|
||||
-- rule sessions and enrolment codes already follow.
|
||||
code_hash bytea NOT NULL UNIQUE,
|
||||
invited_by uuid REFERENCES app_users(id) ON DELETE SET NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_at timestamptz,
|
||||
used_by uuid REFERENCES app_users(id) ON DELETE SET NULL,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT invitations_role_known
|
||||
CHECK (role IN ('owner', 'manager', 'staff'))
|
||||
);
|
||||
|
||||
-- Pending invitations only. The list a manager looks at is "who has been asked
|
||||
-- and has not joined yet"; spent and revoked rows are history.
|
||||
CREATE INDEX IF NOT EXISTS invitations_pending_idx
|
||||
ON invitations (client_id, created_at DESC)
|
||||
WHERE used_at IS NULL AND revoked_at IS NULL;
|
||||
|
||||
COMMIT;
|
||||
60
server/migrations/011_visit_faces.sql
Normal file
60
server/migrations/011_visit_faces.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- Face images for a deployment that has no object storage.
|
||||
--
|
||||
-- 009 did this for camera snapshots and its own comment says why face images
|
||||
-- are different: "Face images grow with every visitor who ever walks in, which
|
||||
-- is why they stay in a bucket." That is true of face images kept PER VISIT,
|
||||
-- and it is the reason this table is bounded to one row per visitor instead.
|
||||
--
|
||||
-- The problem it fixes is the one 009 fixed one level up. With no bucket the
|
||||
-- API answers "This system is not storing customer photos" for every arrival,
|
||||
-- forever - including on the mobile feed, whose entire purpose is to put a face
|
||||
-- in front of somebody so they can recognise the customer walking towards them.
|
||||
-- A shop that turned `app.store_faces` on and has no S3 account got nothing.
|
||||
--
|
||||
-- What makes this bounded, which is the only reason it is acceptable here:
|
||||
--
|
||||
-- * The engine still gates capture. `app.store_faces` is false by default and
|
||||
-- no crop is written without it, so this table changes what happens to an
|
||||
-- image that already exists - it does not change whether one is taken.
|
||||
-- * ONE ROW SURVIVES PER VISITOR. `RecordVisit` prunes the previous row when
|
||||
-- it links a newer one, so storage is (customers x ~20 KB) and grows with
|
||||
-- the customer base, not with footfall. A shop seen by 5,000 people holds
|
||||
-- about 100 MB whether they visit once or a thousand times.
|
||||
-- * Nothing reads a superseded face anyway. Every surface - the arrivals
|
||||
-- feed, the customer record, the mobile app - shows the customer's latest
|
||||
-- view, which is what `VisitorImageKey` has always returned.
|
||||
--
|
||||
-- Where a bucket IS configured this table is never written: the presigned path
|
||||
-- stays primary, because it never passes the bytes through the API at all,
|
||||
-- which is what makes it the right route at estate scale.
|
||||
--
|
||||
-- Keys are prefixed `db:` in `visits.image_key` so one column can name an
|
||||
-- object in either place and the read path can tell which without a second
|
||||
-- lookup or a nullable column.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS visit_faces (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Denormalised like every other table here: a cross-tenant read should
|
||||
-- require a wrong WHERE clause rather than a forgotten join.
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
image bytea NOT NULL,
|
||||
bytes integer NOT NULL,
|
||||
captured_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS visit_faces_client_idx
|
||||
ON visit_faces (client_id);
|
||||
|
||||
-- An agent uploads a face BEFORE the server has decided who it is, so a row can
|
||||
-- exist for a few milliseconds with no visit pointing at it - and permanently,
|
||||
-- if the visit that would have claimed it never arrives because the queue was
|
||||
-- dropped. That is a leak of exactly one image per lost visit, so it is swept
|
||||
-- rather than left: anything older than a day with no visit referencing it is
|
||||
-- an orphan, and this index is what makes finding them cheap.
|
||||
CREATE INDEX IF NOT EXISTS visit_faces_age_idx
|
||||
ON visit_faces (captured_at);
|
||||
|
||||
COMMIT;
|
||||
81
server/migrations/012_visitor_numbers.sql
Normal file
81
server/migrations/012_visitor_numbers.sql
Normal file
@@ -0,0 +1,81 @@
|
||||
-- A customer number a person can say out loud.
|
||||
--
|
||||
-- Until now `RecordVisit` named every new customer with eight hex characters
|
||||
-- off their uuid:
|
||||
--
|
||||
-- UPDATE visitors SET label = 'Visitor ' || left(id::text, 8)
|
||||
--
|
||||
-- So the name a shop assistant reads on the arrivals feed, and reads back to a
|
||||
-- colleague, and types into a search box, was "Visitor 3446ec35". That is not
|
||||
-- a display problem to paper over in the front end - `label` is a stored
|
||||
-- column that staff can overwrite, it is what `SearchVisitors` matches on, and
|
||||
-- it is what the desktop app, the web console and the mobile feed all show.
|
||||
-- It had to be fixed where it is written.
|
||||
--
|
||||
-- The replacement is a per-CLIENT sequential number: "Visitor 42", referenced
|
||||
-- as `V-42`. Three properties, each of which rules out an alternative:
|
||||
--
|
||||
-- * Speakable and typeable. This is the whole point. A number is read down a
|
||||
-- phone, written on a card and searched for; a uuid is copy-pasted or got
|
||||
-- wrong.
|
||||
-- * Per client, not global. A global sequence tells any customer who signs
|
||||
-- up how many people the entire platform has ever seen, from their own
|
||||
-- first visitor number - the German-tank estimate, and a number no
|
||||
-- customer should be able to compute. Per tenant it only reveals a
|
||||
-- tenant's own count to that tenant's own staff, who know it already.
|
||||
-- * Not the primary key. `visitors.id` stays a uuid. The ids in this schema
|
||||
-- are generated in places that cannot ask a database for the next value,
|
||||
-- and swapping a PK that eleven tables reference for a sequence buys
|
||||
-- nothing internal while risking everything. This is a public REFERENCE
|
||||
-- sitting beside the key, which is the part humans needed all along.
|
||||
--
|
||||
-- The counter lives on `clients`, and `UPDATE ... RETURNING` returns the value
|
||||
-- AFTER the update - which is exactly what is wanted here, and is the same
|
||||
-- semantics that silently broke the face prune in 011 by returning the value
|
||||
-- it had just written. Taking the number this way row-locks the client for the
|
||||
-- length of the insert, serialising new-visitor creation per tenant. That is
|
||||
-- free: a new visitor row is written only for a face nobody in the estate has
|
||||
-- ever seen, not once per visit. Computing MAX(number)+1 instead would race
|
||||
-- two shops onto one number.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS visitor_seq bigint NOT NULL DEFAULT 0;
|
||||
ALTER TABLE visitors ADD COLUMN IF NOT EXISTS number bigint;
|
||||
|
||||
-- Existing rows are numbered in the order they were first seen, so a customer
|
||||
-- who has been coming for a year has a lower number than one who arrived
|
||||
-- yesterday. Ties break on id only so the result is deterministic.
|
||||
WITH numbered AS (
|
||||
SELECT id,
|
||||
row_number() OVER (PARTITION BY client_id
|
||||
ORDER BY first_seen_at, id) AS n
|
||||
FROM visitors
|
||||
)
|
||||
UPDATE visitors v
|
||||
SET number = numbered.n
|
||||
FROM numbered
|
||||
WHERE v.id = numbered.id
|
||||
AND v.number IS NULL;
|
||||
|
||||
UPDATE clients c
|
||||
SET visitor_seq = COALESCE((SELECT max(number) FROM visitors
|
||||
WHERE client_id = c.id), 0);
|
||||
|
||||
-- Rename ONLY the labels this system generated. The pattern is exactly the
|
||||
-- eight lowercase hex characters the old statement produced, so a name a human
|
||||
-- typed - including one that legitimately starts with the word Visitor - is
|
||||
-- left alone. Overwriting a staff-assigned name would be silent data loss of
|
||||
-- the kind nobody would notice until a customer was greeted wrongly.
|
||||
UPDATE visitors
|
||||
SET label = 'Visitor ' || number
|
||||
WHERE number IS NOT NULL
|
||||
AND label ~ '^Visitor [0-9a-f]{8}$';
|
||||
|
||||
ALTER TABLE visitors ALTER COLUMN number SET NOT NULL;
|
||||
|
||||
-- Unique per tenant, and the index `V-42` is resolved through.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS visitors_number_idx
|
||||
ON visitors (client_id, number);
|
||||
|
||||
COMMIT;
|
||||
97
server/migrations/013_references_are_immutable.sql
Normal file
97
server/migrations/013_references_are_immutable.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- The references clients are now told to use must not be able to change.
|
||||
--
|
||||
-- 012 made `chennai`, `Office1` and `V-42` first-class: every route that takes
|
||||
-- an id takes one of these instead, and the documentation tells a client to
|
||||
-- prefer them. That turns three columns which were merely descriptive into
|
||||
-- IDENTIFIERS other people store - in an agent's config file on a shop counter,
|
||||
-- in a saved URL, in a report somebody scheduled.
|
||||
--
|
||||
-- All three were already treated as stable, and none of it was enforced:
|
||||
--
|
||||
-- * `clients.slug` appears in MQTT topics as bv/<client>.<site>/... and the
|
||||
-- broker ACL is written against it. Renaming one silently stops that
|
||||
-- tenant's estate from being able to publish, and the agents cannot be told
|
||||
-- - they would simply be refused by the broker.
|
||||
-- * `sites.slug` is what a shop PC calls itself: agent.json holds
|
||||
-- "site_id": "chennai". A rename orphans the PC from the shop it is
|
||||
-- standing in.
|
||||
-- * `site_cameras.camera_id` is what lands in `visits.camera_id`, which is a
|
||||
-- text column and not a foreign key. Renaming it orphans every visit
|
||||
-- already attributed to the old name - the footfall is still there and no
|
||||
-- longer joins to a camera.
|
||||
--
|
||||
-- The camera case was half-enforced in one handler (`handleUpdateCamera` nils
|
||||
-- CameraID before saving) and nowhere else, which is the shape of a rule that
|
||||
-- holds until somebody adds a second write path. This is the backstop, in the
|
||||
-- one place every write has to go through.
|
||||
--
|
||||
-- Deliberately a trigger and not a CHECK: a CHECK cannot see the old row, and
|
||||
-- the rule is about the transition, not the value.
|
||||
--
|
||||
-- Note what this does NOT freeze. `name` - "TeNext Chennai", "Front door" - is
|
||||
-- free to change and always should be: it is what a person reads, it is not what
|
||||
-- anything keys on, and conflating the two is how a system ends up unable to fix
|
||||
-- a typo in a shop's name.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION reference_is_immutable() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'% is a public reference and cannot be changed (% -> %); '
|
||||
'create a new row instead, or change the display name',
|
||||
TG_ARGV[0], OLD.slug, NEW.slug
|
||||
USING ERRCODE = 'check_violation';
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- camera_id lives in its own function only because the column is named
|
||||
-- differently; splitting it keeps the message honest about which value moved.
|
||||
CREATE OR REPLACE FUNCTION camera_reference_is_immutable() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'camera_id is a public reference and cannot be changed (% -> %); '
|
||||
'every visit already recorded names the old one. Change the label instead',
|
||||
OLD.camera_id, NEW.camera_id
|
||||
USING ERRCODE = 'check_violation';
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS clients_slug_immutable ON clients;
|
||||
CREATE TRIGGER clients_slug_immutable
|
||||
BEFORE UPDATE OF slug ON clients
|
||||
FOR EACH ROW WHEN (OLD.slug IS DISTINCT FROM NEW.slug)
|
||||
EXECUTE FUNCTION reference_is_immutable('clients.slug');
|
||||
|
||||
DROP TRIGGER IF EXISTS sites_slug_immutable ON sites;
|
||||
CREATE TRIGGER sites_slug_immutable
|
||||
BEFORE UPDATE OF slug ON sites
|
||||
FOR EACH ROW WHEN (OLD.slug IS DISTINCT FROM NEW.slug)
|
||||
EXECUTE FUNCTION reference_is_immutable('sites.slug');
|
||||
|
||||
DROP TRIGGER IF EXISTS site_cameras_id_immutable ON site_cameras;
|
||||
CREATE TRIGGER site_cameras_id_immutable
|
||||
BEFORE UPDATE OF camera_id ON site_cameras
|
||||
FOR EACH ROW WHEN (OLD.camera_id IS DISTINCT FROM NEW.camera_id)
|
||||
EXECUTE FUNCTION camera_reference_is_immutable();
|
||||
|
||||
-- A visitor's number is assigned once from the tenant's counter and read back
|
||||
-- as V-42. Nothing writes it after the insert; this says so.
|
||||
DROP TRIGGER IF EXISTS visitors_number_immutable ON visitors;
|
||||
CREATE OR REPLACE FUNCTION visitor_number_is_immutable() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'visitors.number is a public reference and cannot be changed (% -> %)',
|
||||
OLD.number, NEW.number
|
||||
USING ERRCODE = 'check_violation';
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER visitors_number_immutable
|
||||
BEFORE UPDATE OF number ON visitors
|
||||
FOR EACH ROW WHEN (OLD.number IS DISTINCT FROM NEW.number)
|
||||
EXECUTE FUNCTION visitor_number_is_immutable();
|
||||
|
||||
COMMIT;
|
||||
@@ -31,6 +31,7 @@ class FakeWorker:
|
||||
def stats(self): return {"camera_id": self.cam_cfg.id, "connected": True,
|
||||
"url": self.cam_cfg.safe_url()}
|
||||
def latest_jpeg(self): return None
|
||||
def latest_jpeg_since(self, known_ts): return None, known_ts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -77,3 +77,32 @@ def test_tcp_precheck_passes_through_non_url_sources():
|
||||
|
||||
assert _tcp_reachable(0, 1.0) == (True, "")
|
||||
assert _tcp_reachable("not-a-url", 1.0)[0] is True
|
||||
|
||||
|
||||
def test_the_probe_reports_the_stream_codec():
|
||||
"""Which codec a camera emits decides whether head office can ever show
|
||||
TRUE live video from it: a browser plays H.264 everywhere and H.265 only on
|
||||
some platforms, so a passthrough relay cannot rely on H.265.
|
||||
|
||||
It is reported because otherwise the only way to learn it is to read RTSP by
|
||||
hand — which is how it was found on the office camera, whose stream paths
|
||||
end in ".264" while both channels emit H.265.
|
||||
"""
|
||||
from behavision.capture import _fourcc
|
||||
|
||||
class Cap:
|
||||
def __init__(self, v):
|
||||
self.v = v
|
||||
|
||||
def get(self, _):
|
||||
return self.v
|
||||
|
||||
def code(text):
|
||||
return sum(ord(c) << (8 * i) for i, c in enumerate(text))
|
||||
|
||||
assert _fourcc(Cap(code("hevc"))) == "hevc"
|
||||
assert _fourcc(Cap(code("h264"))) == "h264"
|
||||
# Nothing to report is "" rather than a misleading value: a camera that
|
||||
# does not say must not read as one that said something.
|
||||
assert _fourcc(Cap(0)) == ""
|
||||
assert _fourcc(Cap(-1)) == ""
|
||||
|
||||
144
tests/test_live_picture.py
Normal file
144
tests/test_live_picture.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""The live picture is the camera's latest frame, not the pipeline's.
|
||||
|
||||
Until this, the MJPEG stream served the frame the recognition pipeline had
|
||||
most recently *finished* - so on a shop PC where detection plus identification
|
||||
ran a few times a second, the live view ran a few times a second too, and every
|
||||
picture it showed was already as old as that processing. It read as lag
|
||||
because it was. These tests pin the decoupling: the picture comes from the
|
||||
capture thread at its own rate, the boxes come from the pipeline at theirs,
|
||||
and nothing is encoded for a camera nobody is watching.
|
||||
"""
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from behavision.config import CameraConfig, Config
|
||||
from behavision.engine import CameraWorker
|
||||
from behavision.tracking import Track
|
||||
|
||||
|
||||
class StillSource:
|
||||
"""A capture thread stand-in that hands out whatever frame it is given."""
|
||||
|
||||
def __init__(self):
|
||||
self.frame = None
|
||||
self.ts = 0.0
|
||||
|
||||
def set(self, frame):
|
||||
self.frame, self.ts = frame, time.time()
|
||||
|
||||
def latest(self):
|
||||
return self.frame, self.ts
|
||||
|
||||
def latest_since(self, known_ts):
|
||||
if self.frame is None or self.ts <= known_ts:
|
||||
return None, known_ts
|
||||
return self.frame, self.ts
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
def make_worker(tmp_path):
|
||||
cfg = Config()
|
||||
cfg.app.data_dir = tmp_path
|
||||
cam = CameraConfig(id="cam1", host="127.0.0.1", port=1, path="/none")
|
||||
w = CameraWorker(cam, cfg, detector=None, encoder=None, gallery=None,
|
||||
bus=None, attrs=None)
|
||||
w.source = StillSource()
|
||||
return w
|
||||
|
||||
|
||||
def grey(v=90):
|
||||
return np.full((120, 160, 3), v, dtype=np.uint8)
|
||||
|
||||
|
||||
def resolved_track(box=(30, 30, 90, 100), label="Priya"):
|
||||
t = Track(id=1, box=box, kps=np.zeros((5, 2), dtype=np.float32), score=0.9)
|
||||
t.state, t.label, t.similarity = "resolved", label, 0.71
|
||||
return t
|
||||
|
||||
|
||||
def test_the_picture_advances_with_the_camera_not_the_pipeline(tmp_path):
|
||||
w = make_worker(tmp_path)
|
||||
|
||||
# Nothing captured yet: nothing to show, and no encode happened.
|
||||
assert w.latest_jpeg_since(0.0) == (None, 0.0)
|
||||
|
||||
# A frame arrives from the camera. The pipeline has not touched it - and
|
||||
# the live view must not wait for it to.
|
||||
w.source.set(grey(80))
|
||||
jpeg1, ts1 = w.latest_jpeg_since(0.0)
|
||||
assert jpeg1 is not None and ts1 > 0
|
||||
|
||||
# Same frame again: the stream asks "anything newer than ts1?" and the
|
||||
# answer is no. This is what stops duplicates going down the wire.
|
||||
assert w.latest_jpeg_since(ts1) == (None, ts1)
|
||||
|
||||
# The camera produces a new frame; the pipeline still has not run.
|
||||
time.sleep(0.002)
|
||||
w.source.set(grey(160))
|
||||
jpeg2, ts2 = w.latest_jpeg_since(ts1)
|
||||
assert jpeg2 is not None and ts2 > ts1 and jpeg2 != jpeg1
|
||||
|
||||
|
||||
def test_boxes_from_the_last_processed_frame_are_drawn_on_the_fresh_one(tmp_path):
|
||||
w = make_worker(tmp_path)
|
||||
w.source.set(grey())
|
||||
plain, _ = w.latest_jpeg_since(0.0)
|
||||
|
||||
# The pipeline finishes a frame with one recognised person in it.
|
||||
w._remember_tracks([resolved_track()])
|
||||
|
||||
# The NEXT camera frame - which the pipeline has not seen - still carries
|
||||
# the box, because a person does not vanish between two frames.
|
||||
time.sleep(0.002)
|
||||
w.source.set(grey())
|
||||
boxed, _ = w.latest_jpeg_since(0.0)
|
||||
assert boxed != plain, "a resolved track should be drawn on the live picture"
|
||||
|
||||
|
||||
def test_a_stale_overlay_is_not_drawn(tmp_path):
|
||||
"""A stalled pipeline must not leave a box floating over an empty spot."""
|
||||
w = make_worker(tmp_path)
|
||||
w.source.set(grey())
|
||||
plain, _ = w.latest_jpeg_since(0.0)
|
||||
|
||||
w._remember_tracks([resolved_track()])
|
||||
# Pretend the pipeline last ran a while ago.
|
||||
with w._lock:
|
||||
w._overlay_ts = time.time() - 2.0
|
||||
|
||||
time.sleep(0.002)
|
||||
w.source.set(grey())
|
||||
fresh, _ = w.latest_jpeg_since(0.0)
|
||||
assert fresh == plain, "boxes older than a second should not be drawn"
|
||||
|
||||
|
||||
def test_only_tracks_matched_in_the_frame_are_drawn(tmp_path):
|
||||
"""A track being coasted on misses has no face under it right now."""
|
||||
w = make_worker(tmp_path)
|
||||
missed = resolved_track()
|
||||
missed.misses = 3
|
||||
w._remember_tracks([missed])
|
||||
assert w._overlay == []
|
||||
|
||||
seen = resolved_track()
|
||||
w._remember_tracks([seen])
|
||||
assert len(w._overlay) == 1
|
||||
(box, _color, text) = w._overlay[0]
|
||||
assert box == (30, 30, 90, 100) and text.startswith("Priya (")
|
||||
|
||||
|
||||
def test_remembering_tracks_does_not_encode_or_copy(tmp_path):
|
||||
"""The whole CPU argument: recording what to draw is a few tuples, and the
|
||||
frame is never touched. A shop PC with no viewer pays nothing."""
|
||||
w = make_worker(tmp_path)
|
||||
tracks = [resolved_track() for _ in range(5)]
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(1000):
|
||||
w._remember_tracks(tracks)
|
||||
per_call_us = (time.perf_counter() - t0) / 1000 * 1e6
|
||||
# A JPEG encode of even a small frame is hundreds of microseconds; this
|
||||
# should be an order of magnitude under that.
|
||||
assert per_call_us < 100, f"remembering tracks took {per_call_us:.0f}us"
|
||||
@@ -6,6 +6,7 @@ import Live from './views/Live.jsx'
|
||||
import CamerasView from './views/Cameras.jsx'
|
||||
import Assistant from './views/Assistant.jsx'
|
||||
import Clients from './views/Clients.jsx'
|
||||
import Team from './views/Team.jsx'
|
||||
|
||||
// A platform admin has no client of their own, so the tenant screens have
|
||||
// nothing to show them. Rather than render empty pages, they get the one screen
|
||||
@@ -22,6 +23,7 @@ const TENANT_VIEWS = [
|
||||
{ id: 'sites', label: 'Shops', View: Sites },
|
||||
{ id: 'live', label: 'Live', View: Live },
|
||||
{ id: 'cameras', label: 'Cameras', View: CamerasView },
|
||||
{ id: 'team', label: 'Team', View: Team },
|
||||
]
|
||||
const ADMIN_VIEWS = [
|
||||
{ id: 'clients', label: 'Companies', View: Clients },
|
||||
|
||||
172
web/src/api.js
172
web/src/api.js
@@ -91,6 +91,54 @@ async function send(method, path, body, retry = true) {
|
||||
parsed?.message || `Something went wrong (${res.status}).`)
|
||||
}
|
||||
|
||||
// fetchImage loads a picture this server holds itself, with the session's
|
||||
// bearer token, and returns an object URL an <img> can use.
|
||||
//
|
||||
// It exists because an <img src> cannot carry an Authorization header. The
|
||||
// object-storage path returns a presigned absolute URL that needs no auth,
|
||||
// which is why it worked with a plain src; a picture served from our own
|
||||
// database has no such link, and minting an unauthenticated one so that <img>
|
||||
// could use it would add a way to reach a photograph of somebody's shop floor
|
||||
// without a session - the opposite of what this path is for.
|
||||
//
|
||||
// The caller MUST revoke the returned URL when it is finished with it, or the
|
||||
// browser keeps every blob it has ever loaded for the life of the page.
|
||||
async function fetchImage(path, retry = true) {
|
||||
const { access } = tokens()
|
||||
const res = await fetch(path, {
|
||||
headers: access ? { Authorization: 'Bearer ' + access } : {},
|
||||
})
|
||||
if (res.ok) return URL.createObjectURL(await res.blob())
|
||||
|
||||
let parsed = null
|
||||
try { parsed = await res.json() } catch { /* an image endpoint may not answer json */ }
|
||||
const code = parsed?.error || ''
|
||||
if (code === 'token_expired' && retry) {
|
||||
await refresh()
|
||||
return fetchImage(path, false)
|
||||
}
|
||||
if (res.status === 401) clearTokens()
|
||||
throw new ApiError(res.status, code,
|
||||
parsed?.message || `That picture could not be loaded (${res.status}).`)
|
||||
}
|
||||
|
||||
// A label for the session list, so somebody can tell which device to sign out.
|
||||
// Deliberately coarse and never an identifier: a fingerprint here would be a
|
||||
// tracking signal we have no reason to hold, and the question this answers is
|
||||
// only "which of these is the one in my hand".
|
||||
function deviceName() {
|
||||
const ua = navigator.userAgent || ''
|
||||
const os = /Windows/.test(ua) ? 'Windows'
|
||||
: /Mac OS X|Macintosh/.test(ua) ? 'Mac'
|
||||
: /Android/.test(ua) ? 'Android'
|
||||
: /iPhone|iPad/.test(ua) ? 'iOS' : 'Unknown'
|
||||
const browser = /Edg\//.test(ua) ? 'Edge'
|
||||
: /Chrome\//.test(ua) ? 'Chrome'
|
||||
: /Safari\//.test(ua) ? 'Safari'
|
||||
: /Firefox\//.test(ua) ? 'Firefox' : 'browser'
|
||||
return `${browser} on ${os}`
|
||||
}
|
||||
|
||||
const qs = (params) => {
|
||||
const p = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params || {})) {
|
||||
@@ -105,7 +153,7 @@ export const api = {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email, password, device: deviceName() }),
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
@@ -116,6 +164,39 @@ export const api = {
|
||||
return body.user
|
||||
},
|
||||
|
||||
// What a code says it is for, before anybody is asked to choose a password.
|
||||
// Unauthenticated by necessity: the holder has no account yet.
|
||||
async previewInvitation(code) {
|
||||
const res = await fetch('/api/auth/invitation' + qs({ code }))
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, body?.error || '',
|
||||
body?.message || 'That invitation code is not valid.')
|
||||
}
|
||||
return body
|
||||
},
|
||||
|
||||
// Redeem an invitation. Returns a signed-in session, not just an account:
|
||||
// sending somebody who has just chosen a password to a sign-in form to type
|
||||
// it again is the sort of thing that gets blamed on the password.
|
||||
//
|
||||
// The address and the role are NOT sent - they come from the invitation, and
|
||||
// the server refuses a body that names either.
|
||||
async register({ code, full_name, password }) {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, full_name, password, device: deviceName() }),
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, body?.error || '',
|
||||
body?.message || 'Could not create the account.')
|
||||
}
|
||||
setTokens(body.access_token, body.refresh_token)
|
||||
return body.user
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try { await send('POST', '/api/auth/logout') } catch { /* already gone */ }
|
||||
clearTokens()
|
||||
@@ -138,6 +219,9 @@ export const api = {
|
||||
conversion: (params) => send('GET', '/api/reports/conversion' + qs(params)),
|
||||
|
||||
cameras: () => send('GET', '/api/cameras'),
|
||||
// A camera picture this server holds itself. Returns an object URL the
|
||||
// caller must revoke; see fetchImage.
|
||||
cameraSnapshot: (url) => fetchImage(url),
|
||||
createCamera: (siteID, cam) =>
|
||||
send('POST', `/api/sites/${encodeURIComponent(siteID)}/cameras`, cam),
|
||||
updateCamera: (id, cam) => send('PATCH', `/api/cameras/${encodeURIComponent(id)}`, cam),
|
||||
@@ -162,6 +246,25 @@ export const api = {
|
||||
|
||||
clients: () => send('GET', '/api/admin/clients'),
|
||||
createClient: (input) => send('POST', '/api/admin/clients', input),
|
||||
|
||||
// The people who work here.
|
||||
team: () => send('GET', '/api/team'),
|
||||
updateMember: (id, changes) =>
|
||||
send('PATCH', `/api/team/${encodeURIComponent(id)}`, changes),
|
||||
invitations: () => send('GET', '/api/team/invitations'),
|
||||
// The code comes back in full exactly once - only a hash is stored - so
|
||||
// whatever calls this has to show it there and then and must not expect to
|
||||
// read it back later. Same contract as enrolmentCode above.
|
||||
invite: (input) => send('POST', '/api/team/invitations', input),
|
||||
revokeInvitation: (id) =>
|
||||
send('DELETE', `/api/team/invitations/${encodeURIComponent(id)}`),
|
||||
|
||||
// Devices this account is signed in on. The point of holding sessions in a
|
||||
// table rather than issuing JWTs is that signing one out actually works.
|
||||
sessions: () => send('GET', '/api/auth/sessions'),
|
||||
revokeSession: (id) =>
|
||||
send('DELETE', `/api/auth/sessions/${encodeURIComponent(id)}`),
|
||||
signOutOthers: () => send('POST', '/api/auth/sessions/revoke-others'),
|
||||
}
|
||||
|
||||
// The live stream, read with fetch rather than EventSource.
|
||||
@@ -227,3 +330,70 @@ export function streamArrivals({ cursor, siteId, onPage, onError, signal }) {
|
||||
run()
|
||||
return () => { stopped = true }
|
||||
}
|
||||
|
||||
// streamCameraLive renders one camera's live view into an <img>.
|
||||
//
|
||||
// Frames arrive base64 over SSE for the same reason the snapshot is fetched
|
||||
// rather than linked: an <img> cannot send an Authorization header, and minting
|
||||
// a URL that works without a session — for LIVE video of a shop floor — would
|
||||
// be a far worse trade than the 33% base64 costs.
|
||||
//
|
||||
// The frame is written straight into `img.src` as a data URL rather than an
|
||||
// object URL. Object URLs would have to be revoked one per frame, several times
|
||||
// a second, and a single missed revoke is a leak that grows for as long as the
|
||||
// view is open. A data URL is owned by the element and replaced by the next one.
|
||||
export function streamCameraLive({ cameraId, img, onState, signal }) {
|
||||
let stopped = false
|
||||
|
||||
const run = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
const { access } = tokens()
|
||||
const res = await fetch(`/api/cameras/${cameraId}/live`, {
|
||||
headers: { Authorization: 'Bearer ' + access, Accept: 'text/event-stream' },
|
||||
signal,
|
||||
})
|
||||
if (res.status === 401) { await refresh(); continue }
|
||||
if (!res.ok || !res.body) throw new Error('live view unavailable')
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (!stopped) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
let split
|
||||
while ((split = buf.indexOf('\n\n')) !== -1) {
|
||||
const chunk = buf.slice(0, split)
|
||||
buf = buf.slice(split + 2)
|
||||
let event = 'message', data = ''
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (line.startsWith('event: ')) event = line.slice(7).trim()
|
||||
else if (line.startsWith('data: ')) data = line.slice(6)
|
||||
}
|
||||
if (event === 'frame' && data) {
|
||||
if (img.current) img.current.src = 'data:image/jpeg;base64,' + data
|
||||
onState?.('live')
|
||||
} else if (event === 'waiting') {
|
||||
// The server has registered us; the shop PC has not started
|
||||
// pushing yet. Saying so beats an empty box, because the wait is
|
||||
// a real second or two while the agent is asked.
|
||||
onState?.('waiting')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (stopped || signal?.aborted) return
|
||||
onState?.('reconnecting')
|
||||
}
|
||||
if (stopped) return
|
||||
// The server caps one push so a tab left open for a week does not leave
|
||||
// a shop uploading for a week. Reconnecting is how a viewer who IS still
|
||||
// watching carries on, so this is a normal event, not an error.
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
}
|
||||
}
|
||||
run()
|
||||
return () => { stopped = true }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { api } from './api.js'
|
||||
|
||||
// usePolled runs `fn` now and every `everyMs`, and is careful about the two
|
||||
// things every screen would otherwise get wrong on its own: overlapping
|
||||
// requests when the server is slower than the interval, and setting state
|
||||
@@ -40,3 +42,40 @@ export function usePolled(fn, everyMs, deps = []) {
|
||||
|
||||
return { data, error, loading, reload: run }
|
||||
}
|
||||
|
||||
|
||||
// useAuthedImage loads a picture that needs the session's bearer token and
|
||||
// hands back a URL an <img> can use.
|
||||
//
|
||||
// Two things it has to get right, and both were bugs the first time something
|
||||
// like it was written elsewhere in this app:
|
||||
//
|
||||
// * REVOKE. An object URL pins the blob in memory until it is revoked, and
|
||||
// this component re-renders on every poll. Without the cleanup a camera
|
||||
// screen left open for an afternoon holds hundreds of copies of the same
|
||||
// photograph.
|
||||
// * Key on the URL, not on the object. `snapshot` is a fresh object on every
|
||||
// poll, so an effect depending on it would re-fetch 90 KB per camera every
|
||||
// few seconds; the URL only changes when the picture actually does.
|
||||
export function useAuthedImage(url) {
|
||||
const [src, setSrc] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) { setSrc(null); return }
|
||||
let alive = true
|
||||
let objectURL = null
|
||||
api.cameraSnapshot(url)
|
||||
.then(u => {
|
||||
if (!alive) { URL.revokeObjectURL(u); return }
|
||||
objectURL = u
|
||||
setSrc(u)
|
||||
})
|
||||
.catch(() => { if (alive) setSrc(null) })
|
||||
return () => {
|
||||
alive = false
|
||||
if (objectURL) URL.revokeObjectURL(objectURL)
|
||||
}
|
||||
}, [url])
|
||||
|
||||
return src
|
||||
}
|
||||
|
||||
@@ -182,7 +182,12 @@ button.ghost:hover { border-color: var(--muted); color: var(--ink); }
|
||||
.arrivals { list-style: none; padding: 0; display: grid; gap: 8px; }
|
||||
.card.arrival { display: flex; align-items: center; gap: 14px; padding: 11px 14px; }
|
||||
.face { width: 46px; height: 46px; border-radius: 50%; flex: none;
|
||||
object-fit: cover; background: var(--surface-2); }
|
||||
overflow: hidden; object-fit: cover; background: var(--surface-2); }
|
||||
/* .face is a <span> wrapping the picture rather than the <img> itself, because
|
||||
a face served from this server's own database has to be fetched with the
|
||||
session before it can be shown. The image inside still has to fill the
|
||||
circle, and the wrapper clips it. */
|
||||
.face > img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.face.initials { display: grid; place-items: center; color: var(--muted);
|
||||
font-size: 15px; font-weight: 600; letter-spacing: .02em; }
|
||||
.who-col { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
|
||||
@@ -497,3 +502,69 @@ button.ghost.danger:hover { border-color: var(--bad); }
|
||||
.assistant { width: 100%; height: 60vh; position: static; border-left: 0;
|
||||
border-top: 1px solid var(--line); }
|
||||
}
|
||||
|
||||
|
||||
/* ------------------------------------------------------- live camera view */
|
||||
/* Deliberately a small affordance on the picture rather than a big play
|
||||
button: opening it makes a shop PC start uploading, so it is an action
|
||||
somebody chooses, not one a page does on their behalf. */
|
||||
.card.cam .watch {
|
||||
position: absolute; left: 10px; top: 10px; z-index: 2;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 9px; border-radius: 999px; border: 0; cursor: pointer;
|
||||
font: inherit; font-size: 11.5px; font-weight: 600; letter-spacing: .02em;
|
||||
color: #fff; background: rgba(12, 16, 20, .72);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.card.cam .watch:hover { background: rgba(12, 16, 20, .9); }
|
||||
.card.cam .watch i {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: #E5484D;
|
||||
box-shadow: 0 0 0 0 rgba(229, 72, 77, .7);
|
||||
animation: livepulse 2s infinite;
|
||||
}
|
||||
@keyframes livepulse {
|
||||
70% { box-shadow: 0 0 0 6px rgba(229, 72, 77, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(229, 72, 77, 0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card.cam .watch i { animation: none; }
|
||||
}
|
||||
|
||||
.drawer.live { max-width: 760px; }
|
||||
.liveshot {
|
||||
position: relative; background: #000; border-radius: 10px; overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
.liveshot img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.livewait {
|
||||
position: absolute; inset: 0; display: grid; place-items: center;
|
||||
font-size: 13px; color: rgba(255, 255, 255, .78);
|
||||
background: rgba(0, 0, 0, .35);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- team --- */
|
||||
.rows tr.inactive td { opacity: .55; }
|
||||
.rows .role { text-transform: capitalize; }
|
||||
.rows td.right { text-align: right; }
|
||||
.pill.muted { margin-left: 8px; font-size: 11px; padding: 1px 7px; border-radius: 999px;
|
||||
background: var(--surface-2); color: var(--muted); vertical-align: middle; }
|
||||
.pending { margin-top: 26px; }
|
||||
.pending h2 { font-size: 14px; font-weight: 600; color: var(--muted); margin: 0 0 10px; }
|
||||
.invites { list-style: none; padding: 0; display: grid; gap: 8px; }
|
||||
.card.invite { display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 14px; padding: 11px 14px; }
|
||||
.card.invite .sub { display: block; }
|
||||
/* The code is read aloud and typed in, so it is set wide and monospaced.
|
||||
Grouped in sixes by the server for the same reason. */
|
||||
.creds code.big { font-size: 16px; letter-spacing: .06em; }
|
||||
|
||||
/* A button that reads as a link. Used where the action is a change of screen
|
||||
rather than a submission, so it must not look like the primary button next
|
||||
to it. */
|
||||
.linkish { background: none; border: 0; padding: 0; font: inherit;
|
||||
color: var(--accent); cursor: pointer; text-decoration: underline;
|
||||
text-underline-offset: 2px; }
|
||||
.linkish:hover { opacity: .8; }
|
||||
/* The code is read off a screen or a phone call, so it is set wide. */
|
||||
.codefield { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: .04em; text-transform: uppercase; }
|
||||
|
||||
85
web/src/views/CameraLive.jsx
Normal file
85
web/src/views/CameraLive.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { streamCameraLive } from '../api.js'
|
||||
|
||||
// The live view of one camera.
|
||||
//
|
||||
// It is a few frames a second, not video, and the label says so rather than
|
||||
// letting somebody conclude the camera is stuttering. That is the honest limit
|
||||
// of relaying through the agent's outbound connection; true 25 fps would need
|
||||
// WebRTC and a TURN server, which is a different piece of infrastructure.
|
||||
//
|
||||
// **Nothing is uploaded from the shop until this component is mounted**, and it
|
||||
// stops within seconds of it going away. That is the whole reason a live view
|
||||
// is affordable at all, and why this is a deliberate action rather than
|
||||
// something every camera card does on its own.
|
||||
export default function CameraLive({ camera, onClose }) {
|
||||
const img = useRef(null)
|
||||
const [state, setState] = useState('waiting')
|
||||
// Nothing has arrived after a sensible wait. Its own state, because "the
|
||||
// shop PC has not answered" is a different thing to tell somebody than
|
||||
// "connecting", and leaving a spinner up forever tells them nothing at all.
|
||||
const [stalled, setStalled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController()
|
||||
const stop = streamCameraLive({
|
||||
cameraId: camera.id, img,
|
||||
onState: s => { setState(s); if (s === 'live') setStalled(false) },
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
// Generous: the server holds the agent's poll, the agent then has to reach
|
||||
// the camera, and a cold start is a couple of seconds even when everything
|
||||
// works.
|
||||
const t = setTimeout(() => setStalled(true), 12000)
|
||||
return () => { stop(); ctrl.abort(); clearTimeout(t) }
|
||||
}, [camera.id])
|
||||
|
||||
// What to say when nothing arrives. The two causes need different actions -
|
||||
// one is the camera, the other is the PC - so they must not share a message.
|
||||
const stalledNote = camera.connected === false
|
||||
? 'This camera was not connecting when the shop PC last reported. Check it is powered on and reachable on the shop’s network.'
|
||||
: camera.connected == null
|
||||
? 'The shop PC has not reported on this camera yet. It may still be starting up.'
|
||||
: 'The shop PC is not sending frames. It may be offline, or its Behavision app may not be running.'
|
||||
|
||||
const note = stalled && state !== 'live'
|
||||
? stalledNote
|
||||
: {
|
||||
waiting: 'Asking the shop PC…',
|
||||
live: 'Live',
|
||||
reconnecting: 'Reconnecting…',
|
||||
}[state]
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<aside className="drawer live" onClick={e => e.stopPropagation()}>
|
||||
<header className="drawer-head">
|
||||
<div>
|
||||
<h2>{camera.label}</h2>
|
||||
<p className="sub">{camera.site}</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
<div className="drawer-body">
|
||||
<div className="liveshot">
|
||||
{/* Seeded with the stored snapshot so the first second shows the
|
||||
camera rather than a black rectangle. It is the same view, a
|
||||
minute old, which is a far better place to start from than
|
||||
nothing. */}
|
||||
<img ref={img} alt={`Live view from ${camera.label}`} />
|
||||
{state !== 'live' && (
|
||||
<div className="livewait">
|
||||
<span>{stalled ? 'No picture yet' : note}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="hint" style={{ marginTop: 10 }}>
|
||||
{state === 'live'
|
||||
? 'Live. The shop only uploads while this view is open.'
|
||||
: note}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import Shot from './Shot.jsx'
|
||||
import CameraLive from './CameraLive.jsx'
|
||||
import { ago, Loading, Problem } from './Sites.jsx'
|
||||
import CameraSetup from './CameraSetup.jsx'
|
||||
|
||||
@@ -38,6 +40,10 @@ export default function Cameras({ user }) {
|
||||
usePolled(() => api.cameras(), 20000, [])
|
||||
const { data: sites } = usePolled(() => api.sites(), 0, [])
|
||||
const [editing, setEditing] = useState(null)
|
||||
// Only one camera streams at a time, on purpose. Every open view makes a
|
||||
// shop PC upload, so a grid that went live all at once would put an estate's
|
||||
// worth of cameras on the wire because somebody opened a page.
|
||||
const [watching, setWatching] = useState(null)
|
||||
|
||||
const canEdit = ['admin', 'owner', 'manager'].includes(user.role)
|
||||
const list = cams || []
|
||||
@@ -75,7 +81,8 @@ export default function Cameras({ user }) {
|
||||
<div className="grid cams">
|
||||
{list.map(c => (
|
||||
<CameraCard key={c.id} cam={c} canEdit={canEdit}
|
||||
onEdit={() => setEditing(c)} />
|
||||
onEdit={() => setEditing(c)}
|
||||
onWatch={() => setWatching(c)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -89,11 +96,15 @@ export default function Cameras({ user }) {
|
||||
onSaved={(_, opts) => { if (!opts?.keepOpen) setEditing(null); reload() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{watching && (
|
||||
<CameraLive camera={watching} onClose={() => setWatching(null)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CameraCard({ cam, canEdit, onEdit }) {
|
||||
function CameraCard({ cam, canEdit, onEdit, onWatch }) {
|
||||
// Three states, not two. A camera nobody has tried yet is not a camera that
|
||||
// is down, and telling an operator to check the cabling on a camera the shop
|
||||
// PC has not even seen sends them to the wrong building.
|
||||
@@ -114,7 +125,7 @@ function CameraCard({ cam, canEdit, onEdit }) {
|
||||
onKeyDown={e => canEdit && e.key === 'Enter' && onEdit()}>
|
||||
<div className="shot">
|
||||
{cam.snapshot?.available
|
||||
? <img src={cam.snapshot.url} alt={`View from ${cam.label}`} loading="lazy" />
|
||||
? <Shot image={cam.snapshot} alt={`View from ${cam.label}`} />
|
||||
: <div className="noshot">
|
||||
<span className="lens" aria-hidden="true" />
|
||||
{cam.snapshot?.reason || 'No picture yet.'}
|
||||
@@ -135,6 +146,18 @@ function CameraCard({ cam, canEdit, onEdit }) {
|
||||
{cam.snapshot_at && (
|
||||
<span className="shot-age">{ago(cam.snapshot_at)}</span>
|
||||
)}
|
||||
|
||||
{/* Always offered, including when this card says the camera is down.
|
||||
`connected` is head office's LAST REPORT and can be two minutes
|
||||
stale, so gating on it hid the button during every reconnect - and
|
||||
"is that camera really down?" is precisely the moment somebody wants
|
||||
to look. A hidden control says "you cannot" when the honest answer
|
||||
is "here is why", which the live view itself can give.
|
||||
stopPropagation because the card itself opens Edit. */}
|
||||
<button className="watch" title="Watch this camera now"
|
||||
onClick={e => { e.stopPropagation(); onWatch() }}>
|
||||
<i aria-hidden="true" />Live
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connected and verified are different claims, and the gap between them
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user