Compare commits
10 Commits
c7024b57ca
...
ee9e8b80b7
| Author | SHA1 | Date | |
|---|---|---|---|
| ee9e8b80b7 | |||
| 3598d8e9c0 | |||
| ce0223006b | |||
| 08873f4a67 | |||
| 9182f70442 | |||
| 3f9fb33b24 | |||
| ffae7e45d5 | |||
| 18686cbceb | |||
| 2cd7a78ddc | |||
| e0ceb14589 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -52,3 +52,6 @@ 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-*
|
||||
|
||||
395
API.md
Normal file
395
API.md
Normal file
@@ -0,0 +1,395 @@
|
||||
# Behavision API — for the web console and a mobile app
|
||||
|
||||
Base URL: `https://platform.loyaly.ai` (locally `http://127.0.0.1:8088`).
|
||||
Everything is JSON unless stated. All times are RFC 3339 UTC unless a field says
|
||||
otherwise.
|
||||
|
||||
There is **one API**, not a web one and a mobile one. The web console in this
|
||||
repository uses exactly these calls; anything it can do, an app can do.
|
||||
|
||||
---
|
||||
|
||||
## 0. Identifiers — you do not have to use uuids
|
||||
|
||||
Every id in the database is a uuid and every one of them still works. But a uuid
|
||||
is not something a person can say, type or recognise, so **anywhere a path or a
|
||||
`site` parameter takes an id, it also takes the name people actually use**:
|
||||
|
||||
| thing | reference | example |
|
||||
|---|---|---|
|
||||
| customer | `V-<number>` | `V-42` — also accepts bare `42` |
|
||||
| shop | its slug | `chennai` |
|
||||
| camera | the id the engine knows it by | `Office1` |
|
||||
| person | their email address | `priya@tenext.in` |
|
||||
|
||||
```
|
||||
GET /api/visitors/3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c/history
|
||||
GET /api/visitors/V-42/history ← the same customer
|
||||
GET /api/visits?site=chennai
|
||||
PATCH /api/cameras/Office1
|
||||
```
|
||||
|
||||
The customer number is **per company**, so `V-42` at one tenant and `V-42` at
|
||||
another are different people, and a reference never resolves outside the tenant
|
||||
the session belongs to. It is also what the label says: a customer nobody has
|
||||
named is called `Visitor 42`, and `ref` on every customer object carries `V-42`
|
||||
for display.
|
||||
|
||||
Two shops in one company may each have a camera called `Office1`. That is
|
||||
ambiguous, so it resolves to **nothing** rather than to a guess — use the uuid,
|
||||
or scope by site.
|
||||
|
||||
An unknown reference in a **path** is `404`; an unknown one in a **query filter**
|
||||
is `400`, because the collection itself was fine and it was the filter that was
|
||||
wrong.
|
||||
|
||||
**A reference never changes.** A shop's slug, a camera's id and a customer's
|
||||
number are immutable in the database, so it is safe to store one — in a saved
|
||||
URL, a config file or a scheduled report. The **display name** beside it
|
||||
(`"TeNext Chennai"`, `"Front door"`) is free to change and should be; do not key
|
||||
on it.
|
||||
|
||||
The uuid is still returned everywhere and still works. Use it if you want a key
|
||||
you never have to think about; use the reference when a person will read it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Signing in
|
||||
|
||||
### `POST /api/auth/login`
|
||||
|
||||
```json
|
||||
{ "email": "priya@tenext.in", "password": "…", "device": "Pixel 8" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "…",
|
||||
"refresh_token": "…",
|
||||
"expires_at": "2026-09-05T18:00:00Z",
|
||||
"user": { "id": "…", "email": "…", "full_name": "Priya R",
|
||||
"role": "staff", "client_id": "…", "client_name": "TeNext Retail" }
|
||||
}
|
||||
```
|
||||
|
||||
Send `Authorization: Bearer <access_token>` on every other call.
|
||||
|
||||
**`device` is worth sending.** It is the only thing that lets somebody look at
|
||||
their list of signed-in devices and tell which one to sign out. Keep it coarse
|
||||
and human — `"Pixel 8"`, `"Shop till"` — never a device identifier; a
|
||||
fingerprint here is a tracking signal nobody asked for.
|
||||
|
||||
Failures:
|
||||
|
||||
| status | `error` | what it means |
|
||||
|---|---|---|
|
||||
| 401 | `bad_credentials` | Wrong password **or** no such account. Deliberately the same answer: telling them apart turns this form into a way to find out who works at a customer. Show the server's `message`. |
|
||||
| 429 | `too_many_attempts` | 10 failures per account / 60 per IP in 15 minutes. Cleared by a success. |
|
||||
|
||||
### `POST /api/auth/refresh`
|
||||
|
||||
```json
|
||||
{ "refresh_token": "…", "device": "Pixel 8" }
|
||||
```
|
||||
|
||||
Returns the same shape. **Both tokens rotate** — the old refresh token stops
|
||||
working the instant the new one is issued, so a copy taken off a resold device
|
||||
cannot keep working alongside the real one.
|
||||
|
||||
Three rules a client must follow, and all three have already been the cause of a
|
||||
bug in this codebase:
|
||||
|
||||
1. **An expired access token returns 401 with `"error": "token_expired"`**,
|
||||
distinct from a real 401. Refresh once and retry, silently — otherwise staff
|
||||
are thrown back to a login form twice a day.
|
||||
2. **Serialise refresh behind one lock.** The refresh token is single use, so
|
||||
four screens polling at once would each spend it and three would lose,
|
||||
logging the user out at random.
|
||||
3. **Persist the rotated tokens before doing anything else.** A client that
|
||||
refreshes and is then killed comes back holding a token the server has
|
||||
already invalidated — indistinguishable from a normal expiry, at the worst
|
||||
possible moment.
|
||||
|
||||
Marshal the request body **before** the first attempt: a retry has to send it
|
||||
again, and a stream is spent after the first read.
|
||||
|
||||
### `POST /api/auth/logout` · `GET /api/auth/me`
|
||||
|
||||
Logout revokes the calling session. `me` returns the `user` object above.
|
||||
|
||||
---
|
||||
|
||||
## 2. Joining — how somebody gets an account
|
||||
|
||||
There is **no open registration**, by design. A manager or owner mints a code
|
||||
and hands it over; the holder chooses their own password.
|
||||
|
||||
### `POST /api/team/invitations` — manager or owner
|
||||
|
||||
```json
|
||||
{ "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager",
|
||||
"expires_in_days": 7 }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "id": "…", "email": "arjun@tenext.in", "role": "manager",
|
||||
"code": "LQOUHR-AYYTPE-7Q756N-PGAAN6",
|
||||
"expires_at": "…", "created_at": "…" }
|
||||
```
|
||||
|
||||
**`code` is returned exactly once and is not recoverable.** Only a hash is
|
||||
stored. Show it immediately; do not expect to read it back.
|
||||
|
||||
`role` is `staff`, `manager` or `owner`. Only an owner may mint an owner.
|
||||
`admin` is not accepted at all.
|
||||
|
||||
### `GET /api/auth/invitation?code=…` — **no auth**
|
||||
|
||||
```json
|
||||
{ "client_name": "TeNext Retail", "email": "arjun@tenext.in",
|
||||
"full_name": "Arjun", "role": "manager" }
|
||||
```
|
||||
|
||||
Call this before asking anyone to choose a password, so the screen can say what
|
||||
they are joining and a mistyped code is caught early. Unknown, expired, spent
|
||||
and withdrawn all return **404 `invalid_code`** with one message.
|
||||
|
||||
### `POST /api/auth/register` — **no auth**
|
||||
|
||||
```json
|
||||
{ "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6",
|
||||
"full_name": "Arjun", "password": "…", "device": "Pixel 8" }
|
||||
```
|
||||
|
||||
Returns **201** and a full session — the same shape as login. Sign the person
|
||||
straight in; do not send them to a login form.
|
||||
|
||||
**Do not send `email` or `role`.** They come from the invitation, and the
|
||||
request is rejected outright if it names either. That is what stops a forwarded
|
||||
code becoming somebody else's account, or a staff invitation being redeemed as
|
||||
an owner.
|
||||
|
||||
Dashes and case in the code are ignored. A rejected attempt (short password,
|
||||
wrong code) does **not** spend the invitation.
|
||||
|
||||
| status | `error` |
|
||||
|---|---|
|
||||
| 400 | password under 8 characters, or a body naming `email`/`role` |
|
||||
| 404 | `invalid_code` |
|
||||
| 409 | `conflict` — that address already has an account; sign in instead |
|
||||
|
||||
### `GET` / `DELETE /api/team/invitations[/{id}]` — manager or owner
|
||||
|
||||
List what is still pending, or withdraw one before it is used.
|
||||
|
||||
---
|
||||
|
||||
## 3. Devices
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /api/auth/sessions` | this account's signed-in devices |
|
||||
| `DELETE /api/auth/sessions/{id}` | sign one out, immediately |
|
||||
| `POST /api/auth/sessions/revoke-others` | sign out everywhere else |
|
||||
|
||||
```json
|
||||
[{ "id": "…", "device": "Pixel 8", "created_at": "…",
|
||||
"last_used_at": "…", "expires_at": "…", "current": true }]
|
||||
```
|
||||
|
||||
`current` marks the session making the request — label it, and warn before
|
||||
somebody signs out the device in their hand. `revoke-others` deliberately keeps
|
||||
the caller's own session.
|
||||
|
||||
A person can revoke only their own sessions. To remove a colleague's access,
|
||||
deactivate them (below); that revokes every session they hold.
|
||||
|
||||
---
|
||||
|
||||
## 4. The team
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /api/team` | everybody in this company |
|
||||
| `PATCH /api/team/{id}` | `{"role": "manager"}` and/or `{"active": false}` |
|
||||
|
||||
Deactivating signs that person out **immediately** and stops them signing back
|
||||
in. Reactivating restores the account but not their old sessions.
|
||||
|
||||
409 `last_owner` if the change would leave the company with no active owner.
|
||||
|
||||
---
|
||||
|
||||
## 5. Who just walked in — the screen a mobile app is for
|
||||
|
||||
### `GET /api/visits`
|
||||
|
||||
`?limit=50&cursor=…&site=…` (`site_id` also accepted; a slug or a uuid)
|
||||
|
||||
```json
|
||||
{
|
||||
"arrivals": [{
|
||||
"visit_id": "…",
|
||||
"occurred_at": "2026-09-05T06:01:45Z",
|
||||
"site_id": "…", "site": "TeNext Chennai", "site_slug": "chennai",
|
||||
"camera_id": "Office1",
|
||||
"visitor_id": "…", "visitor_ref": "V-42", "label": "Priya",
|
||||
"is_new_visitor": false, "similarity": 0.71, "quality": 0.66,
|
||||
"attributes": { "gender": "Male", "age": 32, "emotion": "neutral" },
|
||||
"image": { "available": true,
|
||||
"url": "/api/faces/8e7d3d7a-….jpg", "auth": true }
|
||||
}],
|
||||
"cursor": "djE6NDEy",
|
||||
"polled_at": "…"
|
||||
}
|
||||
```
|
||||
|
||||
**Echo `cursor` back on every poll.** It is opaque and it is the only thing that
|
||||
makes the feed lossless: a burst larger than `limit` leaves rows behind, and
|
||||
polling by timestamp alone would skip them permanently. Rows are **ascending**,
|
||||
so the last row's position is your new cursor — which the response already gives
|
||||
you. A cursor that fails to parse means the format changed; drop it and poll
|
||||
again without one.
|
||||
|
||||
An empty poll returns your own cursor back, not an empty string.
|
||||
|
||||
**There is no `seq` on the wire.** It existed as a convenience for "have I
|
||||
fallen behind"; `visits.seq` is a plain bigserial, so it counted every visit on
|
||||
the *platform* and put the total footfall of every customer we have on every row
|
||||
of every tenant's feed. The cursor — opaque and version-prefixed — is the
|
||||
supported way to know your position, and the only one you need.
|
||||
|
||||
Of the three ids on an arrival, only one of them is a reference you would type:
|
||||
`site_slug`. `visit_id` addresses no route — it is a key for de-duplicating
|
||||
rows, since delivery is at-least-once. And the uuid inside an image URL is
|
||||
**deliberately random**: a derived or sequential one would let somebody
|
||||
enumerate a shop's customers by date.
|
||||
|
||||
### `GET /api/visits/stream` — server-sent events
|
||||
|
||||
The same rows, pushed. Send `Authorization` (so `EventSource` will not do —
|
||||
read the stream with an HTTP client) and resume with `Last-Event-ID` or
|
||||
`?cursor=`. Falls back to polling cleanly; the failure mode is latency, never
|
||||
silence.
|
||||
|
||||
---
|
||||
|
||||
## 6. Photos
|
||||
|
||||
**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. Show
|
||||
initials or a placeholder — a screen of red for a system working as configured
|
||||
is a screen whose real errors get ignored.
|
||||
|
||||
```json
|
||||
"image": { "available": false,
|
||||
"reason": "This system is not storing customer photos." }
|
||||
```
|
||||
|
||||
When a photo **is** available there are two kinds of URL, and the `auth` flag is
|
||||
how you tell them apart. Do not infer it from the shape of the URL.
|
||||
|
||||
| | `auth` | how to load it |
|
||||
|---|---|---|
|
||||
| presigned object-storage link | absent/false | use it directly; it carries its own signature and expires in `expires_in` seconds |
|
||||
| served by this API | `true` | send `Authorization: Bearer …` |
|
||||
|
||||
- **Mobile**: an image view can attach the header —
|
||||
`Image source={{ uri, headers: { Authorization: 'Bearer …' } }}`.
|
||||
- **Web**: an `<img>` cannot. Fetch it and use an object URL
|
||||
(`URL.createObjectURL`), and **revoke it** on unmount — a screen left open all
|
||||
afternoon otherwise holds hundreds of copies of the same photograph.
|
||||
|
||||
Prefix a relative URL with the base URL. Treat any relative URL as needing auth
|
||||
whether or not the flag is set: there is no public one.
|
||||
|
||||
### `GET /api/visitors/{id}/image`
|
||||
|
||||
The same `image` object for one customer's latest photo. 404 `no_image` (nothing
|
||||
captured) or 404 `images_disabled` (this deployment stores none) — two different
|
||||
absences, because a shop can act on one and not the other.
|
||||
|
||||
Every hand-out of a photo link is written to the audit log. **Fetch it once per
|
||||
screen**, not once per component: two components asking for the same face put
|
||||
two rows in *"who looked at my customers"* for one glance at one person.
|
||||
|
||||
---
|
||||
|
||||
## 7. Customers
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /api/visitors?q=…` | search by name, phone, or customer number (`42`, `V-42`) |
|
||||
| `GET /api/visitors/{id}/history` | their past visits |
|
||||
| `PUT /api/visitors/{id}/profile` | name, phone, notes — staff and above |
|
||||
| `DELETE /api/visitors/{id}` | **erasure** — manager and above |
|
||||
| `POST /api/purchases` | link a sale to a visit |
|
||||
|
||||
`{id}` is a uuid **or** `V-42` **or** `42`. Every customer object carries `ref`
|
||||
("V-42") beside `id`, and `label` reads "Visitor 42" until somebody names them.
|
||||
|
||||
Erasure destroys the face template and the photo outright and keeps the visit
|
||||
rows, unlinked. It is irreversible. If the photo cannot be deleted the whole
|
||||
request fails with **502** and *nothing* is erased — so an error there means the
|
||||
data is still there, and must be reported as a failure, never swallowed.
|
||||
|
||||
---
|
||||
|
||||
## 8. Shops, cameras, reports
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /api/sites` | estate health: online, cameras up, `fraction_below_gate` |
|
||||
| `GET /api/sites/{id}/check` | five-step smoke test for one shop |
|
||||
| `GET /api/cameras` | cameras and their latest still |
|
||||
| `GET /api/cameras/{id}/live` | live view relayed from the shop PC (SSE) |
|
||||
| `GET /api/reports/footfall` | `?from=&to=&site=&tz=&bucket=` |
|
||||
| `GET /api/reports/conversion` | same parameters; revenue and basket size |
|
||||
|
||||
**`site` and `site_id` are both accepted everywhere**, and either may be a slug
|
||||
or a uuid. They used to differ per endpoint, which mattered because an unknown
|
||||
query parameter is silently ignored — so getting it the wrong way round returned
|
||||
the whole estate instead of an error. `site` is the documented spelling.
|
||||
|
||||
Dates are `YYYY-MM-DD`. `to` is **inclusive**: "1st to the 7th" includes the
|
||||
7th.
|
||||
|
||||
Two arithmetic traps the API is explicit about, so a client does not reinvent
|
||||
them wrongly:
|
||||
|
||||
- **`total` is unique people over the window; the chart does not sum to it.**
|
||||
Somebody who came Monday and Thursday is one person and two bucket-visitors.
|
||||
Show the server's `total`, with `visits` underneath.
|
||||
- **`new + returning` can be less than the total.** A site sending counts
|
||||
without templates records real footfall by an unidentified person, which
|
||||
belongs to neither.
|
||||
|
||||
Report buckets are **local wall time with no offset**, labelled by `timezone`.
|
||||
Do not parse them as a `Date` — the viewer's own zone would shift every label.
|
||||
|
||||
---
|
||||
|
||||
## 9. Errors
|
||||
|
||||
```json
|
||||
{ "error": "invalid_code", "message": "That invitation code is not valid. Ask for a new one." }
|
||||
```
|
||||
|
||||
`message` is written to be shown to a person; prefer it over inventing your own.
|
||||
`error` is the stable code to branch on — **never match on the prose**, which is
|
||||
rewritten freely.
|
||||
|
||||
| status | meaning |
|
||||
|---|---|
|
||||
| 400 | the request was wrong; `message` says how |
|
||||
| 401 | not signed in, or `token_expired` → refresh once and retry |
|
||||
| 403 | signed in, but this role may not |
|
||||
| 404 | not found — **also** what another tenant's data returns, always |
|
||||
| 409 | a conflict `message` explains (`last_owner`, duplicate address) |
|
||||
| 429 | throttled |
|
||||
| 501 | the feature is off for this deployment, not an error |
|
||||
| 502 | a downstream failure; for erasure it means **nothing was deleted** |
|
||||
|
||||
Roles, in increasing order: `staff` → `manager` → `owner`. A platform admin has
|
||||
`role: "admin"` **and an empty `client_id`** — the two together, never the role
|
||||
alone.
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
@@ -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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,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 +342,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")
|
||||
|
||||
@@ -47,6 +47,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 +114,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),
|
||||
}
|
||||
|
||||
@@ -229,6 +229,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)
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
|
||||
@@ -9,6 +9,7 @@ package cloud
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -393,6 +394,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 +419,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 +545,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"`
|
||||
|
||||
@@ -42,6 +42,35 @@ 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)
|
||||
|
||||
// --- 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 +94,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 +127,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 +160,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 +230,26 @@ 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("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 +262,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 +304,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 +492,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,384 @@ 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
390
server/internal/api/handlers_team.go
Normal file
390
server/internal/api/handlers_team.go
Normal file
@@ -0,0 +1,390 @@
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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,100 @@ 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"`
|
||||
}
|
||||
|
||||
// ==================================================== 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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,9 +63,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
|
||||
}
|
||||
261
server/internal/store/api_faces_live_test.go
Normal file
261
server/internal/store/api_faces_live_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
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)
|
||||
}
|
||||
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()
|
||||
}
|
||||
347
server/internal/store/api_team.go
Normal file
347
server/internal/store/api_team.go
Normal file
@@ -0,0 +1,347 @@
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)) == ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,6 +55,11 @@ export default function Customers({ user }) {
|
||||
onKeyDown={e => e.key === 'Enter' && setOpen(c)}>
|
||||
<td>
|
||||
<strong>{c.full_name || c.label}</strong>
|
||||
{/* The number is shown next to a name a human typed, and
|
||||
not next to the auto label, which already IS the
|
||||
number ("Visitor 13"). Printing "Visitor 13 · V-13"
|
||||
would read as two identifiers for one person. */}
|
||||
{c.full_name && c.ref && <span className="sub"> · {c.ref}</span>}
|
||||
{c.phone && <span className="sub"> · {c.phone}</span>}
|
||||
</td>
|
||||
<td className="num">{c.visit_count}</td>
|
||||
@@ -118,6 +123,8 @@ function Drawer({ customer, user, onClose, onSaved }) {
|
||||
<header className="drawer-head">
|
||||
<div>
|
||||
<h2>{customer.full_name || customer.label}</h2>
|
||||
{customer.full_name && customer.ref &&
|
||||
<p className="sub">{customer.ref}</p>}
|
||||
<p className="sub">{customer.visit_count} visits · last seen {ago(customer.last_seen_at)}</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={onClose}>Close</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, streamArrivals } from '../api.js'
|
||||
import { ago, Loading, Problem } from './Sites.jsx'
|
||||
import Shot from './Shot.jsx'
|
||||
|
||||
// Who just walked in, pushed as it happens.
|
||||
//
|
||||
@@ -85,10 +86,14 @@ function Arrival({ a }) {
|
||||
const name = a.name || a.label || 'Unidentified'
|
||||
return (
|
||||
<li className="card arrival">
|
||||
<Face image={a.image} name={name} />
|
||||
<Face image={a.image} name={name} customerRef={a.visitor_ref} />
|
||||
<div className="who-col">
|
||||
<strong>{name}</strong>
|
||||
<span className="sub">
|
||||
{/* Only beside a name somebody typed. The auto label already IS the
|
||||
number, so "Visitor 13 · V-13" would read as two identifiers for
|
||||
one person. */}
|
||||
{a.name && a.visitor_ref ? `${a.visitor_ref} · ` : ''}
|
||||
{a.site}{a.camera_id ? ` · ${a.camera_id}` : ''} · {ago(a.occurred_at)}
|
||||
</span>
|
||||
{a.attributes && <Attributes attrs={a.attributes} />}
|
||||
@@ -103,17 +108,44 @@ function Arrival({ a }) {
|
||||
// Photos are off unless a shop turns them on, so "no photo" is the ordinary
|
||||
// case. Initials, never an error state — a screen full of red for a system
|
||||
// working exactly as configured teaches people to ignore it.
|
||||
function Face({ image, name }) {
|
||||
//
|
||||
// The picture goes through Shot rather than a bare <img> because a face can
|
||||
// now come from either of two places: a presigned link to object storage, or
|
||||
// this server's own database on a deployment with no bucket. The second cannot
|
||||
// be loaded by an <img> at all — it needs the session — so a plain src here
|
||||
// showed a broken image on exactly the deployments that had just started
|
||||
// storing photos.
|
||||
// customerRef, not `ref`: React reserves that prop name, so it would never
|
||||
// reach this component's props.
|
||||
function Face({ image, name, customerRef }) {
|
||||
if (image?.available) {
|
||||
return <img className="face" src={image.url} alt="" loading="lazy" />
|
||||
return <span className="face"><Shot image={image} alt="" /></span>
|
||||
}
|
||||
return (
|
||||
<span className="face initials" title={image?.reason || ''} aria-hidden="true">
|
||||
{initials(name)}
|
||||
{avatarText(name, customerRef)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// What goes in the circle when there is no photograph.
|
||||
//
|
||||
// Initials of a name a human typed; the NUMBER for a customer the system named
|
||||
// itself. Running initials() over "Visitor 13" takes the first letter of each
|
||||
// word and produces "V1" - which is also what "Visitor 10" and "Visitor 15"
|
||||
// produce, so three different people wore the same badge, and it read as the
|
||||
// V-1 reference for a fourth. Found by opening the page: every unit test here
|
||||
// passes a human name.
|
||||
function avatarText(name, customerRef) {
|
||||
const auto = /^Visitor (\d+)$/.exec(String(name).trim())
|
||||
if (auto) return auto[1]
|
||||
if (customerRef) {
|
||||
const n = /^V-(\d+)$/.exec(customerRef)
|
||||
if (n) return n[1]
|
||||
}
|
||||
return initials(name)
|
||||
}
|
||||
|
||||
function initials(name) {
|
||||
const parts = String(name).trim().split(/\s+/).filter(Boolean)
|
||||
if (!parts.length) return '?'
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
// Sign in, or join with an invitation.
|
||||
//
|
||||
// Both live on this screen because they answer the same question — "let me in"
|
||||
// — and the person arriving with a code has no account yet, so they cannot be
|
||||
// asked to sign in first. That is the same reason the endpoint behind it is
|
||||
// unauthenticated, and the same reason a shop PC claims itself before anybody
|
||||
// signs in on it.
|
||||
export default function Login({ onSignedIn }) {
|
||||
const [joining, setJoining] = useState(false)
|
||||
return joining
|
||||
? <Join onSignedIn={onSignedIn} onCancel={() => setJoining(false)} />
|
||||
: <SignIn onSignedIn={onSignedIn} onJoin={() => setJoining(true)} />
|
||||
}
|
||||
|
||||
function SignIn({ onSignedIn, onJoin }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -51,7 +65,122 @@ export default function Login({ onSignedIn }) {
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
<p className="foot">
|
||||
Accounts are created by Loyaly. Ask your account manager if you need one.
|
||||
Been invited? <button type="button" className="linkish" onClick={onJoin}>
|
||||
Use your invitation code
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Redeeming an invitation.
|
||||
//
|
||||
// Two steps deliberately. The code is checked FIRST, so somebody who has
|
||||
// mistyped it finds out before choosing a password — and so the screen can say
|
||||
// which company they are joining, which is the only thing that makes "is this
|
||||
// the right code" answerable by the person holding it.
|
||||
function Join({ onSignedIn, onCancel }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [invite, setInvite] = useState(null)
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const check = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const prev = await api.previewInvitation(code.trim())
|
||||
setInvite(prev)
|
||||
setFullName(prev.full_name || '')
|
||||
} catch (err) {
|
||||
// Unknown, expired, spent and withdrawn are one message from the server.
|
||||
// The difference only helps somebody guessing codes, and the next step is
|
||||
// the same in all four cases: ask for a new one.
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const join = async (e) => {
|
||||
e.preventDefault()
|
||||
if (password !== confirm) {
|
||||
setError('Those two passwords are not the same.')
|
||||
return
|
||||
}
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
// The address and the role are not sent. They belong to the invitation.
|
||||
const user = await api.register({ code: code.trim(), full_name: fullName, password })
|
||||
onSignedIn(user)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="signin">
|
||||
<form className="card" onSubmit={invite ? join : check}>
|
||||
<span className="mark big" aria-hidden="true" />
|
||||
<h1>{invite ? `Join ${invite.client_name}` : 'Behavision'}</h1>
|
||||
|
||||
{!invite ? (
|
||||
<>
|
||||
<p className="sub">Enter the invitation code you were given.</p>
|
||||
<label>
|
||||
Invitation code
|
||||
<input
|
||||
value={code} onChange={e => setCode(e.target.value)}
|
||||
autoFocus required autoComplete="off" spellCheck="false"
|
||||
placeholder="ABCDEF-123456-GHIJKL-789012" className="codefield"
|
||||
/>
|
||||
<span className="hint">Dashes and capitals do not matter.</span>
|
||||
</label>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<button className="primary" disabled={busy || !code.trim()}>
|
||||
{busy ? 'Checking…' : 'Continue'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="sub">
|
||||
You are joining as <b>{invite.role}</b>, signing in with{' '}
|
||||
<code>{invite.email}</code>. Choose a password only you know.
|
||||
</p>
|
||||
<label>
|
||||
Your name
|
||||
<input value={fullName} onChange={e => setFullName(e.target.value)}
|
||||
autoFocus autoComplete="name" />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" value={password} required minLength={8}
|
||||
autoComplete="new-password"
|
||||
onChange={e => setPassword(e.target.value)} />
|
||||
<span className="hint">At least 8 characters. Longer is the only thing that helps.</span>
|
||||
</label>
|
||||
<label>
|
||||
Password again
|
||||
<input type="password" value={confirm} required
|
||||
autoComplete="new-password"
|
||||
onChange={e => setConfirm(e.target.value)} />
|
||||
</label>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<button className="primary" disabled={busy || !password || !confirm}>
|
||||
{busy ? 'Creating your account…' : 'Create account and sign in'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="foot">
|
||||
<button type="button" className="linkish" onClick={onCancel}>
|
||||
Back to sign in
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
45
web/src/views/Shot.jsx
Normal file
45
web/src/views/Shot.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useAuthedImage } from '../hooks.js'
|
||||
|
||||
// One picture from the API, however this deployment stores them.
|
||||
//
|
||||
// Two shapes arrive here and they need different handling, which is exactly
|
||||
// why it is one component rather than an <img> repeated on each screen:
|
||||
//
|
||||
// * A presigned link to object storage carries its own signature, so a plain
|
||||
// <img src> loads it.
|
||||
// * A picture this server holds itself - for a deployment with no bucket -
|
||||
// is served from an endpoint that requires the session. An <img> cannot
|
||||
// send an Authorization header, so it has to be fetched and handed over as
|
||||
// an object URL. Minting an unauthenticated link instead would put a
|
||||
// photograph of somebody's shop floor, or of a customer, behind no session
|
||||
// at all, which is the thing that path exists to avoid.
|
||||
//
|
||||
// Which one it is comes from the API's own `auth` flag, not from the shape of
|
||||
// the URL. Guessing by whether it starts with "/" is right today and stops
|
||||
// being right the first time object storage is served from this same host -
|
||||
// and the failure then is a photograph that silently will not load.
|
||||
export default function Shot({ image, url, alt }) {
|
||||
// `image` is the whole object from the API; `url` is the older call shape,
|
||||
// kept working so a screen that has not been updated still renders. The
|
||||
// fallback heuristic applies only when nothing told us.
|
||||
const src0 = image ? image.url : url
|
||||
// Either signal is enough, and that is not belt-and-braces. A RELATIVE url is
|
||||
// served by this server and always needs the session - there is no such thing
|
||||
// as a public one - so it is sufficient on its own, and a caller that rebuilds
|
||||
// an image object and loses `auth` cannot turn a working picture into a broken
|
||||
// one. (It did exactly that once: Sites.jsx returned `{url, at}` from its
|
||||
// snapshot picker, the flag went missing, and every shop card showed a broken
|
||||
// image.) The FLAG is what adds the case the URL cannot express: an absolute
|
||||
// link that still needs a bearer, which happens the first time object storage
|
||||
// is served from this same host.
|
||||
const needsAuth =
|
||||
(image && !!image.auth) ||
|
||||
(typeof src0 === 'string' && src0.startsWith('/'))
|
||||
|
||||
// Hooks cannot be called conditionally, so this always runs and simply has
|
||||
// nothing to do when the URL is already usable.
|
||||
const fetched = useAuthedImage(needsAuth ? src0 : null)
|
||||
const src = needsAuth ? fetched : src0
|
||||
if (!src) return null
|
||||
return <img src={src} alt={alt} loading="lazy" />
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import Shot from './Shot.jsx'
|
||||
import SiteCheck from './SiteCheck.jsx'
|
||||
|
||||
// The estate at a glance.
|
||||
@@ -134,7 +135,7 @@ function SiteCard({ site, cams, verdict, onCheck }) {
|
||||
tabIndex={0} onKeyDown={e => e.key === 'Enter' && onCheck()}>
|
||||
<div className="shot">
|
||||
{view.url
|
||||
? <img src={view.url} alt={`View inside ${site.name}`} loading="lazy" />
|
||||
? <Shot image={view} alt={`View inside ${site.name}`} />
|
||||
: <div className="noshot">
|
||||
<ShopMark />
|
||||
{view.reason && <span>{view.reason}</span>}
|
||||
@@ -204,7 +205,11 @@ function bestView(cams) {
|
||||
if (!c.snapshot?.available || !c.snapshot.url) continue
|
||||
if (!best || (c.snapshot_at || '') > (best.snapshot_at || '')) best = c
|
||||
}
|
||||
if (best) return { url: best.snapshot.url, at: best.snapshot_at }
|
||||
// The WHOLE snapshot object, not just its url. It carries `auth`, which says
|
||||
// whether the picture has to be fetched with the session or can be handed
|
||||
// straight to an <img> - and rebuilding a partial copy here is how that flag
|
||||
// gets silently dropped on one screen and not another.
|
||||
if (best) return { ...best.snapshot, at: best.snapshot_at }
|
||||
const reason = cams.map(c => c.snapshot?.reason).find(Boolean)
|
||||
return { reason: reason || 'No picture from this shop yet.' }
|
||||
}
|
||||
@@ -236,6 +241,25 @@ export function ago(iso) {
|
||||
return `${Math.round(hrs / 24)} days ago`
|
||||
}
|
||||
|
||||
// How long until a moment in the future.
|
||||
//
|
||||
// `ago` clamps at zero and reads a future timestamp as "just now", which is
|
||||
// right for a heartbeat whose clock is a little ahead and completely wrong for
|
||||
// an expiry: a code valid for a week rendered as "expires just now", which
|
||||
// tells the operator not to bother handing it over.
|
||||
export function until(iso) {
|
||||
if (!iso) return 'never'
|
||||
const then = new Date(iso).getTime()
|
||||
if (Number.isNaN(then)) return '—'
|
||||
const secs = (then - Date.now()) / 1000
|
||||
if (secs <= 0) return 'expired'
|
||||
const mins = Math.round(secs / 60)
|
||||
if (mins < 60) return `in ${mins} min`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 48) return `in ${hrs} h`
|
||||
return `in ${Math.round(hrs / 24)} days`
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
return <div className="state"><span className="spinner" aria-hidden="true" />Loading…</div>
|
||||
}
|
||||
|
||||
214
web/src/views/Team.jsx
Normal file
214
web/src/views/Team.jsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import { ago, until, Loading, Problem } from './Sites.jsx'
|
||||
|
||||
// The people who work here, and how somebody new gets an account.
|
||||
//
|
||||
// Registration is by invitation, never open signup — the same line the platform
|
||||
// draws around creating a company. What was missing was not openness: it was
|
||||
// that a shop could not add a SECOND person at all without somebody running a
|
||||
// command on the server, so five members of staff shared one password and a
|
||||
// phone app for the shop floor could not exist.
|
||||
//
|
||||
// A manager mints a code and hands it over; the holder chooses their own
|
||||
// password. The code carries the address and the role, so passing it on cannot
|
||||
// turn a staff invitation into an owner account for whoever received it.
|
||||
export default function Team({ user }) {
|
||||
const team = usePolled(() => api.team(), 0, [])
|
||||
const invites = usePolled(() => api.invitations(), 0, [])
|
||||
const [inviting, setInviting] = useState(false)
|
||||
const [minted, setMinted] = useState(null)
|
||||
const [busy, setBusy] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const canManage = user.role === 'owner' || user.role === 'manager'
|
||||
const members = team.data || []
|
||||
const pending = invites.data || []
|
||||
|
||||
const change = async (id, changes) => {
|
||||
setBusy(id); setError('')
|
||||
try {
|
||||
await api.updateMember(id, changes)
|
||||
team.reload()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="head">
|
||||
<h1>Team</h1>
|
||||
{canManage && (
|
||||
<button className="primary" onClick={() => { setMinted(null); setInviting(true) }}>
|
||||
Invite someone
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{minted && <InviteCode invite={minted} onDismiss={() => setMinted(null)} />}
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
|
||||
{team.loading && !team.data ? <Loading /> :
|
||||
team.error ? <Problem error={team.error} /> : (
|
||||
<div className="tablewrap">
|
||||
<table className="rows">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Email</th><th>Role</th><th>Last signed in</th>
|
||||
{canManage && <th />}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map(m => (
|
||||
<tr key={m.id} className={m.active ? '' : 'inactive'}>
|
||||
<td><strong>{m.full_name || '—'}</strong>
|
||||
{!m.active && <span className="pill muted">No access</span>}</td>
|
||||
<td><code>{m.email}</code></td>
|
||||
<td>{canManage && m.id !== user.id ? (
|
||||
<select value={m.role} disabled={busy === m.id}
|
||||
onChange={e => change(m.id, { role: e.target.value })}>
|
||||
{/* 'admin' is absent: a platform administrator is
|
||||
defined by having no company, so the role could
|
||||
never work on a row that has one. */}
|
||||
<option value="staff">Staff</option>
|
||||
<option value="manager">Manager</option>
|
||||
{user.role === 'owner' && <option value="owner">Owner</option>}
|
||||
</select>
|
||||
) : <span className="role">{m.role}</span>}</td>
|
||||
<td className="sub">{m.last_login_at ? ago(m.last_login_at) : 'Never'}</td>
|
||||
{canManage && (
|
||||
<td className="right">
|
||||
{m.id === user.id ? null : m.active ? (
|
||||
<button className="ghost danger" disabled={busy === m.id}
|
||||
onClick={() => change(m.id, { active: false })}>
|
||||
Remove access
|
||||
</button>
|
||||
) : (
|
||||
<button className="ghost" disabled={busy === m.id}
|
||||
onClick={() => change(m.id, { active: true })}>
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManage && pending.length > 0 && (
|
||||
<section className="pending">
|
||||
<h2>Waiting to join</h2>
|
||||
<ul className="invites">
|
||||
{pending.map(i => (
|
||||
<li key={i.id} className="card invite">
|
||||
<div>
|
||||
<strong>{i.email}</strong>
|
||||
<span className="sub">
|
||||
invited as {i.role}
|
||||
{i.invited_by ? ` by ${i.invited_by}` : ''} · expires {until(i.expires_at)}
|
||||
</span>
|
||||
</div>
|
||||
<button className="ghost danger" onClick={async () => {
|
||||
await api.revokeInvitation(i.id); invites.reload()
|
||||
}}>Withdraw</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{inviting && (
|
||||
<InviteForm
|
||||
canMintOwner={user.role === 'owner'}
|
||||
onClose={() => setInviting(false)}
|
||||
onDone={(inv) => { setInviting(false); setMinted(inv); invites.reload() }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InviteForm({ canMintOwner, onClose, onDone }) {
|
||||
const [form, setForm] = useState({ email: '', full_name: '', role: 'staff' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
onDone(await api.invite(form))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<aside className="drawer narrow" onClick={e => e.stopPropagation()}>
|
||||
<header className="drawer-head">
|
||||
<h2>Invite someone</h2>
|
||||
<button className="ghost" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
<form className="drawer-body" onSubmit={submit}>
|
||||
<label>Their email
|
||||
<input type="email" value={form.email} onChange={set('email')}
|
||||
required autoFocus autoComplete="off" name="invitee" />
|
||||
<span className="hint">
|
||||
This is the address they will sign in with, and it is fixed by the
|
||||
invitation — passing the code on cannot make it somebody else’s
|
||||
account.
|
||||
</span>
|
||||
</label>
|
||||
<label>Their name
|
||||
<input value={form.full_name} onChange={set('full_name')}
|
||||
autoComplete="off" name="invitee-name" />
|
||||
</label>
|
||||
<label>Role
|
||||
<select value={form.role} onChange={set('role')}>
|
||||
<option value="staff">Staff — see customers and shops</option>
|
||||
<option value="manager">Manager — also set up cameras and invite people</option>
|
||||
{canMintOwner && <option value="owner">Owner — full control</option>}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<button className="primary" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create invitation'}
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Shown once, and it says so. Only a hash is stored, so this cannot be read
|
||||
// back later — the same rule every other secret in this product follows, and
|
||||
// the reason is the same: a code support can look up is a code anybody with
|
||||
// support access can redeem.
|
||||
function InviteCode({ invite, onDismiss }) {
|
||||
return (
|
||||
<div className="banner ok credentials">
|
||||
<div>
|
||||
<b>Invitation for {invite.email}.</b> Give them this code. It is shown
|
||||
once, works once, and cannot be recovered.
|
||||
<dl className="creds">
|
||||
<div><dt>Code</dt><dd><code className="big">{invite.code}</code></dd></div>
|
||||
<div><dt>Role</dt><dd>{invite.role}</dd></div>
|
||||
</dl>
|
||||
<p className="sub">
|
||||
They open the app, choose “I have an invitation code”, and pick their
|
||||
own password. Nobody else ever sees it.
|
||||
</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={onDismiss}>Done</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user