--- 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, the custom-header guard on POST, the request/reply bridge to App through AppEvent::Web, the quit flow, the browser page and its security caveats. tags: [web, http, tiny_http, control-panel, security, autoreply] 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: "claude-code", at: "2026-10-20T20:27:06.515Z" } verified: - by: openwiki/0.7.2 at: 2026-10-21T21:49:12.291Z --- # 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 and toggle the global and per-chat AI autoreply, 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:1-2`, `src/web.rs:16-17`). ## 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:165-173`) 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 `. `serve` (`src/web.rs:179-186`) 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 (`Cargo.toml:24-25`). ## Routes `resolve_route` (`src/web.rs:325-334`) matches method and path; the query string is ignored (`path_without_query`): | 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/quit` | `WebCommand::Quit` | 200 `{"status":"Bot in chiusura"}` | | other | other | — | 404 `non trovato` | The JSON state is `WebState` (`src/web.rs:92-112`): ```json { "global_autoreply": false, "chats": [ { "jid": "...", "name": "...", "autoreply": true } ], "status": "" } ``` ### Request checks - **Custom header on POST** — every POST route must carry the header `X-Wh-Request: 1` (name case-insensitive, value exact); otherwise the server answers 403 `richiesta non autorizzata` and forwards nothing (`handle_request`, `has_request_header`, `src/web.rs:193-205`, `src/web.rs:341-345`). 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:352-364`). ## Bridge to the application The server never touches application state. `forward` (`src/web.rs:252-261`) 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); - 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:234-246`) 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, one switch per chat, a quit button and a status bar: - it polls `GET /api/state` every 5 s while the tab is visible and refreshes when it becomes visible again (`src/web_page.html:330`, `src/web_page.html:612-635`); - every POST carries `X-Wh-Request: 1`; the chat toggle sends the JID as a `text/plain` body (`buildHeaders`, `src/web_page.html:377-382`); - 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 (`src/web_page.html:534-577`); - names are inserted with `textContent` only; - 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:417-439`, `src/web_page.html:597-610`). ## Security and operations - No authentication and no TLS: anyone who can reach `:` can read chat names and JIDs, toggle autoreplies and stop the bot. 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. ## Tests `src/web.rs:366-445` covers query stripping, route resolution (known routes, wrong method or path), the custom header check (any case accepted, missing or wrong value rejected), which routes require the header, 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).