mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 12:47:48 +01:00
./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
153 lines
7.8 KiB
Markdown
153 lines
7.8 KiB
Markdown
# 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.
|
|
|
|
```sh
|
|
./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`.
|
|
|
|
```sh
|
|
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.
|