//! Routes and handlers. `GET /` and the filter pages live here too. use axum::{ Router, extract::{Form, Path, Query, Request, State}, http::{HeaderMap, HeaderValue, Method, StatusCode, header}, middleware::{self, Next}, response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, }; use rusqlite::Connection; use serde::Deserialize; use crate::db::{self, Db}; use crate::views::{self, Filter, Page}; /// `make bump` rewrites this string so the build has to recompile this file. #[allow(dead_code)] const BUMP: &str = "unbumped"; const LANGUAGE: &str = "Rust"; const COOKIE: &str = "todo_list"; /// Requests carrying this header get the list fragment back instead of a 303. const FRAGMENT_HEADER: &str = "x-todo-fragment"; #[derive(Clone)] pub struct AppState { pub db: Db, /// The app's own origin, e.g. `https://todo-rust.bfj.oncarlos.com`. /// POSTs whose `Origin` names anything else are refused. pub origin: String, pub version: &'static str, } impl AppState { fn secure(&self) -> bool { self.origin.starts_with("https://") } } pub fn router(state: AppState) -> Router { Router::new() .route("/", get(page_all)) .route("/active", get(page_active)) .route("/completed", get(page_completed)) .route("/todos", post(add)) .route("/todos/toggle-all", post(toggle_all)) .route("/todos/clear-completed", post(clear_completed)) .route("/todos/{id}/toggle", post(toggle)) .route("/todos/{id}/edit", post(edit)) .route("/todos/{id}/destroy", post(destroy)) .route("/static/base.css", get(|| css(include_str!("../../../shared/base.css")))) .route("/static/index.css", get(|| css(include_str!("../../../shared/index.css")))) .route("/static/app.js", get(app_js)) .route("/healthz", get(|| async { "ok" })) .route("/api/version", get(|State(s): State| async move { s.version })) .layer(middleware::from_fn_with_state(state.clone(), same_origin)) .with_state(state) } /// The largest request body we read: a 10,000-character title is ~90 KB /// form-encoded at worst. const BODY_LIMIT: usize = 1 << 20; /// Reads the whole request body before anything answers, then refuses a /// POST whose `Origin` is present and isn't ours. /// /// Reading first matters behind Carloku's edge: a response sent while the /// body is still unread (a 403 here, a 404 for an unknown route) makes /// hyper drop the connection, and the edge reports that as a 502. /// /// No `Origin` header is allowed: the contract has no CSRF token, and /// SameSite=Lax already keeps the cookie off cross-site POSTs. async fn same_origin(State(s): State, req: Request, next: Next) -> Response { let (parts, body) = req.into_parts(); let Ok(bytes) = axum::body::to_bytes(body, BODY_LIMIT).await else { return StatusCode::PAYLOAD_TOO_LARGE.into_response(); }; if parts.method == Method::POST { if let Some(origin) = parts.headers.get(header::ORIGIN) { if origin.as_bytes() != s.origin.as_bytes() { return StatusCode::FORBIDDEN.into_response(); } } } next.run(Request::from_parts(parts, axum::body::Body::from(bytes))).await } // ---- the visitor's list ---------------------------------------------- /// The list a request belongs to, plus the cookie to set if it needed a /// new one. struct Visitor { list: i64, new_token: Option, } fn cookie_token(headers: &HeaderMap) -> Option { headers .get_all(header::COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .flat_map(|v| v.split(';')) .filter_map(|pair| pair.trim().split_once('=')) .find(|(name, _)| *name == COOKIE) .map(|(_, value)| value.to_owned()) .filter(|v| v.len() == 32 && v.bytes().all(|b| b.is_ascii_hexdigit())) } fn new_token() -> String { let mut bytes = [0u8; 16]; getrandom::fill(&mut bytes).expect("no system randomness"); bytes.iter().map(|b| format!("{b:02x}")).collect() } /// Finds the list the cookie names. A missing, garbled or unknown cookie /// gets a fresh empty list. fn resolve(conn: &Connection, token: Option) -> rusqlite::Result { if let Some(token) = token { if let Some(list) = db::find_list(conn, &token)? { return Ok(Visitor { list, new_token: None }); } } let token = new_token(); let list = db::create_list(conn, &token)?; Ok(Visitor { list, new_token: Some(token) }) } fn set_cookie(state: &AppState, visitor: &Visitor, response: &mut Response) { if let Some(token) = &visitor.new_token { let secure = if state.secure() { "; Secure" } else { "" }; let value = format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000{secure}"); response .headers_mut() .append(header::SET_COOKIE, HeaderValue::from_str(&value).expect("ascii cookie")); } } // ---- pages ----------------------------------------------------------- #[derive(Deserialize)] struct PageQuery { edit: Option, } async fn page_all(s: State, h: HeaderMap, q: Query) -> Result { render_page(s, h, q, Filter::All).await } async fn page_active(s: State, h: HeaderMap, q: Query) -> Result { render_page(s, h, q, Filter::Active).await } async fn page_completed(s: State, h: HeaderMap, q: Query) -> Result { render_page(s, h, q, Filter::Completed).await } async fn render_page( State(state): State, headers: HeaderMap, Query(q): Query, filter: Filter, ) -> Result { let token = cookie_token(&headers); let (visitor, todos) = state .db .run(move |c| { let v = resolve(c, token)?; let todos = db::todos(c, v.list)?; Ok((v, todos)) }) .await?; let editing = q.edit.and_then(|e| e.parse().ok()); let markup = views::page(&Page { todos: &todos, filter, editing, language: LANGUAGE }); let mut response = (no_store(), Html(markup.into_string())).into_response(); set_cookie(&state, &visitor, &mut response); Ok(response) } // ---- actions --------------------------------------------------------- #[derive(Deserialize)] struct ActionForm { title: Option, filter: Option, } enum Outcome { Done, NotFound, } /// Runs one action against the visitor's list, then answers the way the /// contract says: 404 for an id not in the list, otherwise a 303 back to /// the filter, or the list fragment when our JavaScript asked for it. async fn act(state: AppState, headers: HeaderMap, filter: Option, action: F) -> Result where F: FnOnce(&Connection, i64) -> rusqlite::Result + Send + 'static, { let filter = Filter::from_field(filter.as_deref()); let token = cookie_token(&headers); let want_fragment = headers.contains_key(FRAGMENT_HEADER); let (visitor, outcome, todos) = state .db .run(move |c| { let v = resolve(c, token)?; let tx = c.transaction()?; let outcome = action(&tx, v.list)?; tx.commit()?; let todos = match (&outcome, want_fragment) { (Outcome::Done, true) => Some(db::todos(c, v.list)?), _ => None, }; Ok((v, outcome, todos)) }) .await?; let mut response = match (outcome, todos) { (Outcome::NotFound, _) => StatusCode::NOT_FOUND.into_response(), (Outcome::Done, Some(todos)) => { let markup = views::list(&Page { todos: &todos, filter, editing: None, language: LANGUAGE }); (no_store(), Html(markup.into_string())).into_response() } (Outcome::Done, None) => Redirect::to(filter.path()).into_response(), }; set_cookie(&state, &visitor, &mut response); Ok(response) } fn found(hit: bool) -> Outcome { if hit { Outcome::Done } else { Outcome::NotFound } } /// Ids are positive integers; anything else can't be in the list. fn parse_id(raw: &str) -> Option { raw.parse().ok().filter(|id| *id > 0) } async fn add(State(s): State, h: HeaderMap, Form(f): Form) -> Result { let title = f.title.unwrap_or_default().trim().to_owned(); act(s, h, f.filter, move |c, list| { if !title.is_empty() { db::add(c, list, &title)?; } Ok(Outcome::Done) }) .await } async fn toggle( State(s): State, h: HeaderMap, Path(id): Path, Form(f): Form, ) -> Result { let Some(id) = parse_id(&id) else { return Ok(StatusCode::NOT_FOUND.into_response()) }; act(s, h, f.filter, move |c, list| Ok(found(db::toggle(c, list, id)?))).await } async fn edit( State(s): State, h: HeaderMap, Path(id): Path, Form(f): Form, ) -> Result { let Some(id) = parse_id(&id) else { return Ok(StatusCode::NOT_FOUND.into_response()) }; let title = f.title.unwrap_or_default().trim().to_owned(); act(s, h, f.filter, move |c, list| { let hit = if title.is_empty() { db::destroy(c, list, id)? } else { db::rename(c, list, id, &title)? }; Ok(found(hit)) }) .await } async fn destroy( State(s): State, h: HeaderMap, Path(id): Path, Form(f): Form, ) -> Result { let Some(id) = parse_id(&id) else { return Ok(StatusCode::NOT_FOUND.into_response()) }; act(s, h, f.filter, move |c, list| Ok(found(db::destroy(c, list, id)?))).await } async fn toggle_all(State(s): State, h: HeaderMap, Form(f): Form) -> Result { act(s, h, f.filter, |c, list| db::toggle_all(c, list).map(|_| Outcome::Done)).await } async fn clear_completed(State(s): State, h: HeaderMap, Form(f): Form) -> Result { act(s, h, f.filter, |c, list| db::clear_completed(c, list).map(|_| Outcome::Done)).await } // ---- static files and helpers ---------------------------------------- async fn css(body: &'static str) -> impl IntoResponse { ([(header::CONTENT_TYPE, "text/css; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=3600")], body) } async fn app_js() -> impl IntoResponse { ( [(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=3600")], include_str!("../static/app.js"), ) } fn no_store() -> [(header::HeaderName, &'static str); 1] { [(header::CACHE_CONTROL, "no-store")] } pub struct AppError(rusqlite::Error); impl From for AppError { fn from(e: rusqlite::Error) -> Self { AppError(e) } } impl IntoResponse for AppError { fn into_response(self) -> Response { eprintln!("database error: {}", self.0); (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong.\n").into_response() } }