//! HTTP surface for Anwesen. //! //! Implements the read-one-note endpoint per [ANW-13]: //! //! ```text //! GET /notes/ -> JSON {path, frontmatter, body, last_modified, etag, size} //! GET /notes/ + Accept: text/markdown -> raw file bytes //! GET /notes/ + If-None-Match: "" -> 304 on match //! ``` //! //! Subsequent issues bolt the folder index ([ANW-14]), query ([ANW-15]) and //! `/health` ([ANW-8]) onto the same [`Router`]. use std::sync::Arc; use axum::Router; use axum::body::Body; use axum::extract::{Path as AxumPath, State}; use axum::http::header::{ACCEPT, CONTENT_TYPE, ETAG, IF_NONE_MATCH}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use chrono::{DateTime, Utc}; use serde::Serialize; use std::collections::BTreeMap; use crate::store::NoteStore; use crate::vault::{Note, frontmatter_to_json}; /// Shared state injected into every handler. #[derive(Clone)] pub struct HttpState { pub store: Arc, } pub fn router(state: HttpState) -> Router { // `/notes/{*path}` is greedy and includes any trailing slash; one // handler dispatches read-one vs folder-listing on that suffix. The // root listing (`/notes/`) needs its own route since the wildcard // requires at least one character. Router::new() .route("/notes/", get(list_root_folder)) .route("/notes/{*path}", get(get_notes)) .with_state(state) } #[derive(Debug, Serialize)] struct NoteResponse<'a> { path: &'a str, frontmatter: serde_json::Value, body: &'a str, last_modified: String, etag: &'a str, size: u64, } impl<'a> From<&'a Note> for NoteResponse<'a> { fn from(note: &'a Note) -> Self { Self { path: ¬e.path, frontmatter: frontmatter_to_json(¬e.frontmatter), body: ¬e.body, last_modified: note.last_modified.to_rfc3339(), etag: ¬e.etag, size: note.size, } } } async fn get_notes( State(state): State, AxumPath(path): AxumPath, headers: HeaderMap, ) -> Response { if path.ends_with('/') { let folder = &path[..path.len() - 1]; match resolve_folder(folder) { Err(reason) => bad_request(reason).into_response(), Ok(canonical) => list_folder(&state, &canonical).into_response(), } } else { match resolve_path(&path) { Err(reason) => bad_request(reason).into_response(), Ok(canonical) => match state.store.get(&canonical) { None => not_found().into_response(), Some(note) => respond_note(¬e, &headers).into_response(), }, } } } async fn list_root_folder(State(state): State) -> Response { list_folder(&state, "").into_response() } fn respond_note(note: &Note, headers: &HeaderMap) -> Response { if let Some(client_etag) = headers.get(IF_NONE_MATCH) && etag_matches(client_etag, ¬e.etag) { return Response::builder() .status(StatusCode::NOT_MODIFIED) .header(ETAG, note.etag.as_str()) .body(Body::empty()) .expect("static response"); } if wants_markdown(headers) { return Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, "text/markdown; charset=utf-8") .header(ETAG, note.etag.as_str()) .body(Body::from(note.raw_bytes.clone())) .expect("static response"); } let body = serde_json::to_vec(&NoteResponse::from(note)).expect("note serializes"); Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, "application/json") .header(ETAG, note.etag.as_str()) .body(Body::from(body)) .expect("static response") } fn wants_markdown(headers: &HeaderMap) -> bool { headers .get_all(ACCEPT) .iter() .filter_map(|v| v.to_str().ok()) .flat_map(|s| s.split(',')) .any(|raw| { // Strip media-type parameters (`text/markdown; q=0.9`) before compare. let mime = raw.split(';').next().unwrap_or(raw).trim(); mime.eq_ignore_ascii_case("text/markdown") }) } fn etag_matches(client: &HeaderValue, server_etag: &str) -> bool { let Ok(s) = client.to_str() else { return false; }; // `If-None-Match` may carry one or more comma-separated entity-tags; // we ignore weak validators (W/) since the server only emits strong. s.split(',').any(|raw| { let trimmed = raw.trim(); let stripped = trimmed.strip_prefix("W/").unwrap_or(trimmed); stripped == server_etag || trimmed == "*" }) } fn bad_request(reason: &str) -> (StatusCode, String) { (StatusCode::BAD_REQUEST, format!("bad request: {reason}\n")) } fn not_found() -> (StatusCode, &'static str) { (StatusCode::NOT_FOUND, "not found\n") } #[derive(Debug, Serialize)] struct FolderResponse { path: String, entries: Vec, } #[derive(Debug, Serialize)] struct FolderEntry { name: String, #[serde(rename = "type")] kind: &'static str, last_modified: String, #[serde(skip_serializing_if = "Option::is_none")] size: Option, } fn list_folder(state: &HttpState, folder: &str) -> Response { let prefix = if folder.is_empty() { String::new() } else { format!("{folder}/") }; // Two maps: file_name -> (last_modified, size), dir_name -> max(last_modified). let mut files: BTreeMap, u64)> = BTreeMap::new(); let mut dirs: BTreeMap> = BTreeMap::new(); let mut found_any = false; state.store.with_read(|notes| { for (path, note) in notes { let Some(rel) = path.strip_prefix(prefix.as_str()) else { continue; }; // Empty rel happens if `path == prefix.trim_end_matches('/')`; // BTreeMap iteration with `strip_prefix` won't produce that // (the prefix has a trailing '/'), so a non-empty rel is the // only shape we see here. if rel.is_empty() { continue; } found_any = true; match rel.find('/') { Some(idx) => { let name = rel[..idx].to_string(); dirs.entry(name) .and_modify(|t| { if note.last_modified > *t { *t = note.last_modified; } }) .or_insert(note.last_modified); } None => { // Leaf file. Scanner already filtered to *.md. files.insert(rel.to_string(), (note.last_modified, note.size)); } } } }); if !found_any && !folder.is_empty() { return not_found().into_response(); } let mut entries: Vec = Vec::with_capacity(files.len() + dirs.len()); for (name, (lm, size)) in files { entries.push(FolderEntry { name, kind: "file", last_modified: lm.to_rfc3339(), size: Some(size), }); } for (name, lm) in dirs { entries.push(FolderEntry { name, kind: "dir", last_modified: lm.to_rfc3339(), size: None, }); } entries.sort_by(|a, b| a.name.cmp(&b.name)); let body = FolderResponse { path: folder.to_string(), entries, }; let bytes = serde_json::to_vec(&body).expect("folder response serializes"); Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, "application/json") .body(Body::from(bytes)) .expect("static response") } /// Normalize a request path per the User Manual: /// /// - URL-decode the entire path once; /// - strip leading slashes; /// - reject any `..` segment (or empty segment / `.`) with `400`. /// /// Returns the vault-relative form (forward-slash, no leading slash). fn resolve_path(raw: &str) -> Result { let decoded = percent_decode(raw).ok_or("invalid percent-encoding")?; let stripped = decoded.trim_start_matches('/').to_string(); if stripped.is_empty() { return Err("path is empty"); } for seg in stripped.split('/') { if seg.is_empty() || seg == "." || seg == ".." { return Err("path contains forbidden segment"); } } Ok(stripped) } /// Same normalization as [`resolve_path`] but accepts the empty string -- a /// folder listing of the vault root via `/notes/`. fn resolve_folder(raw: &str) -> Result { let decoded = percent_decode(raw).ok_or("invalid percent-encoding")?; let stripped = decoded.trim_start_matches('/').to_string(); if stripped.is_empty() { return Ok(String::new()); } for seg in stripped.split('/') { if seg.is_empty() || seg == "." || seg == ".." { return Err("path contains forbidden segment"); } } Ok(stripped) } /// Tiny dependency-free single-pass percent-decoder. Anwesen URL-decodes /// each path component exactly once per the User Manual contract; we keep /// the decoder small rather than pull `percent-encoding` for one call. fn percent_decode(input: &str) -> Option { let bytes = input.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' { if i + 2 >= bytes.len() { return None; } let hi = hex_value(bytes[i + 1])?; let lo = hex_value(bytes[i + 2])?; out.push((hi << 4) | lo); i += 3; } else { out.push(bytes[i]); i += 1; } } String::from_utf8(out).ok() } fn hex_value(b: u8) -> Option { match b { b'0'..=b'9' => Some(b - b'0'), b'a'..=b'f' => Some(b - b'a' + 10), b'A'..=b'F' => Some(b - b'A' + 10), _ => None, } } #[cfg(test)] mod tests { use super::*; use axum::body::to_bytes; use axum::http::Request; use chrono::DateTime; use std::collections::BTreeMap; use tower::ServiceExt; use crate::vault::Value; fn note(path: &str, body: &str) -> Note { let mut fm: BTreeMap = BTreeMap::new(); fm.insert("tag".into(), Value::String("demo".into())); let raw = format!("---\ntag: demo\n---\n{body}"); let etag = format!("\"{}\"", blake3::hash(raw.as_bytes()).to_hex()); let size = raw.len() as u64; Note { path: path.into(), frontmatter: fm, body: body.into(), raw_bytes: raw.into_bytes(), last_modified: DateTime::from_timestamp(0, 0).unwrap(), etag, size, } } fn router_with(notes: Vec) -> (Router, Arc) { let store = NoteStore::new(); store.replace(notes); let r = router(HttpState { store: store.clone(), }); (r, store) } async fn send(router: Router, req: Request) -> Response { router.oneshot(req).await.expect("oneshot") } #[tokio::test] async fn unknown_path_returns_404() { let (r, _) = router_with(vec![]); let req = Request::get("/notes/missing.md") .body(Body::empty()) .unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn double_dot_rejected_with_400() { let (r, _) = router_with(vec![note("a.md", "x")]); let req = Request::get("/notes/../etc/passwd") .body(Body::empty()) .unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn percent_encoded_path_decoded_once() { let (r, _) = router_with(vec![note("dir with space/a.md", "x")]); let req = Request::get("/notes/dir%20with%20space/a.md") .body(Body::empty()) .unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn default_response_is_json_with_etag_header() { let n = note("a.md", "body"); let expected_etag = n.etag.clone(); let (r, _) = router_with(vec![n]); let req = Request::get("/notes/a.md").body(Body::empty()).unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get(CONTENT_TYPE).unwrap(), "application/json" ); assert_eq!(resp.headers().get(ETAG).unwrap(), expected_etag.as_str()); let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(v["path"], "a.md"); assert_eq!(v["body"], "body"); assert!(v["frontmatter"].is_object()); } #[tokio::test] async fn accept_text_markdown_returns_raw_bytes() { let n = note("a.md", "body"); let raw = n.raw_bytes.clone(); let (r, _) = router_with(vec![n]); let req = Request::get("/notes/a.md") .header(ACCEPT, "text/markdown") .body(Body::empty()) .unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get(CONTENT_TYPE).unwrap(), "text/markdown; charset=utf-8" ); let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap(); assert_eq!(bytes.as_ref(), raw.as_slice()); } #[tokio::test] async fn if_none_match_returns_304() { let n = note("a.md", "body"); let etag = n.etag.clone(); let (r, _) = router_with(vec![n]); let req = Request::get("/notes/a.md") .header(IF_NONE_MATCH, etag.clone()) .body(Body::empty()) .unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::NOT_MODIFIED); assert_eq!(resp.headers().get(ETAG).unwrap(), etag.as_str()); // 304 must not carry a body. let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap(); assert!(bytes.is_empty()); } #[tokio::test] async fn if_none_match_star_returns_304() { let n = note("a.md", "body"); let (r, _) = router_with(vec![n]); let req = Request::get("/notes/a.md") .header(IF_NONE_MATCH, "*") .body(Body::empty()) .unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::NOT_MODIFIED); } #[tokio::test] async fn if_none_match_with_different_etag_returns_full_body() { let n = note("a.md", "body"); let (r, _) = router_with(vec![n]); let req = Request::get("/notes/a.md") .header(IF_NONE_MATCH, "\"other\"") .body(Body::empty()) .unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn folder_listing_groups_files_and_dirs() { let notes = vec![ note("Projects/a.md", "x"), note("Projects/anwesen/Anwesen.md", "x"), note("Projects/anwesen/User Manual.md", "x"), note("Projects/anwesen/ADR/ADR-001.md", "x"), note("Notes/other.md", "x"), ]; let (r, _) = router_with(notes); let req = Request::get("/notes/Projects/anwesen/") .body(Body::empty()) .unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::OK); let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(v["path"], "Projects/anwesen"); let entries = v["entries"].as_array().expect("entries array"); let names: Vec<&str> = entries .iter() .map(|e| e["name"].as_str().unwrap()) .collect(); assert_eq!(names, vec!["ADR", "Anwesen.md", "User Manual.md"]); // ADR is the only dir; the other two are files (lex-sorted). assert_eq!(entries[0]["type"], "dir"); assert!(entries[0].get("size").is_none()); assert_eq!(entries[1]["type"], "file"); assert_eq!(entries[1]["size"].as_u64().unwrap(), note("x", "x").size); } #[tokio::test] async fn folder_listing_root_lists_top_level() { let notes = vec![ note("a.md", "x"), note("Projects/anwesen/x.md", "x"), note("Notes/other.md", "x"), ]; let (r, _) = router_with(notes); let req = Request::get("/notes/").body(Body::empty()).unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::OK); let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(v["path"], ""); let names: Vec<&str> = v["entries"] .as_array() .unwrap() .iter() .map(|e| e["name"].as_str().unwrap()) .collect(); assert_eq!(names, vec!["Notes", "Projects", "a.md"]); } #[tokio::test] async fn folder_listing_unknown_returns_404() { let (r, _) = router_with(vec![note("a.md", "x")]); let req = Request::get("/notes/no-such-folder/") .body(Body::empty()) .unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn folder_listing_double_dot_rejected_with_400() { let (r, _) = router_with(vec![note("a.md", "x")]); let req = Request::get("/notes/Projects/../etc/") .body(Body::empty()) .unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn folder_listing_prefix_does_not_match_partial_segment() { // "Proj" is not a folder; only "Projects" is. let (r, _) = router_with(vec![note("Projects/a.md", "x"), note("Projet/b.md", "x")]); let req = Request::get("/notes/Proj/").body(Body::empty()).unwrap(); let resp = send(r, req).await; assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[test] fn resolve_path_normalizations() { assert_eq!(resolve_path("Notes/a.md").unwrap(), "Notes/a.md"); assert_eq!(resolve_path("/Notes/a.md").unwrap(), "Notes/a.md"); assert_eq!(resolve_path("////Notes/a.md").unwrap(), "Notes/a.md"); assert_eq!(resolve_path("Notes/a%20b.md").unwrap(), "Notes/a b.md"); assert!(resolve_path("..").is_err()); assert!(resolve_path("a/../b").is_err()); assert!(resolve_path("a//b").is_err()); assert!(resolve_path("a/./b").is_err()); assert!(resolve_path("").is_err()); assert!(resolve_path("a/%ZZ.md").is_err()); } }