signal-bot/DOCS-web-ui.md
Krzysztof kuhy Rudnicki a24d0d5905
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
feat: one page to manage the bot, and the tagger folded into it
./web.sh serves health, the configuration and the photo album on
127.0.0.1:8140 -- the port tool/tagger.py vacates. React 19, Vite 8,
TypeScript 6.0.3, ESLint 10 with strictTypeChecked + stylisticTypeChecked +
unicorn + sonarjs + perfectionist, Vitest 4 at 100% on all four metrics.

No coverage exclude list, including for main.tsx. This repository treats a
coverage omit as 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, and the album view holds its file list apart
from the album so the update path stopped carrying a "what if it is null"
branch that cannot happen. An untestable branch is an untested one.

TypeScript stays at 6.0.3: typescript-eslint 8.68.0 declares
typescript >=4.8.4 <6.1.0, and the fleet-wide allowlist entry says the
compiler waits for the linter. The freshness gate then caught two pins the
reference repo was carrying stale -- eslint-plugin-unicorn 73 -> 74 and a
floating node-version: "24" in CI -- both bumped rather than allowlisted.

Traps the gates found, all of them real:
- .gitignore has a bare lib/, which matches at any depth, so web/src/lib/
  would have been silently untracked. The source lives in src/api/.
- CI's shellcheck glob was `run.sh scripts/*.sh`, so tag.sh was unlinted in
  CI for its whole life and web.sh would have inherited that. Now ./*.sh.
- tsconfig.json is strict JSON, not JSONC: check-json is generic and
  tsconfig being commentable is a convention, not a requirement.
- The personal-data gate refused an invented phone number in a test, which
  is exactly what it is for.

The album's warning that names a command moved with the command, and every
path that pointed at tool/tagger.py now points at ./web.sh. The historical
mentions stay: they explain why the code is shaped as it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 14:54:49 +02:00

7.8 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, but only while /api/groups has actually returned something. parse_group_ids checks syntax; startup additionally requires each listed group to be one the bot really joined, judged against a listing that was really read. So with the container down, a syntactically valid write can name a group the next startup rejects — turning a scoping mistake into a unit that will not start. The page says so and leaves the other keys editable.

Group ids are picked, never typed, 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.

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.

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 rather than the usual JSONC: this repository's check-json hook is generic and tsconfig being commentable is a convention, not a requirement. The two things 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, including noUncheckedIndexedAccess and exactOptionalPropertyTypes.

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.