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:
9fec9d1af4
resource: repo://src/app.rsb55a21a31e
resource: repo://src/main.rsac7a3eaa39
resource: repo://src/web_page.html7b75006986
resource: repo://src/web.rs
generated: { by: "claude-code", at: "2026-10-25T12:54:53.684Z" }
verified: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).
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).
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).
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./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).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;State included) App may start the avatar fetch
described below;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.
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):
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;WaHandle::profile_picture_url
for one chat every 2 seconds to stay below WhatsApp rate limits;Foto profilo non caricate: <error>;The server only passes the URL along: the browser loads the picture directly from the WhatsApp CDN.
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.
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:
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);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;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;X-Wh-Request: 1; the chat toggle sends the JID as a
text/plain body (buildHeaders, src/web_page.html:479-484);applyState, runToggle, src/web_page.html:731-785);textContent only;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;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).<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.web_port = 0 is rejected at startup; values above 65535 or negative are
rejected by TOML parsing.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.