//! Optional web control panel (enabled by `web_port`): a minimal HTTP server with no authentication //! that listens on every network interface (`0.0.0.0`) and drives the application through `AppEvent::Web`. use std::io::Read; use anyhow::{Context, Result, anyhow}; use qrcode::QrCode; use qrcode::render::svg; use serde::Serialize; use tiny_http::{Header, Method, Request, Response, Server}; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::oneshot; use crate::app::AppEvent; /// Name of the thread that serves HTTP requests. const WEB_THREAD_NAME: &str = "wh-web"; /// Network address the control server binds to (all interfaces, no authentication). const BIND_ADDRESS: &str = "0.0.0.0"; /// Route path of the control panel page. const PATH_PAGE: &str = "/"; /// Route path that returns the current state. const PATH_STATE: &str = "/api/state"; /// Route path that toggles the global autoreply. const PATH_GLOBAL: &str = "/api/global"; /// Route path that toggles the autoreply of one chat. const PATH_CHAT: &str = "/api/chat"; /// Route path that quits the bot. const PATH_QUIT: &str = "/api/quit"; /// Route path that deletes the WhatsApp session and pairs again. const PATH_RELINK: &str = "/api/relink"; /// Screen name sent while connecting to WhatsApp. pub const SCREEN_CONNECTING: &str = "connecting"; /// Screen name sent while a pairing QR code is shown. pub const SCREEN_PAIRING: &str = "pairing"; /// Screen name sent while connected. pub const SCREEN_MAIN: &str = "main"; /// Size in pixels of one QR module in the SVG sent to the web panel. const QR_MODULE_PIXELS: u32 = 6; /// Color of the dark QR modules. const QR_DARK_COLOR: &str = "#000000"; /// Color of the light QR modules and of the quiet zone. const QR_LIGHT_COLOR: &str = "#ffffff"; /// Maximum accepted size, in bytes, of a request body. const MAX_BODY_BYTES: usize = 1024; /// HTTP status: success. const STATUS_OK: u16 = 200; /// HTTP status: malformed request. const STATUS_BAD_REQUEST: u16 = 400; /// HTTP status: unknown route. const STATUS_NOT_FOUND: u16 = 404; /// HTTP status: the response could not be serialized. const STATUS_INTERNAL_ERROR: u16 = 500; /// HTTP status: the request lacks the mandatory custom header. const STATUS_FORBIDDEN: u16 = 403; /// HTTP status: the application cannot answer. const STATUS_UNAVAILABLE: u16 = 503; /// Name of the custom header every POST request must carry (forces a CORS preflight, never approved). const REQUEST_HEADER_NAME: &str = "X-Wh-Request"; /// Required value of the custom request header. const REQUEST_HEADER_VALUE: &str = "1"; /// JSON key holding the human readable status in API responses. const JSON_KEY_STATUS: &str = "status"; /// Name of the content type header. const HEADER_CONTENT_TYPE: &str = "Content-Type"; /// Content type of the control panel page. const CONTENT_TYPE_HTML: &str = "text/html; charset=utf-8"; /// Content type of API responses. const CONTENT_TYPE_JSON: &str = "application/json"; /// Content type of plain text responses. const CONTENT_TYPE_TEXT: &str = "text/plain; charset=utf-8"; /// Control panel page served at `/`. const PAGE_HTML: &str = include_str!("web_page.html"); /// Message returned when the bot is shutting down. const QUIT_MESSAGE: &str = "Bot in chiusura"; /// Body returned when the application cannot answer. const UNAVAILABLE_MESSAGE: &str = "bot non disponibile"; /// Body returned for unknown routes. const NOT_FOUND_MESSAGE: &str = "non trovato"; /// Body returned when a POST request lacks the mandatory custom header. const FORBIDDEN_MESSAGE: &str = "richiesta non autorizzata"; /// Body returned when the chat request body is missing, unreadable or too large. const BAD_BODY_MESSAGE: &str = "corpo della richiesta non valido"; /// Body returned when the state cannot be serialized. const SERIALIZE_ERROR_MESSAGE: &str = "errore di serializzazione"; /// Command sent by the web panel to the application. #[derive(Debug)] pub enum WebCommand { /// Only read the current state. State, /// Toggle the global autoreply. ToggleGlobal, /// Toggle the autoreply of the chat with this JID. ToggleChat(String), /// Delete the WhatsApp session and pair again with a new QR code. Relink, /// Quit the bot. Quit, } /// One chat shown in the web panel. #[derive(Debug, Serialize)] pub struct WebChat { /// Chat JID. pub jid: String, /// Display name of the chat. pub name: String, /// Whether the autoreply is enabled for this chat. pub autoreply: bool, /// URL of the contact's profile picture preview (WhatsApp CDN), or `None` when it is not known yet or there is none. pub avatar_url: Option, } /// Application state returned to the web panel. #[derive(Debug, Serialize)] pub struct WebState { /// Whether the global autoreply is enabled. pub global_autoreply: bool, /// Chats known to the application. pub chats: Vec, /// Human readable status line. pub status: String, /// Current screen: `SCREEN_CONNECTING`, `SCREEN_PAIRING` or `SCREEN_MAIN`. pub screen: &'static str, /// Pairing QR code as an SVG document, while one is shown. pub qr_svg: Option, } /// Request forwarded to the application through `AppEvent::Web`. #[derive(Debug)] pub struct WebRequest { /// Command to execute. pub command: WebCommand, /// Channel on which the application sends the state after executing the command. pub reply: oneshot::Sender, } /// Route resolved from an HTTP method and path. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Route { /// `GET /`. Page, /// `GET /api/state`. State, /// `POST /api/global`. ToggleGlobal, /// `POST /api/chat`. ToggleChat, /// `POST /api/relink`. Relink, /// `POST /api/quit`. Quit, /// Any other method/path combination. NotFound, } impl Route { /// Purpose: tell whether the route is a state-changing POST route. /// Inputs: none. /// Output: `true` for the POST routes, which must carry the mandatory custom header. /// Side effects: none. fn requires_request_header(self) -> bool { matches!(self, Route::ToggleGlobal | Route::ToggleChat | Route::Relink | Route::Quit) } } /// HTTP response ready to be written. struct HttpReply { /// HTTP status code. status: u16, /// Value of the `Content-Type` header. content_type: &'static str, /// Response body. body: String, } /// Purpose: start the web control server. /// Inputs: `port`, the TCP port to listen on; `events`, the application event sender. /// Output: `Ok(())` once the server is bound and its thread is running, otherwise the bind or spawn error. /// Side effects: binds a TCP socket on all interfaces and spawns a detached OS thread that ends when /// the server stops or the application channel is closed. pub fn start(port: u16, events: UnboundedSender) -> Result<()> { let server = Server::http((BIND_ADDRESS, port)) .map_err(|err| anyhow!("cannot bind web server on {BIND_ADDRESS}:{port}: {err}"))?; std::thread::Builder::new() .name(WEB_THREAD_NAME.to_owned()) .spawn(move || serve(&server, &events)) .context("cannot spawn web thread")?; Ok(()) } /// Purpose: handle incoming requests one at a time until the server or the application is gone. /// Inputs: `server`, the bound HTTP server; `events`, the application event sender. /// Output: none. /// Side effects: blocks on the network; forwards commands to the application and writes responses. fn serve(server: &Server, events: &UnboundedSender) { for request in server.incoming_requests() { handle_request(request, events); if events.is_closed() { break; } } } /// Purpose: route one request and answer it. /// Inputs: `request`, the incoming request; `events`, the application event sender. /// Output: none. /// Side effects: may read the request body, block until the application replies, and write the response; /// a POST route without the mandatory custom header is answered with 403 and nothing is forwarded. fn handle_request(mut request: Request, events: &UnboundedSender) { let route = resolve_route(request.method(), path_without_query(request.url())); if route.requires_request_header() && !has_request_header(request.headers()) { respond(request, text_reply(STATUS_FORBIDDEN, FORBIDDEN_MESSAGE)); return; } if route == Route::Quit { handle_quit(request, events); return; } let reply = build_reply(&mut request, route, events); respond(request, reply); } /// Purpose: compute the response of every route except quit. /// Inputs: `request`, the incoming request (its body is read only for the chat route); /// `route`, the resolved route; `events`, the application event sender. /// Output: the response to write. /// Side effects: may read the request body and block until the application replies. fn build_reply(request: &mut Request, route: Route, events: &UnboundedSender) -> HttpReply { match route { Route::Page => HttpReply { status: STATUS_OK, content_type: CONTENT_TYPE_HTML, body: PAGE_HTML.to_owned(), }, Route::State => forward(events, WebCommand::State), Route::ToggleGlobal => forward(events, WebCommand::ToggleGlobal), Route::ToggleChat => match read_limited_body(request.as_reader()) { Some(jid) => forward(events, WebCommand::ToggleChat(jid)), None => text_reply(STATUS_BAD_REQUEST, BAD_BODY_MESSAGE), }, Route::Relink => forward(events, WebCommand::Relink), Route::Quit | Route::NotFound => text_reply(STATUS_NOT_FOUND, NOT_FOUND_MESSAGE), } } /// Purpose: answer a quit request and then ask the application to quit. /// Inputs: `request`, the incoming request; `events`, the application event sender. /// Output: none. /// Side effects: writes the response first (the process exits right after the app handles the event), /// then sends `WebCommand::Quit` without waiting for the reply. fn handle_quit(request: Request, events: &UnboundedSender) { let body = serde_json::json!({ JSON_KEY_STATUS: QUIT_MESSAGE }).to_string(); respond(request, json_reply(STATUS_OK, body)); let (reply, _receiver) = oneshot::channel(); let quit = WebRequest { command: WebCommand::Quit, reply, }; // Ignoring a send failure is fine here: it means the app is already shutting down. let _ = events.send(AppEvent::Web(quit)); } /// Purpose: send a command to the application and wait for the resulting state. /// Inputs: `events`, the application event sender; `command`, the command to execute. /// Output: a JSON response with the state, 503 if the app is unreachable, 500 if serialization fails. /// Side effects: blocks the calling (non-runtime) thread until the application replies. fn forward(events: &UnboundedSender, command: WebCommand) -> HttpReply { let (reply, receiver) = oneshot::channel(); if events.send(AppEvent::Web(WebRequest { command, reply })).is_err() { return text_reply(STATUS_UNAVAILABLE, UNAVAILABLE_MESSAGE); } match receiver.blocking_recv() { Ok(state) => state_reply(&state), Err(_) => text_reply(STATUS_UNAVAILABLE, UNAVAILABLE_MESSAGE), } } /// Purpose: serialize the application state into a response. /// Inputs: `state`, the state to serialize. /// Output: a 200 JSON response, or a 500 plain text response if serialization fails. /// Side effects: none. fn state_reply(state: &WebState) -> HttpReply { match serde_json::to_string(state) { Ok(body) => json_reply(STATUS_OK, body), Err(_) => text_reply(STATUS_INTERNAL_ERROR, SERIALIZE_ERROR_MESSAGE), } } /// Purpose: build a JSON response. /// Inputs: `status`, the HTTP status code; `body`, the JSON text. /// Output: the response. /// Side effects: none. fn json_reply(status: u16, body: String) -> HttpReply { HttpReply { status, content_type: CONTENT_TYPE_JSON, body, } } /// Purpose: build a plain text response. /// Inputs: `status`, the HTTP status code; `body`, the text. /// Output: the response. /// Side effects: none. fn text_reply(status: u16, body: &str) -> HttpReply { HttpReply { status, content_type: CONTENT_TYPE_TEXT, body: body.to_owned(), } } /// Purpose: write a response to the client. /// Inputs: `request`, the request to answer; `reply`, the response to write. /// Output: none. /// Side effects: writes to the client connection; if the (constant) content type header cannot be /// built the response is sent without it rather than failing. fn respond(request: Request, reply: HttpReply) { let mut response = Response::from_string(reply.body).with_status_code(reply.status); if let Ok(header) = Header::from_bytes(HEADER_CONTENT_TYPE.as_bytes(), reply.content_type.as_bytes()) { response = response.with_header(header); } // Ignoring a write failure is fine here: it means the client already disconnected. let _ = request.respond(response); } /// Purpose: strip the query string from a request URL. /// Inputs: `url`, the request URL as received. /// Output: the part of `url` before the first `?`. /// Side effects: none. fn path_without_query(url: &str) -> &str { url.split_once('?').map_or(url, |(path, _)| path) } /// Purpose: map an HTTP method and path to a route. /// Inputs: `method`, the HTTP method; `path`, the URL path without query. /// Output: the matching route, or `Route::NotFound`. /// Side effects: none. fn resolve_route(method: &Method, path: &str) -> Route { match (method, path) { (Method::Get, PATH_PAGE) => Route::Page, (Method::Get, PATH_STATE) => Route::State, (Method::Post, PATH_GLOBAL) => Route::ToggleGlobal, (Method::Post, PATH_CHAT) => Route::ToggleChat, (Method::Post, PATH_RELINK) => Route::Relink, (Method::Post, PATH_QUIT) => Route::Quit, _ => Route::NotFound, } } /// Purpose: draw a pairing QR code for the web panel. /// Inputs: `payload`, the QR payload received from WhatsApp. /// Output: an SVG document with dark modules on a light quiet zone, or `None` when the payload /// cannot be encoded (the TUI pairing screen shows that encoding error). /// Side effects: none. pub fn qr_svg(payload: &str) -> Option { let code = QrCode::new(payload.as_bytes()).ok()?; Some( code.render::>() .module_dimensions(QR_MODULE_PIXELS, QR_MODULE_PIXELS) .dark_color(svg::Color(QR_DARK_COLOR)) .light_color(svg::Color(QR_LIGHT_COLOR)) .quiet_zone(true) .build(), ) } /// Purpose: check that a request carries the custom header required on POST routes. /// Inputs: `headers`, the request headers. /// Output: `true` if a header named `REQUEST_HEADER_NAME` (name matched case-insensitively) has /// exactly the value `REQUEST_HEADER_VALUE`, otherwise `false`. /// Side effects: none. fn has_request_header(headers: &[Header]) -> bool { headers .iter() .any(|header| header.field.equiv(REQUEST_HEADER_NAME) && header.value.as_str() == REQUEST_HEADER_VALUE) } /// Purpose: read a request body as trimmed UTF-8 text, refusing oversized bodies. /// Inputs: `reader`, the body reader. /// Output: `Some(text)` for a non-empty body of at most `MAX_BODY_BYTES`, otherwise `None` /// (read error, invalid UTF-8, empty or too large). /// Side effects: consumes at most `MAX_BODY_BYTES + 1` bytes from `reader`. fn read_limited_body(reader: R) -> Option { let mut bytes = Vec::new(); reader .take(MAX_BODY_BYTES as u64 + 1) .read_to_end(&mut bytes) .ok()?; if bytes.len() > MAX_BODY_BYTES { return None; } let text = String::from_utf8(bytes).ok()?; let trimmed = text.trim(); (!trimmed.is_empty()).then(|| trimmed.to_owned()) } #[cfg(test)] mod tests { use super::*; #[test] fn path_without_query_strips_query_string() { assert_eq!(path_without_query("/api/state?x=1&y=2"), "/api/state"); assert_eq!(path_without_query("/api/state"), "/api/state"); assert_eq!(path_without_query("/?"), "/"); } #[test] fn resolve_route_matches_known_routes() { assert_eq!(resolve_route(&Method::Get, PATH_PAGE), Route::Page); assert_eq!(resolve_route(&Method::Get, PATH_STATE), Route::State); assert_eq!(resolve_route(&Method::Post, PATH_GLOBAL), Route::ToggleGlobal); assert_eq!(resolve_route(&Method::Post, PATH_CHAT), Route::ToggleChat); assert_eq!(resolve_route(&Method::Post, PATH_RELINK), Route::Relink); assert_eq!(resolve_route(&Method::Post, PATH_QUIT), Route::Quit); } #[test] fn relink_requires_the_request_header() { assert!(Route::Relink.requires_request_header()); assert_eq!(resolve_route(&Method::Get, PATH_RELINK), Route::NotFound); } #[test] fn qr_svg_draws_an_svg_document() { let svg = qr_svg("2@ref,noise,identity,adv,1").expect("payload must encode"); assert!(svg.contains(" Header { Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid test header") } #[test] fn has_request_header_accepts_required_header_any_case() { assert!(has_request_header(&[header(REQUEST_HEADER_NAME, REQUEST_HEADER_VALUE)])); assert!(has_request_header(&[header("x-wh-request", REQUEST_HEADER_VALUE)])); assert!(has_request_header(&[ header("Accept", "*/*"), header("X-WH-REQUEST", REQUEST_HEADER_VALUE), ])); } #[test] fn has_request_header_rejects_missing_or_wrong_value() { assert!(!has_request_header(&[])); assert!(!has_request_header(&[header("Accept", "*/*")])); assert!(!has_request_header(&[header(REQUEST_HEADER_NAME, "0")])); assert!(!has_request_header(&[header(REQUEST_HEADER_NAME, "")])); } #[test] fn only_post_routes_require_request_header() { assert!(Route::ToggleGlobal.requires_request_header()); assert!(Route::ToggleChat.requires_request_header()); assert!(Route::Quit.requires_request_header()); assert!(!Route::Page.requires_request_header()); assert!(!Route::State.requires_request_header()); assert!(!Route::NotFound.requires_request_header()); } #[test] fn read_limited_body_trims_text() { let body = read_limited_body(&b" 123@s.whatsapp.net\n"[..]); assert_eq!(body.as_deref(), Some("123@s.whatsapp.net")); } #[test] fn read_limited_body_rejects_empty_and_invalid() { assert_eq!(read_limited_body(&b" \n"[..]), None); assert_eq!(read_limited_body(&[0xff, 0xfe][..]), None); } #[test] fn read_limited_body_enforces_size_limit() { let at_limit = vec![b'a'; MAX_BODY_BYTES]; let over_limit = vec![b'a'; MAX_BODY_BYTES + 1]; assert!(read_limited_body(&at_limit[..]).is_some()); assert_eq!(read_limited_body(&over_limit[..]), None); } }