ANW-14 GET /notes/<folder>/ directory listing
This commit is contained in:
parent
e1fe1dcb4e
commit
da2dfbbd6d
2 changed files with 235 additions and 8 deletions
229
src/http.rs
229
src/http.rs
|
|
@ -20,7 +20,9 @@ use axum::http::header::{ACCEPT, CONTENT_TYPE, ETAG, IF_NONE_MATCH};
|
||||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crate::store::NoteStore;
|
use crate::store::NoteStore;
|
||||||
use crate::vault::{Note, frontmatter_to_json};
|
use crate::vault::{Note, frontmatter_to_json};
|
||||||
|
|
@ -32,8 +34,13 @@ pub struct HttpState {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: HttpState) -> Router {
|
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()
|
Router::new()
|
||||||
.route("/notes/{*path}", get(get_note))
|
.route("/notes/", get(list_root_folder))
|
||||||
|
.route("/notes/{*path}", get(get_notes))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,20 +67,32 @@ impl<'a> From<&'a Note> for NoteResponse<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_note(
|
async fn get_notes(
|
||||||
State(state): State<HttpState>,
|
State(state): State<HttpState>,
|
||||||
AxumPath(path): AxumPath<String>,
|
AxumPath(path): AxumPath<String>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match resolve_path(&path) {
|
if path.ends_with('/') {
|
||||||
Err(reason) => bad_request(reason).into_response(),
|
let folder = &path[..path.len() - 1];
|
||||||
Ok(canonical) => match state.store.get(&canonical) {
|
match resolve_folder(folder) {
|
||||||
None => not_found().into_response(),
|
Err(reason) => bad_request(reason).into_response(),
|
||||||
Some(note) => respond_note(¬e, &headers).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<HttpState>) -> Response {
|
||||||
|
list_folder(&state, "").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
fn respond_note(note: &Note, headers: &HeaderMap) -> Response {
|
fn respond_note(note: &Note, headers: &HeaderMap) -> Response {
|
||||||
if let Some(client_etag) = headers.get(IF_NONE_MATCH)
|
if let Some(client_etag) = headers.get(IF_NONE_MATCH)
|
||||||
&& etag_matches(client_etag, ¬e.etag)
|
&& etag_matches(client_etag, ¬e.etag)
|
||||||
|
|
@ -137,6 +156,101 @@ fn not_found() -> (StatusCode, &'static str) {
|
||||||
(StatusCode::NOT_FOUND, "not found\n")
|
(StatusCode::NOT_FOUND, "not found\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct FolderResponse {
|
||||||
|
path: String,
|
||||||
|
entries: Vec<FolderEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, (DateTime<Utc>, u64)> = BTreeMap::new();
|
||||||
|
let mut dirs: BTreeMap<String, DateTime<Utc>> = 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<FolderEntry> = 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:
|
/// Normalize a request path per the User Manual:
|
||||||
///
|
///
|
||||||
/// - URL-decode the entire path once;
|
/// - URL-decode the entire path once;
|
||||||
|
|
@ -158,6 +272,22 @@ fn resolve_path(raw: &str) -> Result<String, &'static str> {
|
||||||
Ok(stripped)
|
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<String, &'static str> {
|
||||||
|
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
|
/// Tiny dependency-free single-pass percent-decoder. Anwesen URL-decodes
|
||||||
/// each path component exactly once per the User Manual contract; we keep
|
/// each path component exactly once per the User Manual contract; we keep
|
||||||
/// the decoder small rather than pull `percent-encoding` for one call.
|
/// the decoder small rather than pull `percent-encoding` for one call.
|
||||||
|
|
@ -342,6 +472,89 @@ mod tests {
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
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]
|
#[test]
|
||||||
fn resolve_path_normalizations() {
|
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");
|
||||||
|
|
|
||||||
14
src/store.rs
14
src/store.rs
|
|
@ -90,6 +90,20 @@ impl NoteStore {
|
||||||
.len()
|
.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run `f` with a shared reference to the underlying map. The read lock
|
||||||
|
/// is held for the call; do not run anything that might block on a
|
||||||
|
/// writer here. Used by the folder-listing handler in [`crate::http`].
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
/// Panics if the inner `RwLock` has been poisoned.
|
||||||
|
pub fn with_read<F, R>(&self, f: F) -> R
|
||||||
|
where
|
||||||
|
F: FnOnce(&BTreeMap<String, Note>) -> R,
|
||||||
|
{
|
||||||
|
let guard = self.inner.read().expect("note_store: read lock poisoned");
|
||||||
|
f(&guard)
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.len() == 0
|
self.len() == 0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue