signal-bot/DOCS-web-ui.md
Krzysztof kuhy Rudnicki da016e1b94
Some checks failed
CI / check (push) Has been cancelled
CI / web (push) Has been cancelled
!dodaj lands in a queue; the management page approves it into the album
A photo sent to the chat no longer enters the album by itself. pack_add
strips it, checks it against the album and the queue, and writes it to
pending/ with a sidecar naming the people and labels the sender proposed;
the reply is "czeka na zatwierdzenie". The management page lists the queue
above the tagger, with the proposal pre-ticked, and Approve moves the file
in, mints the labels, records approved_at and asks the bot to rebuild its
table through /_reload -- the same publish path every other data change
takes, so PackHolder.reload is gone. A reload the bot refuses is reported
on the page, not rolled back: the photo is in the album either way.

The personal-data gate now reads pending/*.json too, so a label that is
still waiting for approval is forbidden in the tree already. The e2e run
boots the management page beside the bot with the same environment,
proves the queue is stripped and never reaches media/, approves it through
the real routes, and has !losowe answer with the photo afterwards without
a restart; exiftool is found through media_strip.locate() there rather
than PATH, which lacks vendor_perl on Arch.

!gadaj is dropped from !odcisz: it was within a typo of !dodaj, and the
nearest-command suggestion must never be a coin toss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 22:55:39 +02:00

14 KiB

The management page

./web.sh serves one page on 127.0.0.1:8140: whether the bot is alive, what it is configured with, and the photo album's tagger. It replaces tool/tagger.py, which served the album half of this on the same port.

./web.sh                             # the configured pack
./web.sh --dir ~/pack --port 8140
./web.sh --import ~/photos           # dedup, strip and import, then serve
./web.sh --import ~/photos --dry-run # the report, changing nothing
./web.sh --untagged                  # only items with no tags yet

Why it is started by hand

A systemd unit would be more convenient and is the wrong shape. This page can rewrite .env — the file that decides which groups the bot answers in and who may add it to one — and a unit would keep that writable on loopback all day for the sake of a bookmark. Started by hand, the window is the few minutes somebody is actually looking at it.

It is also a second process, not more routes on the bot. The bot's own port already serves POST /_selftest, which posts for real into a real group; putting a configuration writer beside it would put both behind one mistake.

Why loopback is not the whole defence

Binding to 127.0.0.1 stops the network. It does not stop the browser already running on this machine, and two attacks reach a loopback port from an ordinary web page. signal_bot/manage/guard.py closes both, in about forty lines, with nothing to configure and no session to keep:

  • Cross-origin form posts. Any site can POST a <form> at 127.0.0.1:8140 without reading the reply. A form can only send application/x-www-form-urlencoded, multipart/form-data or text/plain, so every mutating request must declare application/json — which a form cannot. Anything else is 415.
  • DNS rebinding. A hostname the attacker controls is re-pointed at 127.0.0.1 after the page loads, which makes the browser treat this app as same-origin. The rebound request still carries the attacker's hostname, so a Host that is not loopback is 421.

Neither is authentication, and neither pretends to be. They are the two cheapest things that make "only this machine" mean it.

Editing .env

Three refusals, in this order, and the order is the point.

  1. A key that may not be written is refused before anything is parsed, so a read-only key cannot be smuggled past by making the rest of the file invalid. 403, naming the key.
  2. The whole candidate file is validated by the function startup uses — load_config(candidate, use_dotenv=False). An edit this bot would refuse at boot is refused at the form instead, with the journal's own sentence. 400.
  3. Only then is anything written. A rejected edit leaves .env byte-identical.

The write itself is line-by-line: only the KEY= line of a key that actually changed is replaced, so the paragraphs explaining each setting survive an edit made in a browser. A key the file never had is appended under a marker rather than dropped among comments that explain something else. Temp-then-replace with mode 0600 reapplied before the swap, because this file holds the account's number and is edited from a tab that can be closed mid-save.

What is read-only, and why

Key Why the page will not write it
PHONE_NUMBER Identifies the account; the container holds the matching registration.
API_BASE_URL Repointing it sends every outgoing message and attachment fetch elsewhere at the next restart. Strictly worse than changing the number.
DATABASE_PATH Repointing it silently orphans the whole statistical history.
PHOTO_PACK_DIR Cannot be validated. _photo_pack_dirs() reads os.environ directly rather than the mapping it is handed — deliberately, since that is what keeps the test suite away from a real album.
BIND_HOST / BIND_PORT Moving the bot's port from a page that proxies to it.

Group settings need a group listing

GROUP_IDS and SELFTEST_GROUPS are editable only while /api/groups has actually returned something. parse_group_ids checks syntax; startup also requires each listed group to be one the bot really joined, judged against a listing that was really read. With the container down, a syntactically valid write can name a group the next startup rejects — a scoping mistake turned into a unit that will not start. The page says so and leaves the rest editable.

Group ids are picked, never typed — GROUP_IDS and SELFTEST_GROUPS render as a <select> filled from /api/groups, for the same reason the tagger picked them: an id is 44 characters of base64.

SELFTEST_GROUPS is editable and worth understanding before you widen it. It is what keeps POST /_selftest fail-closed; every group named in it is a group the bot can be made to post arbitrary text into.

The album, in two halves

Which half you get is the album's own configured flag, as the tagger decided.

  • No people.json yet → the roster form. One person per line, aliases separated by commas; a line with an = is a combination, + for "both of them" and | for "either of them". Keys are reused for anyone already on the roster and otherwise handed out one past the highest in use — never the count, or a key deleted by hand would be reissued and inherit somebody else's photos (pack_people.next_key is the authority). Names are typed into the page, never passed as arguments: an argument lands in shell history.
  • Configured → the tagger: the item, a checkbox per person, saved on the click rather than on a "done" button, and an "add somebody" box for the usual case of noticing a new face in the photo already on screen. Above it, only while something is in it, the !dodaj queue: one card per photo with the sender's people and labels pre-ticked, Approve or Reject — the only door into the album from the chat (DOCS-add-photo.md).

--untagged is a view filter, never a shorter list — /api/album/media and the tag route both index the full album, so filtering server-side would show item i of the album while tagging item i of the filtered set. Anything seen this session stays navigable even once it has tags, or tagging the photo you are looking at would remove it from the list you are walking.

Analytics

The first tab: what the group actually asks the bot for. Read-only, and the only thing on this page that is worth nothing to an attacker and everything to a maintainer -- so there is deliberately no write route under it.

?group= is optional on every route. Absent means the all-groups roll-up, and it runs the same SQL rather than a second query that would drift.

Every panel carries the window it covers and the three source counts, because two of the three classes of row were reconstructed rather than observed. DOCS-analytics.md says what each class knows and what it does not.

Charts are Nivo, always the fixed-size components: the Responsive* ones measure their container, and a jsdom container has none, so the whole test suite would assert against empty divs.

Commands

The second tab is the switchboard: one row per command the bot is serving right now, each with an on/off switch, an optional "off until" moment and a cooldown that overrides the bot's default for that one command. Every row has its own Save, because a switch is thrown for one command at a time.

No switch goes through /_reload. The bot keeps them in its own SQLite (command_settings) and reads the row on every attempt, so a switch is live on the next message -- DOCS-reload.md is about the table, and a switch does not touch it. A command that is off answers BEEP BOOP !kot WYŁĄCZONE (with DO HH:MM for a timed switch) rather than staying silent, so nobody reports it broken; the refusal is filed under refused_off, counted by the Analytics tab with the other refusals, and does not start the cooldown.

The list is the bot's, fetched live, which is why the page says so when the bot is still connecting: there is no table to list until it has one. !cisza and !odcisz have no cooldown to override, so the field is disabled with "none" in it: a number typed there would be a lie the bot ignored.

The one thing in a row that does go through /_reload is the aliases field: the operator's own comma-separated trigger words for that command. They are data the table is built from, so PUT /api/aliases/{name} writes aliases_custom.json in the bot's state directory and then asks the bot to rebuild. Write and reload are one step on purpose: whenever the answer is anything but reloaded -- a word another command has, a name no command has, a bot still connecting -- the file is put back byte for byte and the bot's own sentence is shown next to the field, so what is on disk is always what the bot serves and a typo cannot poison every later reload. A missing ! is added, a word with a space is refused before any write, and an empty field removes the command's custom words.

Ideas

The fourth tab lists what the group has asked for: !zaproponuj notes with the text people wrote, and every unrecognised !word with a count and the messages it came in.

The Export button writes that to the state directory — $STATE_DIRECTORY, else $XDG_DATA_HOME/signal-bot/ — never into this repository. Two reasons, both about a public git tree that has leaked real names once: a file the group can write into would wedge check_personal_data the moment somebody's name landed in it, refusing every commit until a human edited it by hand; and the gate learns the names it refuses from the live database, so it catches anna and cannot catch !annna, which is what an unrecognised-command corpus is full of. So the export carries deliberate notes in full and unrecognised words as counts only. Copy what is worth keeping into the tree yourself.

Routes

Everything under /api. The page itself is served from web/dist at every other path, so a reload on a sub-view works.

Method Path What it does
GET /api/status Proxies the bot's /healthz. Unreachable is a state, never an error.
GET /api/groups The live listing, or [] when the container is down.
GET /api/config The resolved config. The number is cut to its last four digits.
GET / PUT /api/env Read and write .env, per the rules above.
POST /api/selftest Proxied to the bot verbatim, including its 403.
POST /api/restart-container docker restart, through signal_bot/external.py.
GET /api/album/state The album, the roster and the groups to pick from.
POST /api/album/tag Record who is in one file.
GET /api/album/summary What each command would answer with.
POST /api/album/setup Write the roster, then prove the bot could load it.
POST /api/album/person Add one person mid-session.
GET /api/album/media/{index} One item, by position.
GET /api/album/pending, …/pending/{name}/media The !dodaj queue, oldest first and naming no sender; one queued file, by the name the listing gave.
POST /api/album/pending/{name}/{approve,reject} Into the album with the ticked people and labels, then the bot reloads (a refused reload is reported, not rolled back); or deleted.
GET /api/suggestions Filed ideas and unrecognised-word tallies, bodies included.
GET /api/commands Proxies the bot's /_commands: the live table with each command's switches.
PUT /api/commands/{name} One command's switches, forwarded to the bot; its 404/503 come back unwrapped.
GET /api/aliases The operator's custom aliases, by command name; 409 if the file cannot be read.
PUT /api/aliases/{name} Writes the file and reloads the bot; anything but reloaded restores the file and passes the bot's reason on.
GET /api/analytics/groups Every group with a command history, for the picker.
GET /api/analytics/{overview,time,health,demand,behaviour,content} One per panel, all read-only, all taking an optional ?group=.
POST /api/suggestions/export Write those to a file outside the repository.

That last one is not a style choice. A filename supplied by a client is a path, and this directory holds private photographs; the route takes an int, so FastAPI refuses a name with 422 before any handler code runs.

Building it

dist/ is a build output and this repository does not commit those. scripts/install_service.sh builds it, and verifies the artefact rather than trusting the exit code. Until it has been built the page answers 503 with the command to run, because StaticFiles raises at construction on a missing directory — mounting it unconditionally would take the app down on a fresh clone, and in CI, where pytest never runs pnpm build.

pnpm --dir web install --frozen-lockfile
pnpm --dir web build
pnpm --dir web lint        # tsc --noEmit + eslint strictTypeChecked
pnpm --dir web coverage    # vitest, 100% with no exclude list

React 19, Vite 8, TypeScript 6.0.3, ESLint 10 with strictTypeChecked + stylisticTypeChecked + unicorn + sonarjs + perfectionist, Vitest 4 at 100% on all four metrics. TypeScript stays on 6.0.3 because typescript-eslint@8.68.0 declares typescript: ">=4.8.4 <6.1.0"; the pin is fleet-wide in ~/utils/dependency-freshness.allowlist.yaml and clears itself when the linter catches up. The compiler waits for the linter.

web/tsconfig.json is strict JSON, not JSONC: the check-json hook is generic and a commentable tsconfig is a convention, not a requirement. What the comments used to say: lib is ESNext while target stays ES2023, so iterator helpers are declared without being emitted for; and every strictness flag is on, noUncheckedIndexedAccess and exactOptionalPropertyTypes included.

There is no coverage exclude list, including for src/main.tsx. A coverage omit is a suppression, and "the DOM bootstrap is awkward to assert" is the same sentence as "this code is untested" — so the bootstrap is a shim thin enough to test in jsdom instead.