| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505 |
- //! 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<String>,
- }
- /// 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<WebChat>,
- /// 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<String>,
- }
- /// 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<WebState>,
- }
- /// 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<AppEvent>) -> 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<AppEvent>) {
- 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<AppEvent>) {
- 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<AppEvent>) -> 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<AppEvent>) {
- 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<AppEvent>, 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<String> {
- let code = QrCode::new(payload.as_bytes()).ok()?;
- Some(
- code.render::<svg::Color<'_>>()
- .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<R: Read>(reader: R) -> Option<String> {
- 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("<svg") && svg.contains(QR_DARK_COLOR));
- }
- #[test]
- fn resolve_route_rejects_wrong_method_or_path() {
- assert_eq!(resolve_route(&Method::Post, PATH_STATE), Route::NotFound);
- assert_eq!(resolve_route(&Method::Get, PATH_QUIT), Route::NotFound);
- assert_eq!(resolve_route(&Method::Get, "/unknown"), Route::NotFound);
- }
- /// Builds a header from raw name and value for the tests.
- fn header(name: &str, value: &str) -> 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);
- }
- }
|