web-control-panel.md 12 KB


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, pairing QR as SVG and per-chat profile picture URLs, the lazy avatar fetch, the quit and relink flows, the browser page with its pairing card and round avatars, and its security caveats. tags: [web, http, tiny_http, control-panel, security, autoreply, qr-code, relink, avatars] sources:

  • id: openwiki-source-9fec9d1af4 resource: repo://src/app.rs
  • id: openwiki-source-b55a21a31e resource: repo://src/main.rs
  • id: openwiki-source-ac7a3eaa39 resource: repo://src/web_page.html
  • id: openwiki-source-7b75006986 resource: repo://src/web.rs generated: { by: "claude-code", at: "2026-10-25T12:54:53.684Z" } verified:
  • by: openwiki/0.7.2 at: 2026-10-25T12:54:53.684Z ---

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 with their profile pictures, 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). 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). start (src/web.rs:193-205) binds a tiny_http::Server on 0.0.0.0:<port> and spawns a detached OS thread named wh-web. A bind failure aborts startup with cannot start web server on port <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:207-219) 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:354-369) matches method and path; the query string is ignored (path_without_query, src/web.rs:346-350):

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>); 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:127-138), whose chats are WebChat entries (src/web.rs:114-123):

{ "global_autoreply": false,
  "chats": [ { "jid": "...", "name": "...", "autoreply": true,
               "avatar_url": "https://... | null" } ],
  "status": "<TUI status line>",
  "screen": "connecting | pairing | main",
  "qr_svg": "<svg ...>...</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:371-386) 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). avatar_url is the WhatsApp CDN URL of the chat's profile picture preview, or null while it has not been fetched yet or when the chat has no picture or hides it (see Profile pictures).

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:173-176, src/web.rs:221-238, src/web.rs:388-397). 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:399-412).

Bridge to the application

The server never touches application state. forward (src/web.rs:281-294) 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):

  • ToggleGlobal follows the same rules as F4 (enabling needs a configured AI client; the flag is persisted in the history store);
  • 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;
  • after every command (State included) App may start the avatar fetch described below;
  • 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.

Profile pictures

Avatars are fetched lazily by App and driven by the panel's own requests, so nothing is fetched while no browser is open (Application state: avatar cache):

  • each web request calls maybe_start_avatar_fetch, which starts one background batch only on the main screen, while WhatsApp is connected and when no batch is already running;
  • the batch covers the panel's chats whose cached avatar is missing or older than one day, and asks WaHandle::profile_picture_url for one chat every 2 seconds to stay below WhatsApp rate limits;
  • the first error stops the batch, caches that chat without picture until the TTL expires and shows Foto profilo non caricate: <error>;
  • the URLs appear in later state replies as they arrive; the cache lives in memory only and is rebuilt after a restart.

The server only passes the URL along: the browser loads the picture directly from the WhatsApp CDN.

Quit

handle_quit (src/web.rs:263-279) 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 row per chat (avatar, name, switch) 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:416-417, src/web_page.html:707-713, src/web_page.html:826-847);
  • the pairing card (renderPairing, src/web_page.html:688-706) 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;
  • each chat row has a 40 px round avatar (createAvatar, updateAvatar, src/web_page.html:548-624): the upper-cased first character of the name on a grey circle, covered by the profile picture once avatar_url is set. The image is loaded lazily with referrerPolicy = "no-referrer", its src changes only when the URL changes (polling never reloads it), and a failed load (for example an expired CDN URL) hides it so the initial shows again;
  • every POST carries X-Wh-Request: 1; the chat toggle sends the JID as a text/plain body (buildHeaders, src/web_page.html:479-484);
  • 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:731-785);
  • names are inserted with textContent only;
  • "Nuovo QR" (relinkBot, src/web_page.html:791-802) 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:519-545, src/web_page.html:808-821).

Security and operations

  • No authentication and no TLS: anyone who can reach <host>:<web_port> can read chat names, JIDs and profile picture URLs, 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; the avatar fetch likewise runs in the background and never delays a reply.
  • Opening the panel makes the bot issue profile picture requests to WhatsApp (one every 2 seconds, at most once a day per chat).

Tests

src/web.rs:414-505 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; the avatar fetch and the page's avatar rendering have no tests. See Unit tests.