diff --git a/signal_bot/manage/app.py b/signal_bot/manage/app.py new file mode 100644 index 0000000..aef2bcb --- /dev/null +++ b/signal_bot/manage/app.py @@ -0,0 +1,46 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The management application, assembled. + +A second process rather than more routes on the bot's own app. Two reasons, +and both are about blast radius: this one can rewrite ``.env``, and the bot's +port already serves ``/_selftest``, which posts for real into a real group. +Keeping them apart means the surface that can reconfigure the bot is only +listening while somebody has the page open. + +Order is load-bearing. The guard goes on first so it sees every request; the +API routers are registered next; the static catch-all is registered last, +because ``/{path:path}`` would otherwise shadow every route after it. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import FastAPI + +from signal_bot.manage import assets, guard, routes_actions, routes_album, routes_env, routes_status + +if TYPE_CHECKING: + from signal_bot.manage.deps import ManageDeps + +DEFAULT_CONTAINER = "signal-api" + + +def build_app(deps: ManageDeps, *, container: str = DEFAULT_CONTAINER) -> FastAPI: + """The whole page, bound to one bot and one album. + + Args: + deps: Everything the app is pointed at. + container: The docker container signal-cli runs in. + + Returns: + An application ready for uvicorn, or for a test client. + """ + app = FastAPI(title="signal-bot", docs_url=None, redoc_url=None, openapi_url=None) + guard.install(app, deps.port) + app.include_router(routes_status.router(deps)) + app.include_router(routes_env.router(deps)) + app.include_router(routes_actions.router(deps, container)) + app.include_router(routes_album.router(deps)) + assets.install(app, deps.dist_dir) + return app diff --git a/signal_bot/manage/assets.py b/signal_bot/manage/assets.py new file mode 100644 index 0000000..5ebc1d2 --- /dev/null +++ b/signal_bot/manage/assets.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Serving the built page, and saying so plainly when it is not built. + +``StaticFiles`` raises at construction when its directory is missing, so +mounting it unconditionally would make the whole app fail to start on a fresh +clone -- and in CI, where pytest runs without ever invoking ``pnpm build``. +The branch is therefore explicit: built, or a 503 that names the command to +run. "Not built yet" is a normal state with an obvious fix, and it deserves a +sentence rather than a traceback. + +No new dependency. Starlette's ``StaticFiles`` is anyio-based and needs no +``aiofiles``, which is verified rather than assumed: this repository's runtime +pins are exactly six packages and adding a seventh would trip the freshness +gate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from fastapi import FastAPI, Response, status +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles + +if TYPE_CHECKING: + from pathlib import Path + +INDEX: Final = "index.html" +ASSETS: Final = "assets" +BUILD_COMMAND: Final = "pnpm --dir web install && pnpm --dir web build" + +NOT_BUILT: Final = ( + "
The management page has not been built yet. Run:" + f"
{BUILD_COMMAND}"
+)
+
+
+def is_built(dist: Path) -> bool:
+ """Whether there is a built page to serve.
+
+ Args:
+ dist: The directory ``vite build`` writes into.
+
+ Returns:
+ True when both halves of a vite build are there. Both, because
+ mounting StaticFiles on a missing ``assets/`` raises exactly the way
+ a missing ``dist/`` does, and a half-built directory should answer
+ "not built" rather than take the app down with it.
+ """
+ return (dist / INDEX).is_file() and (dist / ASSETS).is_dir()
+
+
+def install(app: FastAPI, dist: Path) -> None:
+ """Serve the built page, or explain its absence, at every unclaimed path.
+
+ Registered last, after every API route, so the catch-all cannot shadow
+ them. The catch-all exists because the page routes client-side: a reload
+ on a sub-view asks this server for a path only the browser knows about.
+
+ Args:
+ app: The FastAPI application to serve from.
+ dist: The directory ``vite build`` writes into.
+ """
+ if is_built(dist):
+ # Hashed filenames under /assets, so they are safe to cache and are
+ # the only paths that must not fall through to index.html.
+ app.mount(f"/{ASSETS}", StaticFiles(directory=dist / ASSETS), name=ASSETS)
+
+ @app.get("/{_path:path}", response_class=HTMLResponse)
+ async def page(_path: str, response: Response) -> str:
+ if not is_built(dist):
+ response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
+ return NOT_BUILT
+ return (dist / INDEX).read_text(encoding="utf-8")
diff --git a/signal_bot/manage/deps.py b/signal_bot/manage/deps.py
new file mode 100644
index 0000000..30b77b8
--- /dev/null
+++ b/signal_bot/manage/deps.py
@@ -0,0 +1,49 @@
+# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
+"""Everything the management app is pointed at, as one value.
+
+Every path is a field rather than a module constant. That is what lets the
+tests point the whole app at ``tmp_path`` and never go near the real ``.env``
+(mode 0600, holding the live account) or the real album (mode 0700, holding
+photographs of people who did not agree to be in a test fixture).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ import httpx
+
+ from signal_bot.config import Config
+ from signal_bot.pack_album import Album
+
+
+@dataclass(frozen=True, slots=True)
+class ManageDeps:
+ """What one running management page is bound to."""
+
+ config: Config
+ """The bot's own configuration, already validated by load_config."""
+
+ env_path: Path
+ """The .env the page edits. Not derived: the tests point it elsewhere."""
+
+ dist_dir: Path
+ """Where `pnpm build` puts the page. Absent until it has been run."""
+
+ album: Album
+ """The one pack this session tags, chosen by --dir."""
+
+ client: httpx.AsyncClient
+ """Shared, so the proxied /healthz and the group listing pool sockets."""
+
+ port: int
+ """The port this app is served on, so the Host guard knows what to allow."""
+
+ @property
+ def bot_url(self) -> str:
+ """The bot's own loopback base URL, which this app proxies to."""
+ return f"http://{self.config.bind_host}:{self.config.bind_port}"
diff --git a/signal_bot/manage/guard.py b/signal_bot/manage/guard.py
new file mode 100644
index 0000000..84d8862
--- /dev/null
+++ b/signal_bot/manage/guard.py
@@ -0,0 +1,84 @@
+# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
+"""The two checks that make a loopback bind actually mean loopback.
+
+Binding to 127.0.0.1 stops the network. It does not stop the browser already
+running on this machine, and this app writes ``.env`` -- the file that decides
+which groups the bot answers in and who may add it to one. Two attacks reach a
+loopback port from an ordinary web page, and both are cheap to close:
+
+- **Form CSRF.** A page on any site can POST a cross-origin ``