--- type: integration title: Web control panel description: The optional HTTP control panel in src/web.rs and src/web_page.html, enabled by web_port — routes (including POST /api/relink), the custom-header guard on POST, the request/reply bridge to App through AppEvent::Web, the state with screen and pairing QR as SVG, the quit and relink flows, the browser page with its pairing card and its security caveats. tags: [web, http, tiny_http, control-panel, security, autoreply, qr-code, relink] sources: - id: openwiki-source-9fec9d1af408501c9c455161 resource: repo://src/app.rs - id: openwiki-source-b55a21a31ede1b56cd31a6a6 resource: repo://src/main.rs - id: openwiki-source-ac7a3eaa396cc3aa88c00eef resource: repo://src/web_page.html - id: openwiki-source-7b75006986d8b0337adc6ca0 resource: repo://src/web.rs generated: { by: "openwiki/0.7.2", at: "2026-10-24T16:42:08.550Z" } verified: - by: openwiki/0.7.2 at: 2026-10-23T21:26:24.156Z --- # Web control panel `src/web.rs` is an optional, minimal HTTP server that lets a browser (for example a phone on the same network) see the chats, toggle the global and per-chat AI autoreply, pair WhatsApp by scanning the QR code shown in the page, request a new pairing, or quit the bot. It is off unless `web_port` is set in `config.toml` (see [Configuration and data](../operations/configuration-and-data.md)). It has **no authentication** and listens on **all interfaces** (`0.0.0.0`, `src/web.rs:17-19`). ## Startup and threading `main` calls `start_web_panel`, which calls `web::start(port, app_tx)` only when `web_port` is set ([Runtime and event loop](../architecture/runtime-and-event-loop.md)). `start` (`src/web.rs:191-203`) binds a `tiny_http::Server` on `0.0.0.0:` and spawns a detached OS thread named `wh-web`. A bind failure aborts startup with `cannot start web server on port `. The port is read once: unlike the other settings, a changed `web_port` is not applied by the configuration live reload and needs a restart. `serve` (`src/web.rs:205-217`) handles requests **one at a time** on that thread and stops after a request once the application channel is closed. `tiny_http` is the only extra dependency for the server; the `qrcode` crate (already used by the TUI) is built with its `svg` feature for the pairing QR (`Cargo.toml`). ## Routes `resolve_route` (`src/web.rs:352-367`) matches method and path; the query string is ignored (`path_without_query`, `src/web.rs:344-348`): | Method | Path | Effect | Response | |---|---|---|---| | GET | `/` | Serves the embedded page (`include_str!("web_page.html")`) | 200 HTML | | GET | `/api/state` | `WebCommand::State` | 200 JSON state | | POST | `/api/global` | `WebCommand::ToggleGlobal` | 200 JSON state | | POST | `/api/chat` | `WebCommand::ToggleChat()`; body = chat JID as plain text | 200 JSON state, 400 on bad body | | POST | `/api/relink` | `WebCommand::Relink` | 200 JSON state | | POST | `/api/quit` | `WebCommand::Quit` | 200 `{"status":"Bot in chiusura"}` | | other | other | — | 404 `non trovato` | The JSON state is `WebState` (`src/web.rs:125-136`): ```json { "global_autoreply": false, "chats": [ { "jid": "...", "name": "...", "autoreply": true } ], "status": "", "screen": "connecting | pairing | main", "qr_svg": "..." } ``` `screen` mirrors the TUI screen (`SCREEN_CONNECTING`, `SCREEN_PAIRING`, `SCREEN_MAIN`, `src/web.rs:35-39`). `qr_svg` is `null` unless the app holds a pairing QR payload; then `qr_svg` (`src/web.rs:369-384`) renders it as an SVG document with 6-pixel black modules on a white quiet zone. A payload that cannot be encoded yields `null` (the TUI pairing screen shows the encoding error). ### Request checks - **Custom header on POST** — every POST route (global, chat, relink, quit) must carry the header `X-Wh-Request: 1` (name case-insensitive, value exact); otherwise the server answers 403 `richiesta non autorizzata` and forwards nothing (`requires_request_header`, `handle_request`, `has_request_header`, `src/web.rs:171-174`, `src/web.rs:219-236`, `src/web.rs:386-395`). A cross-site page cannot add a custom header without a CORS preflight, which the server never approves, so this blocks simple cross-site form/fetch requests (CSRF). It is **not** authentication: anyone who can reach the port can send the header. - **Body limit** — `/api/chat` reads at most 1024 bytes (`MAX_BODY_BYTES`); an oversized, empty (after trimming), unreadable or non-UTF-8 body yields 400 `corpo della richiesta non valido` (`read_limited_body`, `src/web.rs:397-414`). ## Bridge to the application The server never touches application state. `forward` (`src/web.rs:279-292`) sends `AppEvent::Web(WebRequest { command, reply })` with a tokio `oneshot` sender and blocks the web thread (`blocking_recv`) until `App` answers. `App::handle_web_request` runs inside the normal event loop, so web commands are serialised with keys and WhatsApp events ([Application state](../architecture/app-state.md#web-panel-requests)): - `ToggleGlobal` follows the same rules as F4 (enabling needs a configured AI client; the flag is persisted in the [history store](../concepts/history-store.md)); - `ToggleChat(jid)` is accepted only for JIDs present in the panel's chat list — otherwise only the status becomes `Chat non trovata` — and then follows the same rules as F3 (AI configured, private chat or group); - `Relink` starts the session deletion and re-pairing at once (the browser asks for confirmation instead of the TUI's double F8); it is ignored while a relink is already running. See [WhatsApp relink and pairing](../workflows/relink-and-pairing.md); - the reply is always the state after the command; refusals are visible only through `status`. The panel's chat list (`web_chats`) is the TUI chat list in the same order (most recent first), restricted to chats that support autoreply (private chats and groups). The `autoreply` field is the **per-chat** flag; the global flag is reported separately. If the event channel is closed or the reply is dropped, the server answers 503 `bot non disponibile`; a serialisation failure gives 500. ### Quit `handle_quit` (`src/web.rs:261-277`) first writes the 200 response, then sends `WebCommand::Quit` without waiting for a reply, because the process exits as soon as `App` sets `should_quit` and the event loop ends. The TUI terminal is restored as on F10. ## Browser page `src/web_page.html` is a self-contained page (inline CSS and JS, Italian UI) with a global "AI tutte" switch, a "Nuovo QR" button, a "Chiudi bot" button, a pairing card, one switch per chat and a status bar: - it polls `GET /api/state` every 5 s while the tab is visible — every 2 s while `screen` is not `main`, so a rotated QR shows up quickly (`adjustPolling`) — and refreshes when it becomes visible again (`src/web_page.html:382-383`, `src/web_page.html:623-629`, `src/web_page.html:742-763`); - the pairing card (`renderPairing`, `src/web_page.html:604-622`) is hidden on `main`; otherwise it shows "Connessione a WhatsApp in corso...", "In attesa del codice QR..." or, with a QR, "Collega WhatsApp: scansiona il codice QR", the QR image (the SVG as a `data:` URL, replaced only when it changes) and the WhatsApp linking steps; - every POST carries `X-Wh-Request: 1`; the chat toggle sends the JID as a `text/plain` body (`buildHeaders`, `src/web_page.html:445-450`); - while a toggle is in flight its switch is disabled and keeps the user's position; polling is paused, and responses are applied in request order through a ticket counter so a stale poll cannot overwrite a newer toggle result (`applyState`, `runToggle`, `src/web_page.html:647-701`); - names are inserted with `textContent` only; - "Nuovo QR" (`relinkBot`, `src/web_page.html:707-718`) asks for confirmation (the device will be unlinked from the phone), posts `/api/relink`, applies the returned state (the pairing card appears) and keeps the button disabled while the request runs; - quit asks for confirmation (`Chiudere il bot?`) and then shows a full-screen "Bot chiuso" overlay and stops polling; a network error after a quit request is treated as the bot having exited (`src/web_page.html:485-511`, `src/web_page.html:724-737`). ## Security and operations - No authentication and no TLS: anyone who can reach `:` can read chat names and JIDs, toggle autoreplies, stop the bot, unlink the WhatsApp device with `/api/relink` and, while pairing, read the QR code and link their own phone. Enable it only on a trusted network, or restrict the port with the host firewall. - The server binds to every interface; there is no setting for the bind address. - `web_port = 0` is rejected at startup; values above 65535 or negative are rejected by TOML parsing. - Requests are served sequentially and each API request waits for the event loop, so a slow request delays the others. A relink request returns as soon as the relink task is spawned, not when it completes. ## Tests `src/web.rs:415-502` covers query stripping, route resolution (known routes including `/api/relink`, wrong method or path, `GET /api/relink` → not found), the custom header check (any case accepted, missing or wrong value rejected), which routes require the header (relink included), `qr_svg` producing an SVG document, and body reading (trimming, empty/invalid UTF-8 rejection, the 1024-byte limit). `web_chats` filtering is tested in `src/app.rs`. See [Unit tests](../testing/unit-tests.md).