web.rs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. //! Optional web control panel (enabled by `web_port`): a minimal HTTP server with no authentication
  2. //! that listens on every network interface (`0.0.0.0`) and drives the application through `AppEvent::Web`.
  3. use std::io::Read;
  4. use anyhow::{Context, Result, anyhow};
  5. use qrcode::QrCode;
  6. use qrcode::render::svg;
  7. use serde::Serialize;
  8. use tiny_http::{Header, Method, Request, Response, Server};
  9. use tokio::sync::mpsc::UnboundedSender;
  10. use tokio::sync::oneshot;
  11. use crate::app::AppEvent;
  12. /// Name of the thread that serves HTTP requests.
  13. const WEB_THREAD_NAME: &str = "wh-web";
  14. /// Network address the control server binds to (all interfaces, no authentication).
  15. const BIND_ADDRESS: &str = "0.0.0.0";
  16. /// Route path of the control panel page.
  17. const PATH_PAGE: &str = "/";
  18. /// Route path that returns the current state.
  19. const PATH_STATE: &str = "/api/state";
  20. /// Route path that toggles the global autoreply.
  21. const PATH_GLOBAL: &str = "/api/global";
  22. /// Route path that toggles the autoreply of one chat.
  23. const PATH_CHAT: &str = "/api/chat";
  24. /// Route path that quits the bot.
  25. const PATH_QUIT: &str = "/api/quit";
  26. /// Route path that deletes the WhatsApp session and pairs again.
  27. const PATH_RELINK: &str = "/api/relink";
  28. /// Screen name sent while connecting to WhatsApp.
  29. pub const SCREEN_CONNECTING: &str = "connecting";
  30. /// Screen name sent while a pairing QR code is shown.
  31. pub const SCREEN_PAIRING: &str = "pairing";
  32. /// Screen name sent while connected.
  33. pub const SCREEN_MAIN: &str = "main";
  34. /// Size in pixels of one QR module in the SVG sent to the web panel.
  35. const QR_MODULE_PIXELS: u32 = 6;
  36. /// Color of the dark QR modules.
  37. const QR_DARK_COLOR: &str = "#000000";
  38. /// Color of the light QR modules and of the quiet zone.
  39. const QR_LIGHT_COLOR: &str = "#ffffff";
  40. /// Maximum accepted size, in bytes, of a request body.
  41. const MAX_BODY_BYTES: usize = 1024;
  42. /// HTTP status: success.
  43. const STATUS_OK: u16 = 200;
  44. /// HTTP status: malformed request.
  45. const STATUS_BAD_REQUEST: u16 = 400;
  46. /// HTTP status: unknown route.
  47. const STATUS_NOT_FOUND: u16 = 404;
  48. /// HTTP status: the response could not be serialized.
  49. const STATUS_INTERNAL_ERROR: u16 = 500;
  50. /// HTTP status: the request lacks the mandatory custom header.
  51. const STATUS_FORBIDDEN: u16 = 403;
  52. /// HTTP status: the application cannot answer.
  53. const STATUS_UNAVAILABLE: u16 = 503;
  54. /// Name of the custom header every POST request must carry (forces a CORS preflight, never approved).
  55. const REQUEST_HEADER_NAME: &str = "X-Wh-Request";
  56. /// Required value of the custom request header.
  57. const REQUEST_HEADER_VALUE: &str = "1";
  58. /// JSON key holding the human readable status in API responses.
  59. const JSON_KEY_STATUS: &str = "status";
  60. /// Name of the content type header.
  61. const HEADER_CONTENT_TYPE: &str = "Content-Type";
  62. /// Content type of the control panel page.
  63. const CONTENT_TYPE_HTML: &str = "text/html; charset=utf-8";
  64. /// Content type of API responses.
  65. const CONTENT_TYPE_JSON: &str = "application/json";
  66. /// Content type of plain text responses.
  67. const CONTENT_TYPE_TEXT: &str = "text/plain; charset=utf-8";
  68. /// Control panel page served at `/`.
  69. const PAGE_HTML: &str = include_str!("web_page.html");
  70. /// Message returned when the bot is shutting down.
  71. const QUIT_MESSAGE: &str = "Bot in chiusura";
  72. /// Body returned when the application cannot answer.
  73. const UNAVAILABLE_MESSAGE: &str = "bot non disponibile";
  74. /// Body returned for unknown routes.
  75. const NOT_FOUND_MESSAGE: &str = "non trovato";
  76. /// Body returned when a POST request lacks the mandatory custom header.
  77. const FORBIDDEN_MESSAGE: &str = "richiesta non autorizzata";
  78. /// Body returned when the chat request body is missing, unreadable or too large.
  79. const BAD_BODY_MESSAGE: &str = "corpo della richiesta non valido";
  80. /// Body returned when the state cannot be serialized.
  81. const SERIALIZE_ERROR_MESSAGE: &str = "errore di serializzazione";
  82. /// Command sent by the web panel to the application.
  83. #[derive(Debug)]
  84. pub enum WebCommand {
  85. /// Only read the current state.
  86. State,
  87. /// Toggle the global autoreply.
  88. ToggleGlobal,
  89. /// Toggle the autoreply of the chat with this JID.
  90. ToggleChat(String),
  91. /// Delete the WhatsApp session and pair again with a new QR code.
  92. Relink,
  93. /// Quit the bot.
  94. Quit,
  95. }
  96. /// One chat shown in the web panel.
  97. #[derive(Debug, Serialize)]
  98. pub struct WebChat {
  99. /// Chat JID.
  100. pub jid: String,
  101. /// Display name of the chat.
  102. pub name: String,
  103. /// Whether the autoreply is enabled for this chat.
  104. pub autoreply: bool,
  105. /// URL of the contact's profile picture preview (WhatsApp CDN), or `None` when it is not known yet or there is none.
  106. pub avatar_url: Option<String>,
  107. }
  108. /// Application state returned to the web panel.
  109. #[derive(Debug, Serialize)]
  110. pub struct WebState {
  111. /// Whether the global autoreply is enabled.
  112. pub global_autoreply: bool,
  113. /// Chats known to the application.
  114. pub chats: Vec<WebChat>,
  115. /// Human readable status line.
  116. pub status: String,
  117. /// Current screen: `SCREEN_CONNECTING`, `SCREEN_PAIRING` or `SCREEN_MAIN`.
  118. pub screen: &'static str,
  119. /// Pairing QR code as an SVG document, while one is shown.
  120. pub qr_svg: Option<String>,
  121. }
  122. /// Request forwarded to the application through `AppEvent::Web`.
  123. #[derive(Debug)]
  124. pub struct WebRequest {
  125. /// Command to execute.
  126. pub command: WebCommand,
  127. /// Channel on which the application sends the state after executing the command.
  128. pub reply: oneshot::Sender<WebState>,
  129. }
  130. /// Route resolved from an HTTP method and path.
  131. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  132. enum Route {
  133. /// `GET /`.
  134. Page,
  135. /// `GET /api/state`.
  136. State,
  137. /// `POST /api/global`.
  138. ToggleGlobal,
  139. /// `POST /api/chat`.
  140. ToggleChat,
  141. /// `POST /api/relink`.
  142. Relink,
  143. /// `POST /api/quit`.
  144. Quit,
  145. /// Any other method/path combination.
  146. NotFound,
  147. }
  148. impl Route {
  149. /// Purpose: tell whether the route is a state-changing POST route.
  150. /// Inputs: none.
  151. /// Output: `true` for the POST routes, which must carry the mandatory custom header.
  152. /// Side effects: none.
  153. fn requires_request_header(self) -> bool {
  154. matches!(self, Route::ToggleGlobal | Route::ToggleChat | Route::Relink | Route::Quit)
  155. }
  156. }
  157. /// HTTP response ready to be written.
  158. struct HttpReply {
  159. /// HTTP status code.
  160. status: u16,
  161. /// Value of the `Content-Type` header.
  162. content_type: &'static str,
  163. /// Response body.
  164. body: String,
  165. }
  166. /// Purpose: start the web control server.
  167. /// Inputs: `port`, the TCP port to listen on; `events`, the application event sender.
  168. /// Output: `Ok(())` once the server is bound and its thread is running, otherwise the bind or spawn error.
  169. /// Side effects: binds a TCP socket on all interfaces and spawns a detached OS thread that ends when
  170. /// the server stops or the application channel is closed.
  171. pub fn start(port: u16, events: UnboundedSender<AppEvent>) -> Result<()> {
  172. let server = Server::http((BIND_ADDRESS, port))
  173. .map_err(|err| anyhow!("cannot bind web server on {BIND_ADDRESS}:{port}: {err}"))?;
  174. std::thread::Builder::new()
  175. .name(WEB_THREAD_NAME.to_owned())
  176. .spawn(move || serve(&server, &events))
  177. .context("cannot spawn web thread")?;
  178. Ok(())
  179. }
  180. /// Purpose: handle incoming requests one at a time until the server or the application is gone.
  181. /// Inputs: `server`, the bound HTTP server; `events`, the application event sender.
  182. /// Output: none.
  183. /// Side effects: blocks on the network; forwards commands to the application and writes responses.
  184. fn serve(server: &Server, events: &UnboundedSender<AppEvent>) {
  185. for request in server.incoming_requests() {
  186. handle_request(request, events);
  187. if events.is_closed() {
  188. break;
  189. }
  190. }
  191. }
  192. /// Purpose: route one request and answer it.
  193. /// Inputs: `request`, the incoming request; `events`, the application event sender.
  194. /// Output: none.
  195. /// Side effects: may read the request body, block until the application replies, and write the response;
  196. /// a POST route without the mandatory custom header is answered with 403 and nothing is forwarded.
  197. fn handle_request(mut request: Request, events: &UnboundedSender<AppEvent>) {
  198. let route = resolve_route(request.method(), path_without_query(request.url()));
  199. if route.requires_request_header() && !has_request_header(request.headers()) {
  200. respond(request, text_reply(STATUS_FORBIDDEN, FORBIDDEN_MESSAGE));
  201. return;
  202. }
  203. if route == Route::Quit {
  204. handle_quit(request, events);
  205. return;
  206. }
  207. let reply = build_reply(&mut request, route, events);
  208. respond(request, reply);
  209. }
  210. /// Purpose: compute the response of every route except quit.
  211. /// Inputs: `request`, the incoming request (its body is read only for the chat route);
  212. /// `route`, the resolved route; `events`, the application event sender.
  213. /// Output: the response to write.
  214. /// Side effects: may read the request body and block until the application replies.
  215. fn build_reply(request: &mut Request, route: Route, events: &UnboundedSender<AppEvent>) -> HttpReply {
  216. match route {
  217. Route::Page => HttpReply {
  218. status: STATUS_OK,
  219. content_type: CONTENT_TYPE_HTML,
  220. body: PAGE_HTML.to_owned(),
  221. },
  222. Route::State => forward(events, WebCommand::State),
  223. Route::ToggleGlobal => forward(events, WebCommand::ToggleGlobal),
  224. Route::ToggleChat => match read_limited_body(request.as_reader()) {
  225. Some(jid) => forward(events, WebCommand::ToggleChat(jid)),
  226. None => text_reply(STATUS_BAD_REQUEST, BAD_BODY_MESSAGE),
  227. },
  228. Route::Relink => forward(events, WebCommand::Relink),
  229. Route::Quit | Route::NotFound => text_reply(STATUS_NOT_FOUND, NOT_FOUND_MESSAGE),
  230. }
  231. }
  232. /// Purpose: answer a quit request and then ask the application to quit.
  233. /// Inputs: `request`, the incoming request; `events`, the application event sender.
  234. /// Output: none.
  235. /// Side effects: writes the response first (the process exits right after the app handles the event),
  236. /// then sends `WebCommand::Quit` without waiting for the reply.
  237. fn handle_quit(request: Request, events: &UnboundedSender<AppEvent>) {
  238. let body = serde_json::json!({ JSON_KEY_STATUS: QUIT_MESSAGE }).to_string();
  239. respond(request, json_reply(STATUS_OK, body));
  240. let (reply, _receiver) = oneshot::channel();
  241. let quit = WebRequest {
  242. command: WebCommand::Quit,
  243. reply,
  244. };
  245. // Ignoring a send failure is fine here: it means the app is already shutting down.
  246. let _ = events.send(AppEvent::Web(quit));
  247. }
  248. /// Purpose: send a command to the application and wait for the resulting state.
  249. /// Inputs: `events`, the application event sender; `command`, the command to execute.
  250. /// Output: a JSON response with the state, 503 if the app is unreachable, 500 if serialization fails.
  251. /// Side effects: blocks the calling (non-runtime) thread until the application replies.
  252. fn forward(events: &UnboundedSender<AppEvent>, command: WebCommand) -> HttpReply {
  253. let (reply, receiver) = oneshot::channel();
  254. if events.send(AppEvent::Web(WebRequest { command, reply })).is_err() {
  255. return text_reply(STATUS_UNAVAILABLE, UNAVAILABLE_MESSAGE);
  256. }
  257. match receiver.blocking_recv() {
  258. Ok(state) => state_reply(&state),
  259. Err(_) => text_reply(STATUS_UNAVAILABLE, UNAVAILABLE_MESSAGE),
  260. }
  261. }
  262. /// Purpose: serialize the application state into a response.
  263. /// Inputs: `state`, the state to serialize.
  264. /// Output: a 200 JSON response, or a 500 plain text response if serialization fails.
  265. /// Side effects: none.
  266. fn state_reply(state: &WebState) -> HttpReply {
  267. match serde_json::to_string(state) {
  268. Ok(body) => json_reply(STATUS_OK, body),
  269. Err(_) => text_reply(STATUS_INTERNAL_ERROR, SERIALIZE_ERROR_MESSAGE),
  270. }
  271. }
  272. /// Purpose: build a JSON response.
  273. /// Inputs: `status`, the HTTP status code; `body`, the JSON text.
  274. /// Output: the response.
  275. /// Side effects: none.
  276. fn json_reply(status: u16, body: String) -> HttpReply {
  277. HttpReply {
  278. status,
  279. content_type: CONTENT_TYPE_JSON,
  280. body,
  281. }
  282. }
  283. /// Purpose: build a plain text response.
  284. /// Inputs: `status`, the HTTP status code; `body`, the text.
  285. /// Output: the response.
  286. /// Side effects: none.
  287. fn text_reply(status: u16, body: &str) -> HttpReply {
  288. HttpReply {
  289. status,
  290. content_type: CONTENT_TYPE_TEXT,
  291. body: body.to_owned(),
  292. }
  293. }
  294. /// Purpose: write a response to the client.
  295. /// Inputs: `request`, the request to answer; `reply`, the response to write.
  296. /// Output: none.
  297. /// Side effects: writes to the client connection; if the (constant) content type header cannot be
  298. /// built the response is sent without it rather than failing.
  299. fn respond(request: Request, reply: HttpReply) {
  300. let mut response = Response::from_string(reply.body).with_status_code(reply.status);
  301. if let Ok(header) = Header::from_bytes(HEADER_CONTENT_TYPE.as_bytes(), reply.content_type.as_bytes()) {
  302. response = response.with_header(header);
  303. }
  304. // Ignoring a write failure is fine here: it means the client already disconnected.
  305. let _ = request.respond(response);
  306. }
  307. /// Purpose: strip the query string from a request URL.
  308. /// Inputs: `url`, the request URL as received.
  309. /// Output: the part of `url` before the first `?`.
  310. /// Side effects: none.
  311. fn path_without_query(url: &str) -> &str {
  312. url.split_once('?').map_or(url, |(path, _)| path)
  313. }
  314. /// Purpose: map an HTTP method and path to a route.
  315. /// Inputs: `method`, the HTTP method; `path`, the URL path without query.
  316. /// Output: the matching route, or `Route::NotFound`.
  317. /// Side effects: none.
  318. fn resolve_route(method: &Method, path: &str) -> Route {
  319. match (method, path) {
  320. (Method::Get, PATH_PAGE) => Route::Page,
  321. (Method::Get, PATH_STATE) => Route::State,
  322. (Method::Post, PATH_GLOBAL) => Route::ToggleGlobal,
  323. (Method::Post, PATH_CHAT) => Route::ToggleChat,
  324. (Method::Post, PATH_RELINK) => Route::Relink,
  325. (Method::Post, PATH_QUIT) => Route::Quit,
  326. _ => Route::NotFound,
  327. }
  328. }
  329. /// Purpose: draw a pairing QR code for the web panel.
  330. /// Inputs: `payload`, the QR payload received from WhatsApp.
  331. /// Output: an SVG document with dark modules on a light quiet zone, or `None` when the payload
  332. /// cannot be encoded (the TUI pairing screen shows that encoding error).
  333. /// Side effects: none.
  334. pub fn qr_svg(payload: &str) -> Option<String> {
  335. let code = QrCode::new(payload.as_bytes()).ok()?;
  336. Some(
  337. code.render::<svg::Color<'_>>()
  338. .module_dimensions(QR_MODULE_PIXELS, QR_MODULE_PIXELS)
  339. .dark_color(svg::Color(QR_DARK_COLOR))
  340. .light_color(svg::Color(QR_LIGHT_COLOR))
  341. .quiet_zone(true)
  342. .build(),
  343. )
  344. }
  345. /// Purpose: check that a request carries the custom header required on POST routes.
  346. /// Inputs: `headers`, the request headers.
  347. /// Output: `true` if a header named `REQUEST_HEADER_NAME` (name matched case-insensitively) has
  348. /// exactly the value `REQUEST_HEADER_VALUE`, otherwise `false`.
  349. /// Side effects: none.
  350. fn has_request_header(headers: &[Header]) -> bool {
  351. headers
  352. .iter()
  353. .any(|header| header.field.equiv(REQUEST_HEADER_NAME) && header.value.as_str() == REQUEST_HEADER_VALUE)
  354. }
  355. /// Purpose: read a request body as trimmed UTF-8 text, refusing oversized bodies.
  356. /// Inputs: `reader`, the body reader.
  357. /// Output: `Some(text)` for a non-empty body of at most `MAX_BODY_BYTES`, otherwise `None`
  358. /// (read error, invalid UTF-8, empty or too large).
  359. /// Side effects: consumes at most `MAX_BODY_BYTES + 1` bytes from `reader`.
  360. fn read_limited_body<R: Read>(reader: R) -> Option<String> {
  361. let mut bytes = Vec::new();
  362. reader
  363. .take(MAX_BODY_BYTES as u64 + 1)
  364. .read_to_end(&mut bytes)
  365. .ok()?;
  366. if bytes.len() > MAX_BODY_BYTES {
  367. return None;
  368. }
  369. let text = String::from_utf8(bytes).ok()?;
  370. let trimmed = text.trim();
  371. (!trimmed.is_empty()).then(|| trimmed.to_owned())
  372. }
  373. #[cfg(test)]
  374. mod tests {
  375. use super::*;
  376. #[test]
  377. fn path_without_query_strips_query_string() {
  378. assert_eq!(path_without_query("/api/state?x=1&y=2"), "/api/state");
  379. assert_eq!(path_without_query("/api/state"), "/api/state");
  380. assert_eq!(path_without_query("/?"), "/");
  381. }
  382. #[test]
  383. fn resolve_route_matches_known_routes() {
  384. assert_eq!(resolve_route(&Method::Get, PATH_PAGE), Route::Page);
  385. assert_eq!(resolve_route(&Method::Get, PATH_STATE), Route::State);
  386. assert_eq!(resolve_route(&Method::Post, PATH_GLOBAL), Route::ToggleGlobal);
  387. assert_eq!(resolve_route(&Method::Post, PATH_CHAT), Route::ToggleChat);
  388. assert_eq!(resolve_route(&Method::Post, PATH_RELINK), Route::Relink);
  389. assert_eq!(resolve_route(&Method::Post, PATH_QUIT), Route::Quit);
  390. }
  391. #[test]
  392. fn relink_requires_the_request_header() {
  393. assert!(Route::Relink.requires_request_header());
  394. assert_eq!(resolve_route(&Method::Get, PATH_RELINK), Route::NotFound);
  395. }
  396. #[test]
  397. fn qr_svg_draws_an_svg_document() {
  398. let svg = qr_svg("2@ref,noise,identity,adv,1").expect("payload must encode");
  399. assert!(svg.contains("<svg") && svg.contains(QR_DARK_COLOR));
  400. }
  401. #[test]
  402. fn resolve_route_rejects_wrong_method_or_path() {
  403. assert_eq!(resolve_route(&Method::Post, PATH_STATE), Route::NotFound);
  404. assert_eq!(resolve_route(&Method::Get, PATH_QUIT), Route::NotFound);
  405. assert_eq!(resolve_route(&Method::Get, "/unknown"), Route::NotFound);
  406. }
  407. /// Builds a header from raw name and value for the tests.
  408. fn header(name: &str, value: &str) -> Header {
  409. Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid test header")
  410. }
  411. #[test]
  412. fn has_request_header_accepts_required_header_any_case() {
  413. assert!(has_request_header(&[header(REQUEST_HEADER_NAME, REQUEST_HEADER_VALUE)]));
  414. assert!(has_request_header(&[header("x-wh-request", REQUEST_HEADER_VALUE)]));
  415. assert!(has_request_header(&[
  416. header("Accept", "*/*"),
  417. header("X-WH-REQUEST", REQUEST_HEADER_VALUE),
  418. ]));
  419. }
  420. #[test]
  421. fn has_request_header_rejects_missing_or_wrong_value() {
  422. assert!(!has_request_header(&[]));
  423. assert!(!has_request_header(&[header("Accept", "*/*")]));
  424. assert!(!has_request_header(&[header(REQUEST_HEADER_NAME, "0")]));
  425. assert!(!has_request_header(&[header(REQUEST_HEADER_NAME, "")]));
  426. }
  427. #[test]
  428. fn only_post_routes_require_request_header() {
  429. assert!(Route::ToggleGlobal.requires_request_header());
  430. assert!(Route::ToggleChat.requires_request_header());
  431. assert!(Route::Quit.requires_request_header());
  432. assert!(!Route::Page.requires_request_header());
  433. assert!(!Route::State.requires_request_header());
  434. assert!(!Route::NotFound.requires_request_header());
  435. }
  436. #[test]
  437. fn read_limited_body_trims_text() {
  438. let body = read_limited_body(&b" 123@s.whatsapp.net\n"[..]);
  439. assert_eq!(body.as_deref(), Some("123@s.whatsapp.net"));
  440. }
  441. #[test]
  442. fn read_limited_body_rejects_empty_and_invalid() {
  443. assert_eq!(read_limited_body(&b" \n"[..]), None);
  444. assert_eq!(read_limited_body(&[0xff, 0xfe][..]), None);
  445. }
  446. #[test]
  447. fn read_limited_body_enforces_size_limit() {
  448. let at_limit = vec![b'a'; MAX_BODY_BYTES];
  449. let over_limit = vec![b'a'; MAX_BODY_BYTES + 1];
  450. assert!(read_limited_body(&at_limit[..]).is_some());
  451. assert_eq!(read_limited_body(&over_limit[..]), None);
  452. }
  453. }