| 1 | //! Routes and handlers. `GET /` and the filter pages live here too. |
| 2 | |
| 3 | use axum::{ |
| 4 | Router, |
| 5 | extract::{Form, Path, Query, Request, State}, |
| 6 | http::{HeaderMap, HeaderValue, Method, StatusCode, header}, |
| 7 | middleware::{self, Next}, |
| 8 | response::{Html, IntoResponse, Redirect, Response}, |
| 9 | routing::{get, post}, |
| 10 | }; |
| 11 | use rusqlite::Connection; |
| 12 | use serde::Deserialize; |
| 13 | |
| 14 | use crate::db::{self, Db}; |
| 15 | use crate::views::{self, Filter, Page}; |
| 16 | |
| 17 | /// `make bump` rewrites this string so the build has to recompile this file. |
| 18 | #[allow(dead_code)] |
| 19 | const BUMP: &str = "unbumped"; |
| 20 | |
| 21 | const LANGUAGE: &str = "Rust"; |
| 22 | const COOKIE: &str = "todo_list"; |
| 23 | /// Requests carrying this header get the list fragment back instead of a 303. |
| 24 | const FRAGMENT_HEADER: &str = "x-todo-fragment"; |
| 25 | |
| 26 | #[derive(Clone)] |
| 27 | pub struct AppState { |
| 28 | pub db: Db, |
| 29 | /// The app's own origin, e.g. `https://todo-rust.bfj.oncarlos.com`. |
| 30 | /// POSTs whose `Origin` names anything else are refused. |
| 31 | pub origin: String, |
| 32 | pub version: &'static str, |
| 33 | } |
| 34 | |
| 35 | impl AppState { |
| 36 | fn secure(&self) -> bool { |
| 37 | self.origin.starts_with("https://") |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | pub fn router(state: AppState) -> Router { |
| 42 | Router::new() |
| 43 | .route("/", get(page_all)) |
| 44 | .route("/active", get(page_active)) |
| 45 | .route("/completed", get(page_completed)) |
| 46 | .route("/todos", post(add)) |
| 47 | .route("/todos/toggle-all", post(toggle_all)) |
| 48 | .route("/todos/clear-completed", post(clear_completed)) |
| 49 | .route("/todos/{id}/toggle", post(toggle)) |
| 50 | .route("/todos/{id}/edit", post(edit)) |
| 51 | .route("/todos/{id}/destroy", post(destroy)) |
| 52 | .route("/static/base.css", get(|| css(include_str!("../../../shared/base.css")))) |
| 53 | .route("/static/index.css", get(|| css(include_str!("../../../shared/index.css")))) |
| 54 | .route("/static/app.js", get(app_js)) |
| 55 | .route("/healthz", get(|| async { "ok" })) |
| 56 | .route("/api/version", get(|State(s): State<AppState>| async move { s.version })) |
| 57 | .layer(middleware::from_fn_with_state(state.clone(), same_origin)) |
| 58 | .with_state(state) |
| 59 | } |
| 60 | |
| 61 | /// The largest request body we read: a 10,000-character title is ~90 KB |
| 62 | /// form-encoded at worst. |
| 63 | const BODY_LIMIT: usize = 1 << 20; |
| 64 | |
| 65 | /// Reads the whole request body before anything answers, then refuses a |
| 66 | /// POST whose `Origin` is present and isn't ours. |
| 67 | /// |
| 68 | /// Reading first matters behind Carloku's edge: a response sent while the |
| 69 | /// body is still unread (a 403 here, a 404 for an unknown route) makes |
| 70 | /// hyper drop the connection, and the edge reports that as a 502. |
| 71 | /// |
| 72 | /// No `Origin` header is allowed: the contract has no CSRF token, and |
| 73 | /// SameSite=Lax already keeps the cookie off cross-site POSTs. |
| 74 | async fn same_origin(State(s): State<AppState>, req: Request, next: Next) -> Response { |
| 75 | let (parts, body) = req.into_parts(); |
| 76 | let Ok(bytes) = axum::body::to_bytes(body, BODY_LIMIT).await else { |
| 77 | return StatusCode::PAYLOAD_TOO_LARGE.into_response(); |
| 78 | }; |
| 79 | if parts.method == Method::POST { |
| 80 | if let Some(origin) = parts.headers.get(header::ORIGIN) { |
| 81 | if origin.as_bytes() != s.origin.as_bytes() { |
| 82 | return StatusCode::FORBIDDEN.into_response(); |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | next.run(Request::from_parts(parts, axum::body::Body::from(bytes))).await |
| 87 | } |
| 88 | |
| 89 | // ---- the visitor's list ---------------------------------------------- |
| 90 | |
| 91 | /// The list a request belongs to, plus the cookie to set if it needed a |
| 92 | /// new one. |
| 93 | struct Visitor { |
| 94 | list: i64, |
| 95 | new_token: Option<String>, |
| 96 | } |
| 97 | |
| 98 | fn cookie_token(headers: &HeaderMap) -> Option<String> { |
| 99 | headers |
| 100 | .get_all(header::COOKIE) |
| 101 | .iter() |
| 102 | .filter_map(|v| v.to_str().ok()) |
| 103 | .flat_map(|v| v.split(';')) |
| 104 | .filter_map(|pair| pair.trim().split_once('=')) |
| 105 | .find(|(name, _)| *name == COOKIE) |
| 106 | .map(|(_, value)| value.to_owned()) |
| 107 | .filter(|v| v.len() == 32 && v.bytes().all(|b| b.is_ascii_hexdigit())) |
| 108 | } |
| 109 | |
| 110 | fn new_token() -> String { |
| 111 | let mut bytes = [0u8; 16]; |
| 112 | getrandom::fill(&mut bytes).expect("no system randomness"); |
| 113 | bytes.iter().map(|b| format!("{b:02x}")).collect() |
| 114 | } |
| 115 | |
| 116 | /// Finds the list the cookie names. A missing, garbled or unknown cookie |
| 117 | /// gets a fresh empty list. |
| 118 | fn resolve(conn: &Connection, token: Option<String>) -> rusqlite::Result<Visitor> { |
| 119 | if let Some(token) = token { |
| 120 | if let Some(list) = db::find_list(conn, &token)? { |
| 121 | return Ok(Visitor { list, new_token: None }); |
| 122 | } |
| 123 | } |
| 124 | let token = new_token(); |
| 125 | let list = db::create_list(conn, &token)?; |
| 126 | Ok(Visitor { list, new_token: Some(token) }) |
| 127 | } |
| 128 | |
| 129 | fn set_cookie(state: &AppState, visitor: &Visitor, response: &mut Response) { |
| 130 | if let Some(token) = &visitor.new_token { |
| 131 | let secure = if state.secure() { "; Secure" } else { "" }; |
| 132 | let value = format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000{secure}"); |
| 133 | response |
| 134 | .headers_mut() |
| 135 | .append(header::SET_COOKIE, HeaderValue::from_str(&value).expect("ascii cookie")); |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // ---- pages ----------------------------------------------------------- |
| 140 | |
| 141 | #[derive(Deserialize)] |
| 142 | struct PageQuery { |
| 143 | edit: Option<String>, |
| 144 | } |
| 145 | |
| 146 | async fn page_all(s: State<AppState>, h: HeaderMap, q: Query<PageQuery>) -> Result<Response, AppError> { |
| 147 | render_page(s, h, q, Filter::All).await |
| 148 | } |
| 149 | |
| 150 | async fn page_active(s: State<AppState>, h: HeaderMap, q: Query<PageQuery>) -> Result<Response, AppError> { |
| 151 | render_page(s, h, q, Filter::Active).await |
| 152 | } |
| 153 | |
| 154 | async fn page_completed(s: State<AppState>, h: HeaderMap, q: Query<PageQuery>) -> Result<Response, AppError> { |
| 155 | render_page(s, h, q, Filter::Completed).await |
| 156 | } |
| 157 | |
| 158 | async fn render_page( |
| 159 | State(state): State<AppState>, |
| 160 | headers: HeaderMap, |
| 161 | Query(q): Query<PageQuery>, |
| 162 | filter: Filter, |
| 163 | ) -> Result<Response, AppError> { |
| 164 | let token = cookie_token(&headers); |
| 165 | let (visitor, todos) = state |
| 166 | .db |
| 167 | .run(move |c| { |
| 168 | let v = resolve(c, token)?; |
| 169 | let todos = db::todos(c, v.list)?; |
| 170 | Ok((v, todos)) |
| 171 | }) |
| 172 | .await?; |
| 173 | let editing = q.edit.and_then(|e| e.parse().ok()); |
| 174 | let markup = views::page(&Page { todos: &todos, filter, editing, language: LANGUAGE }); |
| 175 | let mut response = (no_store(), Html(markup.into_string())).into_response(); |
| 176 | set_cookie(&state, &visitor, &mut response); |
| 177 | Ok(response) |
| 178 | } |
| 179 | |
| 180 | // ---- actions --------------------------------------------------------- |
| 181 | |
| 182 | #[derive(Deserialize)] |
| 183 | struct ActionForm { |
| 184 | title: Option<String>, |
| 185 | filter: Option<String>, |
| 186 | } |
| 187 | |
| 188 | enum Outcome { |
| 189 | Done, |
| 190 | NotFound, |
| 191 | } |
| 192 | |
| 193 | /// Runs one action against the visitor's list, then answers the way the |
| 194 | /// contract says: 404 for an id not in the list, otherwise a 303 back to |
| 195 | /// the filter, or the list fragment when our JavaScript asked for it. |
| 196 | async fn act<F>(state: AppState, headers: HeaderMap, filter: Option<String>, action: F) -> Result<Response, AppError> |
| 197 | where |
| 198 | F: FnOnce(&Connection, i64) -> rusqlite::Result<Outcome> + Send + 'static, |
| 199 | { |
| 200 | let filter = Filter::from_field(filter.as_deref()); |
| 201 | let token = cookie_token(&headers); |
| 202 | let want_fragment = headers.contains_key(FRAGMENT_HEADER); |
| 203 | let (visitor, outcome, todos) = state |
| 204 | .db |
| 205 | .run(move |c| { |
| 206 | let v = resolve(c, token)?; |
| 207 | let tx = c.transaction()?; |
| 208 | let outcome = action(&tx, v.list)?; |
| 209 | tx.commit()?; |
| 210 | let todos = match (&outcome, want_fragment) { |
| 211 | (Outcome::Done, true) => Some(db::todos(c, v.list)?), |
| 212 | _ => None, |
| 213 | }; |
| 214 | Ok((v, outcome, todos)) |
| 215 | }) |
| 216 | .await?; |
| 217 | let mut response = match (outcome, todos) { |
| 218 | (Outcome::NotFound, _) => StatusCode::NOT_FOUND.into_response(), |
| 219 | (Outcome::Done, Some(todos)) => { |
| 220 | let markup = views::list(&Page { todos: &todos, filter, editing: None, language: LANGUAGE }); |
| 221 | (no_store(), Html(markup.into_string())).into_response() |
| 222 | } |
| 223 | (Outcome::Done, None) => Redirect::to(filter.path()).into_response(), |
| 224 | }; |
| 225 | set_cookie(&state, &visitor, &mut response); |
| 226 | Ok(response) |
| 227 | } |
| 228 | |
| 229 | fn found(hit: bool) -> Outcome { |
| 230 | if hit { Outcome::Done } else { Outcome::NotFound } |
| 231 | } |
| 232 | |
| 233 | /// Ids are positive integers; anything else can't be in the list. |
| 234 | fn parse_id(raw: &str) -> Option<i64> { |
| 235 | raw.parse().ok().filter(|id| *id > 0) |
| 236 | } |
| 237 | |
| 238 | async fn add(State(s): State<AppState>, h: HeaderMap, Form(f): Form<ActionForm>) -> Result<Response, AppError> { |
| 239 | let title = f.title.unwrap_or_default().trim().to_owned(); |
| 240 | act(s, h, f.filter, move |c, list| { |
| 241 | if !title.is_empty() { |
| 242 | db::add(c, list, &title)?; |
| 243 | } |
| 244 | Ok(Outcome::Done) |
| 245 | }) |
| 246 | .await |
| 247 | } |
| 248 | |
| 249 | async fn toggle( |
| 250 | State(s): State<AppState>, |
| 251 | h: HeaderMap, |
| 252 | Path(id): Path<String>, |
| 253 | Form(f): Form<ActionForm>, |
| 254 | ) -> Result<Response, AppError> { |
| 255 | let Some(id) = parse_id(&id) else { return Ok(StatusCode::NOT_FOUND.into_response()) }; |
| 256 | act(s, h, f.filter, move |c, list| Ok(found(db::toggle(c, list, id)?))).await |
| 257 | } |
| 258 | |
| 259 | async fn edit( |
| 260 | State(s): State<AppState>, |
| 261 | h: HeaderMap, |
| 262 | Path(id): Path<String>, |
| 263 | Form(f): Form<ActionForm>, |
| 264 | ) -> Result<Response, AppError> { |
| 265 | let Some(id) = parse_id(&id) else { return Ok(StatusCode::NOT_FOUND.into_response()) }; |
| 266 | let title = f.title.unwrap_or_default().trim().to_owned(); |
| 267 | act(s, h, f.filter, move |c, list| { |
| 268 | let hit = if title.is_empty() { db::destroy(c, list, id)? } else { db::rename(c, list, id, &title)? }; |
| 269 | Ok(found(hit)) |
| 270 | }) |
| 271 | .await |
| 272 | } |
| 273 | |
| 274 | async fn destroy( |
| 275 | State(s): State<AppState>, |
| 276 | h: HeaderMap, |
| 277 | Path(id): Path<String>, |
| 278 | Form(f): Form<ActionForm>, |
| 279 | ) -> Result<Response, AppError> { |
| 280 | let Some(id) = parse_id(&id) else { return Ok(StatusCode::NOT_FOUND.into_response()) }; |
| 281 | act(s, h, f.filter, move |c, list| Ok(found(db::destroy(c, list, id)?))).await |
| 282 | } |
| 283 | |
| 284 | async fn toggle_all(State(s): State<AppState>, h: HeaderMap, Form(f): Form<ActionForm>) -> Result<Response, AppError> { |
| 285 | act(s, h, f.filter, |c, list| db::toggle_all(c, list).map(|_| Outcome::Done)).await |
| 286 | } |
| 287 | |
| 288 | async fn clear_completed(State(s): State<AppState>, h: HeaderMap, Form(f): Form<ActionForm>) -> Result<Response, AppError> { |
| 289 | act(s, h, f.filter, |c, list| db::clear_completed(c, list).map(|_| Outcome::Done)).await |
| 290 | } |
| 291 | |
| 292 | // ---- static files and helpers ---------------------------------------- |
| 293 | |
| 294 | async fn css(body: &'static str) -> impl IntoResponse { |
| 295 | ([(header::CONTENT_TYPE, "text/css; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=3600")], body) |
| 296 | } |
| 297 | |
| 298 | async fn app_js() -> impl IntoResponse { |
| 299 | ( |
| 300 | [(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=3600")], |
| 301 | include_str!("../static/app.js"), |
| 302 | ) |
| 303 | } |
| 304 | |
| 305 | fn no_store() -> [(header::HeaderName, &'static str); 1] { |
| 306 | [(header::CACHE_CONTROL, "no-store")] |
| 307 | } |
| 308 | |
| 309 | pub struct AppError(rusqlite::Error); |
| 310 | |
| 311 | impl From<rusqlite::Error> for AppError { |
| 312 | fn from(e: rusqlite::Error) -> Self { |
| 313 | AppError(e) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | impl IntoResponse for AppError { |
| 318 | fn into_response(self) -> Response { |
| 319 | eprintln!("database error: {}", self.0); |
| 320 | (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong.\n").into_response() |
| 321 | } |
| 322 | } |
| 323 | |