carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1//! Routes and handlers. `GET /` and the filter pages live here too.
2
3use 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};
11use rusqlite::Connection;
12use serde::Deserialize;
13
14use crate::db::{self, Db};
15use crate::views::{self, Filter, Page};
16
17/// `make bump` rewrites this string so the build has to recompile this file.
18#[allow(dead_code)]
19const BUMP: &str = "unbumped";
20
21const LANGUAGE: &str = "Rust";
22const COOKIE: &str = "todo_list";
23/// Requests carrying this header get the list fragment back instead of a 303.
24const FRAGMENT_HEADER: &str = "x-todo-fragment";
25
26#[derive(Clone)]
27pub 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
35impl AppState {
36 fn secure(&self) -> bool {
37 self.origin.starts_with("https://")
38 }
39}
40
41pub 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.
63const 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.
74async 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.
93struct Visitor {
94 list: i64,
95 new_token: Option<String>,
96}
97
98fn 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
110fn 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.
118fn 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
129fn 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)]
142struct PageQuery {
143 edit: Option<String>,
144}
145
146async 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
150async 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
154async 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
158async 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)]
183struct ActionForm {
184 title: Option<String>,
185 filter: Option<String>,
186}
187
188enum 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.
196async fn act<F>(state: AppState, headers: HeaderMap, filter: Option<String>, action: F) -> Result<Response, AppError>
197where
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
229fn 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.
234fn parse_id(raw: &str) -> Option<i64> {
235 raw.parse().ok().filter(|id| *id > 0)
236}
237
238async 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
249async 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
259async 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
274async 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
284async 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
288async 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
294async 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
298async 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
305fn no_store() -> [(header::HeaderName, &'static str); 1] {
306 [(header::CACHE_CONTROL, "no-store")]
307}
308
309pub struct AppError(rusqlite::Error);
310
311impl From<rusqlite::Error> for AppError {
312 fn from(e: rusqlite::Error) -> Self {
313 AppError(e)
314 }
315}
316
317impl 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