diff --git a/crates/archivr-core/src/archive.rs b/crates/archivr-core/src/archive.rs index 9eafb4b..dd16e66 100644 --- a/crates/archivr-core/src/archive.rs +++ b/crates/archivr-core/src/archive.rs @@ -85,6 +85,8 @@ pub struct Tag { #[derive(Debug, Clone, serde::Serialize)] pub struct TagNode { pub tag: Tag, + pub entry_count: i64, + pub subtree_count: i64, pub children: Vec, } @@ -196,7 +198,10 @@ pub fn initialize_store_directories(store_path: &Path) -> Result<()> { Ok(()) } -pub fn list_root_entries(conn: &rusqlite::Connection, caller_bits: u32) -> Result> { +pub fn list_root_entries( + conn: &rusqlite::Connection, + caller_bits: u32, +) -> Result> { let mut stmt = conn.prepare( "SELECT e.entry_uid, @@ -336,16 +341,18 @@ pub fn get_capture_job( conn: &rusqlite::Connection, job_uid: &str, ) -> Result> { - Ok(database::get_capture_job(conn, job_uid)?.map(|r| CaptureJobSummary { - job_uid: r.job_uid, - archive_id: r.archive_id, - run_uid: r.run_uid, - status: r.status, - error_text: r.error_text, - notes_json: r.notes_json, - created_at: r.created_at, - updated_at: r.updated_at, - })) + Ok( + database::get_capture_job(conn, job_uid)?.map(|r| CaptureJobSummary { + job_uid: r.job_uid, + archive_id: r.archive_id, + run_uid: r.run_uid, + status: r.status, + error_text: r.error_text, + notes_json: r.notes_json, + created_at: r.created_at, + updated_at: r.updated_at, + }), + ) } /// Lists all collections in the archive. @@ -413,8 +420,7 @@ pub fn list_entries_for_collection( OR (ce.visibility_bits & CAST(?2 AS INTEGER)) != 0) \ GROUP BY e.id \ ORDER BY e.archived_at DESC, e.id DESC", - ENTRY_SELECT_COLS, - ENTRY_FROM_JOINS, + ENTRY_SELECT_COLS, ENTRY_FROM_JOINS, ); let mut stmt = conn.prepare(&sql)?; let entries = stmt @@ -448,9 +454,12 @@ pub fn resolve_artifact_path( artifact: &EntryArtifactSummary, ) -> Result { let joined = store_path.join(&artifact.relpath); - let canonical_store = store_path - .canonicalize() - .with_context(|| format!("failed to canonicalize store path: {}", store_path.display()))?; + let canonical_store = store_path.canonicalize().with_context(|| { + format!( + "failed to canonicalize store path: {}", + store_path.display() + ) + })?; let canonical_artifact = joined .canonicalize() .with_context(|| format!("artifact path does not exist: {}", joined.display()))?; @@ -522,13 +531,13 @@ pub fn parse_search_query(raw: &str) -> Result { match prefix { "source" => query.source_kind = Some(value), - "type" => query.entity_kind = Some(value), - "url" => query.url = Some(value), - "title" => query.title = Some(value), - "after" => query.after = Some(value), + "type" => query.entity_kind = Some(value), + "url" => query.url = Some(value), + "title" => query.title = Some(value), + "after" => query.after = Some(value), "before" => query.before = Some(value), - "tag" => query.tag = Some(value), - other => return Err(other.to_string()), + "tag" => query.tag = Some(value), + other => return Err(other.to_string()), } } else { free_text_tokens.push(token); @@ -541,16 +550,14 @@ pub fn parse_search_query(raw: &str) -> Result { Ok(query) } -const ENTRY_SELECT_COLS: &str = - "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \ +const ENTRY_SELECT_COLS: &str = "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \ e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \ COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, \ parent.entry_uid AS parent_entry_uid, \ EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon, \ e.cached_bytes"; -const ENTRY_FROM_JOINS: &str = - "FROM archived_entries e \ +const ENTRY_FROM_JOINS: &str = "FROM archived_entries e \ JOIN source_identities si ON si.id = e.source_identity_id \ LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id \ LEFT JOIN blobs b ON b.id = ea.blob_id \ @@ -579,14 +586,12 @@ pub fn search_entries( JOIN entry_tag_assignments eta ON eta.entry_id = e.id \ JOIN descendants d ON eta.tag_id = d.id \ WHERE 1=1", - ENTRY_SELECT_COLS, - ENTRY_FROM_JOINS, + ENTRY_SELECT_COLS, ENTRY_FROM_JOINS, ); } else { sql = format!( "{} {} WHERE e.parent_entry_id IS NULL", - ENTRY_SELECT_COLS, - ENTRY_FROM_JOINS, + ENTRY_SELECT_COLS, ENTRY_FROM_JOINS, ); } @@ -687,19 +692,65 @@ pub fn create_tag(conn: &rusqlite::Connection, full_path: &str) -> Result { } /// Returns the full tag tree with root nodes at the top level and children nested. +/// Each node includes a direct entry count and a subtree count (unique entries +/// assigned to the tag itself or any descendant). pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result> { use std::collections::HashMap; let records = database::list_all_tags(conn)?; + // Fetch direct entry counts for all tags in a single query. + let mut counts: HashMap = HashMap::new(); + { + let mut stmt = conn.prepare( + "SELECT t.tag_uid, COUNT(eta.entry_id) \ + FROM tags t \ + LEFT JOIN entry_tag_assignments eta ON eta.tag_id = t.id \ + GROUP BY t.id", + )?; + for row in stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))? { + let (uid, cnt) = row?; + counts.insert(uid, cnt); + } + } + + // Fetch subtree entry counts: for each tag, count distinct entries assigned + // to it or any descendant. COUNT(DISTINCT) ensures an entry tagged at both + // a parent and a child is counted only once. + let mut subtree_counts: HashMap = HashMap::new(); + { + let mut stmt = conn.prepare( + "WITH RECURSIVE descendants(ancestor_id, descendant_id) AS ( \ + SELECT id, id FROM tags \ + UNION ALL \ + SELECT d.ancestor_id, t.id \ + FROM tags t JOIN descendants d ON t.parent_tag_id = d.descendant_id \ + ) \ + SELECT t.tag_uid, COUNT(DISTINCT eta.entry_id) \ + FROM tags t \ + JOIN descendants d ON d.ancestor_id = t.id \ + LEFT JOIN entry_tag_assignments eta ON eta.tag_id = d.descendant_id \ + GROUP BY t.id", + )?; + for row in stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))? { + let (uid, cnt) = row?; + subtree_counts.insert(uid, cnt); + } + } + let mut by_parent: HashMap, Vec> = HashMap::new(); for record in records { - by_parent.entry(record.parent_tag_id).or_default().push(record); + by_parent + .entry(record.parent_tag_id) + .or_default() + .push(record); } fn build_nodes( parent_id: Option, by_parent: &HashMap, Vec>, + counts: &HashMap, + subtree_counts: &HashMap, ) -> Vec { let Some(children) = by_parent.get(&parent_id) else { return Vec::new(); @@ -713,22 +764,21 @@ pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result> { slug: r.slug.clone(), full_path: r.full_path.clone(), }, - children: build_nodes(Some(r.id), by_parent), + entry_count: counts.get(&r.tag_uid).copied().unwrap_or(0), + subtree_count: subtree_counts.get(&r.tag_uid).copied().unwrap_or(0), + children: build_nodes(Some(r.id), by_parent, counts, subtree_counts), }) .collect() } - Ok(build_nodes(None, &by_parent)) + Ok(build_nodes(None, &by_parent, &counts, &subtree_counts)) } /// Returns the tags assigned to an entry. /// /// Returns `Ok(None)` if the entry_uid does not exist (caller maps to 404). /// Returns `Ok(Some([]))` if the entry exists but has no tags. -pub fn get_entry_tags( - conn: &rusqlite::Connection, - entry_uid: &str, -) -> Result>> { +pub fn get_entry_tags(conn: &rusqlite::Connection, entry_uid: &str) -> Result>> { let Some(entry_id) = conn .query_row( "SELECT id FROM archived_entries WHERE entry_uid = ?1", @@ -1111,10 +1161,14 @@ mod tests { // Entry 1: tweet by source x let si1 = database::upsert_source_identity( - &conn, "x", "tweet", Some("t-1"), + &conn, + "x", + "tweet", + Some("t-1"), Some("https://x.com/user/status/1"), "https://x.com/user/status/1", - ).unwrap(); + ) + .unwrap(); database::create_archived_entry( &conn, &database::NewEntry { @@ -1132,14 +1186,19 @@ mod tests { source_metadata_json: "{}".to_string(), display_metadata_json: None, }, - ).unwrap(); + ) + .unwrap(); // Entry 2: web page let si2 = database::upsert_source_identity( - &conn, "web", "page", Some("page-1"), + &conn, + "web", + "page", + Some("page-1"), Some("https://medium.com/article"), "https://medium.com/article", - ).unwrap(); + ) + .unwrap(); database::create_archived_entry( &conn, &database::NewEntry { @@ -1157,7 +1216,8 @@ mod tests { source_metadata_json: "{}".to_string(), display_metadata_json: None, }, - ).unwrap(); + ) + .unwrap(); conn } @@ -1173,10 +1233,14 @@ mod tests { #[test] fn search_q_filters_on_title() { let conn = make_test_db_with_entries(); - let results = search_entries(&conn, &SearchEntriesQuery { - q: Some("polymarket".to_string()), - ..Default::default() - }).unwrap(); + let results = search_entries( + &conn, + &SearchEntriesQuery { + q: Some("polymarket".to_string()), + ..Default::default() + }, + ) + .unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].entity_kind, "tweet"); } @@ -1184,10 +1248,14 @@ mod tests { #[test] fn search_entity_kind_exact_match() { let conn = make_test_db_with_entries(); - let results = search_entries(&conn, &SearchEntriesQuery { - entity_kind: Some("page".to_string()), - ..Default::default() - }).unwrap(); + let results = search_entries( + &conn, + &SearchEntriesQuery { + entity_kind: Some("page".to_string()), + ..Default::default() + }, + ) + .unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].source_kind, "web"); } @@ -1195,10 +1263,14 @@ mod tests { #[test] fn search_url_like_filter() { let conn = make_test_db_with_entries(); - let results = search_entries(&conn, &SearchEntriesQuery { - url: Some("medium.com".to_string()), - ..Default::default() - }).unwrap(); + let results = search_entries( + &conn, + &SearchEntriesQuery { + url: Some("medium.com".to_string()), + ..Default::default() + }, + ) + .unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].title.as_deref(), Some("Resume Templates")); } @@ -1206,21 +1278,29 @@ mod tests { #[test] fn search_no_match_returns_empty() { let conn = make_test_db_with_entries(); - let results = search_entries(&conn, &SearchEntriesQuery { - q: Some("zzznonexistent".to_string()), - ..Default::default() - }).unwrap(); + let results = search_entries( + &conn, + &SearchEntriesQuery { + q: Some("zzznonexistent".to_string()), + ..Default::default() + }, + ) + .unwrap(); assert!(results.is_empty()); } #[test] fn search_multiple_filters_compound() { let conn = make_test_db_with_entries(); - let results = search_entries(&conn, &SearchEntriesQuery { - source_kind: Some("x".to_string()), - entity_kind: Some("tweet".to_string()), - ..Default::default() - }).unwrap(); + let results = search_entries( + &conn, + &SearchEntriesQuery { + source_kind: Some("x".to_string()), + entity_kind: Some("tweet".to_string()), + ..Default::default() + }, + ) + .unwrap(); assert_eq!(results.len(), 1); } @@ -1243,9 +1323,8 @@ mod tests { title: &str, url: &str, ) -> database::ArchivedEntry { - let si = database::upsert_source_identity( - conn, "web", "page", None, Some(url), url, - ).unwrap(); + let si = + database::upsert_source_identity(conn, "web", "page", None, Some(url), url).unwrap(); database::create_archived_entry( conn, &database::NewEntry { @@ -1263,7 +1342,8 @@ mod tests { source_metadata_json: "{}".to_string(), display_metadata_json: None, }, - ).unwrap() + ) + .unwrap() } #[test] @@ -1275,32 +1355,143 @@ mod tests { let tree = list_tag_tree(&conn).unwrap(); assert_eq!(tree.len(), 2, "expected two root nodes"); - let science = tree.iter().find(|n| n.tag.slug == "science").expect("science root missing"); + let science = tree + .iter() + .find(|n| n.tag.slug == "science") + .expect("science root missing"); assert_eq!(science.children.len(), 1, "science should have one child"); assert_eq!(science.children[0].tag.slug, "cs"); - let art = tree.iter().find(|n| n.tag.slug == "art").expect("art root missing"); + let art = tree + .iter() + .find(|n| n.tag.slug == "art") + .expect("art root missing"); assert!(art.children.is_empty(), "art should have no children"); } + #[test] + fn tag_tree_entry_counts_direct_and_subtree() { + let (conn, user_id, run_id) = make_tag_test_db(); + // Tree: /science -> /science/cs -> /science/cs/algorithms + create_tag(&conn, "/science/cs/algorithms").unwrap(); + + let e1 = make_entry_in_db( + &conn, + user_id, + run_id, + None, + None, + "E1", + "https://example.com/e1", + ); + let e2 = make_entry_in_db( + &conn, + user_id, + run_id, + None, + None, + "E2", + "https://example.com/e2", + ); + + // e1 → /science/cs/algorithms (leaf), e2 → /science (root) + assign_entry_tag(&conn, &e1.entry_uid, "/science/cs/algorithms").unwrap(); + assign_entry_tag(&conn, &e2.entry_uid, "/science").unwrap(); + + let tree = list_tag_tree(&conn).unwrap(); + let science = tree.iter().find(|n| n.tag.slug == "science").unwrap(); + let cs = science + .children + .iter() + .find(|n| n.tag.slug == "cs") + .unwrap(); + let algo = cs + .children + .iter() + .find(|n| n.tag.slug == "algorithms") + .unwrap(); + + assert_eq!(science.entry_count, 1, "science direct = 1"); + assert_eq!( + science.subtree_count, 2, + "science subtree = 2 (e1 via algo, e2 direct)" + ); + assert_eq!(cs.entry_count, 0, "cs direct = 0"); + assert_eq!(cs.subtree_count, 1, "cs subtree = 1 (e1 via algo)"); + assert_eq!(algo.entry_count, 1, "algo direct = 1"); + assert_eq!(algo.subtree_count, 1, "algo subtree = 1"); + } + + #[test] + fn tag_tree_subtree_count_deduplicates_shared_entry() { + // Regression: an entry assigned to both a parent and a child must count + // as 1 in the parent's subtree_count, not 2. + let (conn, user_id, run_id) = make_tag_test_db(); + create_tag(&conn, "/science/cs").unwrap(); + + let e = make_entry_in_db( + &conn, + user_id, + run_id, + None, + None, + "E", + "https://example.com/ded", + ); + assign_entry_tag(&conn, &e.entry_uid, "/science").unwrap(); + assign_entry_tag(&conn, &e.entry_uid, "/science/cs").unwrap(); + + let tree = list_tag_tree(&conn).unwrap(); + let science = tree.iter().find(|n| n.tag.slug == "science").unwrap(); + + assert_eq!( + science.subtree_count, 1, + "entry assigned to both parent and child must not be double-counted" + ); + assert_eq!(science.entry_count, 1, "science direct = 1"); + assert_eq!(science.children[0].subtree_count, 1, "cs subtree = 1"); + } + #[test] fn assign_entry_tag_is_idempotent() { let (conn, user_id, run_id) = make_tag_test_db(); - let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Test", "https://example.com/t1"); + let entry = make_entry_in_db( + &conn, + user_id, + run_id, + None, + None, + "Test", + "https://example.com/t1", + ); assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap(); assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap(); let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap(); - assert_eq!(tags.len(), 1, "idempotent assign should yield exactly one tag"); + assert_eq!( + tags.len(), + 1, + "idempotent assign should yield exactly one tag" + ); } #[test] fn remove_entry_tag_clears_assignment() { let (conn, user_id, run_id) = make_tag_test_db(); - let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Test", "https://example.com/t2"); + let entry = make_entry_in_db( + &conn, + user_id, + run_id, + None, + None, + "Test", + "https://example.com/t2", + ); - let tag = assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap().unwrap(); + let tag = assign_entry_tag(&conn, &entry.entry_uid, "/science") + .unwrap() + .unwrap(); remove_entry_tag(&conn, &entry.entry_uid, &tag.tag_uid).unwrap(); let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap(); @@ -1310,7 +1501,15 @@ mod tests { #[test] fn entries_for_tag_includes_descendants() { let (conn, user_id, run_id) = make_tag_test_db(); - let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Compilers Paper", "https://example.com/c1"); + let entry = make_entry_in_db( + &conn, + user_id, + run_id, + None, + None, + "Compilers Paper", + "https://example.com/c1", + ); assign_entry_tag(&conn, &entry.entry_uid, "/science/cs/compilers").unwrap(); @@ -1322,11 +1521,23 @@ mod tests { #[test] fn entries_for_tag_includes_child_entries() { let (conn, user_id, run_id) = make_tag_test_db(); - let parent = make_entry_in_db(&conn, user_id, run_id, None, None, "Playlist", "https://example.com/pl"); + let parent = make_entry_in_db( + &conn, + user_id, + run_id, + None, + None, + "Playlist", + "https://example.com/pl", + ); let child = make_entry_in_db( - &conn, user_id, run_id, - Some(parent.id), Some(parent.id), - "Video 1", "https://example.com/pl/v1", + &conn, + user_id, + run_id, + Some(parent.id), + Some(parent.id), + "Video 1", + "https://example.com/pl/v1", ); assign_entry_tag(&conn, &child.entry_uid, "/science").unwrap(); @@ -1334,42 +1545,77 @@ mod tests { let results = entries_for_tag(&conn, "/science").unwrap(); assert_eq!(results.len(), 1, "only the tagged child should appear"); assert_eq!(results[0].entry_uid, child.entry_uid); - assert_eq!(results[0].parent_entry_uid.as_deref(), Some(parent.entry_uid.as_str())); + assert_eq!( + results[0].parent_entry_uid.as_deref(), + Some(parent.entry_uid.as_str()) + ); } #[test] fn search_with_tag_filter_works() { let (conn, user_id, run_id) = make_tag_test_db(); - let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Science Article", "https://example.com/s1"); + let entry = make_entry_in_db( + &conn, + user_id, + run_id, + None, + None, + "Science Article", + "https://example.com/s1", + ); assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap(); - let results = search_entries(&conn, &SearchEntriesQuery { - tag: Some("/science".to_string()), - ..Default::default() - }).unwrap(); + let results = search_entries( + &conn, + &SearchEntriesQuery { + tag: Some("/science".to_string()), + ..Default::default() + }, + ) + .unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].entry_uid, entry.entry_uid); - let empty = search_entries(&conn, &SearchEntriesQuery { - tag: Some("/art".to_string()), - ..Default::default() - }).unwrap(); + let empty = search_entries( + &conn, + &SearchEntriesQuery { + tag: Some("/art".to_string()), + ..Default::default() + }, + ) + .unwrap(); assert!(empty.is_empty(), "no entries under /art"); } #[test] fn search_without_tag_returns_roots_only() { let (conn, user_id, run_id) = make_tag_test_db(); - let parent = make_entry_in_db(&conn, user_id, run_id, None, None, "Parent", "https://example.com/par"); + let parent = make_entry_in_db( + &conn, + user_id, + run_id, + None, + None, + "Parent", + "https://example.com/par", + ); let _child = make_entry_in_db( - &conn, user_id, run_id, - Some(parent.id), Some(parent.id), - "Child", "https://example.com/par/c", + &conn, + user_id, + run_id, + Some(parent.id), + Some(parent.id), + "Child", + "https://example.com/par/c", ); let results = search_entries(&conn, &SearchEntriesQuery::default()).unwrap(); - assert_eq!(results.len(), 1, "plain search should return root entries only"); + assert_eq!( + results.len(), + 1, + "plain search should return root entries only" + ); assert_eq!(results[0].entry_uid, parent.entry_uid); } } diff --git a/crates/archivr-core/src/database.rs b/crates/archivr-core/src/database.rs index 159972f..e158734 100644 --- a/crates/archivr-core/src/database.rs +++ b/crates/archivr-core/src/database.rs @@ -135,7 +135,7 @@ pub struct RoleRecord { pub struct InstanceSettings { pub public_index_enabled: bool, pub public_entry_content_enabled: bool, - pub open_registration_enabled: bool, // maps to public_archive_submission_enabled column + pub open_registration_enabled: bool, // maps to public_archive_submission_enabled column pub default_entry_visibility: u32, /// Global default for ad-blocking via uBlock Origin Lite during WebPage captures. /// Per-capture requests can override this. @@ -532,13 +532,11 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> { pub fn open_auth_db(auth_db_path: &Path) -> Result { if let Some(parent) = auth_db_path.parent() { - std::fs::create_dir_all(parent).with_context(|| { - format!("failed to create auth DB directory {}", parent.display()) - })?; + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create auth DB directory {}", parent.display()))?; } - let conn = Connection::open(auth_db_path).with_context(|| { - format!("failed to open auth database at {}", auth_db_path.display()) - })?; + let conn = Connection::open(auth_db_path) + .with_context(|| format!("failed to open auth database at {}", auth_db_path.display()))?; initialize_auth_schema(&conn)?; Ok(conn) } @@ -566,11 +564,10 @@ pub fn create_owner(conn: &Connection, username: &str, password_hash: &str) -> R )?; let user_id = conn.last_insert_rowid(); for slug in &["user", "admin", "owner"] { - let role_id: i64 = conn.query_row( - "SELECT id FROM roles WHERE slug = ?1", - [slug], - |row| row.get(0), - )?; + let role_id: i64 = + conn.query_row("SELECT id FROM roles WHERE slug = ?1", [slug], |row| { + row.get(0) + })?; conn.execute( "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at) VALUES (?1, ?2, ?3)", @@ -804,12 +801,12 @@ pub fn list_cookie_rules(conn: &Connection) -> Result> { let records = stmt .query_map([], |row| { Ok(CookieRule { - rule_uid: row.get(0)?, - url_pattern: row.get(1)?, + rule_uid: row.get(0)?, + url_pattern: row.get(1)?, pattern_kind: row.get(2)?, cookies_json: row.get(3)?, - ordinal: row.get(4)?, - created_at: row.get(5)?, + ordinal: row.get(4)?, + created_at: row.get(5)?, }) })? .collect::, _>>()?; @@ -865,10 +862,7 @@ pub fn update_cookie_rule( } pub fn delete_cookie_rule(conn: &Connection, rule_uid: &str) -> Result<()> { - let rows = conn.execute( - "DELETE FROM cookie_rules WHERE rule_uid = ?1", - [rule_uid], - )?; + let rows = conn.execute("DELETE FROM cookie_rules WHERE rule_uid = ?1", [rule_uid])?; if rows == 0 { anyhow::bail!("cookie rule not found: {rule_uid}"); } @@ -893,7 +887,11 @@ pub fn update_user_password(conn: &Connection, user_id: i64, new_hash: &str) -> Ok(()) } -pub fn update_user_display_name(conn: &Connection, user_id: i64, display_name: Option<&str>) -> Result<()> { +pub fn update_user_display_name( + conn: &Connection, + user_id: i64, + display_name: Option<&str>, +) -> Result<()> { conn.execute( "UPDATE users SET display_name = ?1 WHERE id = ?2", params![display_name, user_id], @@ -937,9 +935,13 @@ pub fn invalidate_user_sessions(conn: &Connection, user_id: i64) -> Result Result> { - conn.query_row("SELECT id FROM users WHERE user_uid = ?1", [user_uid], |r| r.get(0)) - .optional() - .map_err(Into::into) + conn.query_row( + "SELECT id FROM users WHERE user_uid = ?1", + [user_uid], + |r| r.get(0), + ) + .optional() + .map_err(Into::into) } /// Lists all users with their assigned roles and computed role_bits. @@ -948,7 +950,16 @@ pub fn list_users(conn: &Connection) -> Result> { "SELECT id, user_uid, username, email, status, created_at FROM users ORDER BY created_at ASC", )?; let rows: Vec<(i64, String, String, Option, String, String)> = stmt - .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)))? + .query_map([], |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + )) + })? .collect::>()?; rows.into_iter() @@ -958,9 +969,18 @@ pub fn list_users(conn: &Connection) -> Result> { "SELECT r.slug FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?1 ORDER BY r.level, r.bit_position", )?; - let role_slugs: Vec = - rs.query_map([id], |r| r.get(0))?.collect::>()?; - Ok(UserSummary { user_uid, username, email, status, created_at, role_slugs, role_bits }) + let role_slugs: Vec = rs + .query_map([id], |r| r.get(0))? + .collect::>()?; + Ok(UserSummary { + user_uid, + username, + email, + status, + created_at, + role_slugs, + role_bits, + }) }) .collect() } @@ -982,9 +1002,18 @@ pub fn get_user_by_uid(conn: &Connection, user_uid: &str) -> Result = - rs.query_map([id], |r| r.get(0))?.collect::>()?; - Ok(Some(UserSummary { user_uid, username, email, status, created_at, role_slugs, role_bits })) + let role_slugs: Vec = rs + .query_map([id], |r| r.get(0))? + .collect::>()?; + Ok(Some(UserSummary { + user_uid, + username, + email, + status, + created_at, + role_slugs, + role_bits, + })) } } } @@ -1005,11 +1034,8 @@ pub fn create_user( params![user_uid, username, email, password_hash, now_timestamp()], )?; let user_id = conn.last_insert_rowid(); - let role_id: i64 = conn.query_row( - "SELECT id FROM roles WHERE slug = 'user'", - [], - |r| r.get(0), - )?; + let role_id: i64 = + conn.query_row("SELECT id FROM roles WHERE slug = 'user'", [], |r| r.get(0))?; conn.execute( "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at, assigned_by_user_id) VALUES (?1, ?2, ?3, ?4)", @@ -1023,7 +1049,11 @@ pub fn create_user( pub fn set_user_status(conn: &Connection, user_uid: &str, status: &str) -> Result { if status == "disabled" { let id: Option = conn - .query_row("SELECT id FROM users WHERE user_uid = ?1", [user_uid], |r| r.get(0)) + .query_row( + "SELECT id FROM users WHERE user_uid = ?1", + [user_uid], + |r| r.get(0), + ) .optional()?; if let Some(id) = id { invalidate_user_sessions(conn, id)?; @@ -1045,16 +1075,24 @@ pub fn assign_role( assigned_by_user_id: i64, ) -> Result<()> { let role_id: i64 = conn - .query_row("SELECT id FROM roles WHERE slug = ?1", [role_slug], |r| r.get(0)) + .query_row("SELECT id FROM roles WHERE slug = ?1", [role_slug], |r| { + r.get(0) + }) .map_err(|_| anyhow::anyhow!("role '{}' not found", role_slug))?; conn.execute( "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at, assigned_by_user_id) VALUES (?1, ?2, ?3, ?4)", - params![target_user_id, role_id, now_timestamp(), assigned_by_user_id], + params![ + target_user_id, + role_id, + now_timestamp(), + assigned_by_user_id + ], )?; // Cumulative: ensure 'user' whenever any non-guest role is assigned if role_slug != "user" && role_slug != "guest" { - let uid: i64 = conn.query_row("SELECT id FROM roles WHERE slug = 'user'", [], |r| r.get(0))?; + let uid: i64 = + conn.query_row("SELECT id FROM roles WHERE slug = 'user'", [], |r| r.get(0))?; conn.execute( "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at, assigned_by_user_id) VALUES (?1, ?2, ?3, ?4)", @@ -1063,7 +1101,9 @@ pub fn assign_role( } // Also ensure 'admin' when assigning 'owner' if role_slug == "owner" { - let aid: i64 = conn.query_row("SELECT id FROM roles WHERE slug = 'admin'", [], |r| r.get(0))?; + let aid: i64 = conn.query_row("SELECT id FROM roles WHERE slug = 'admin'", [], |r| { + r.get(0) + })?; conn.execute( "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at, assigned_by_user_id) VALUES (?1, ?2, ?3, ?4)", @@ -1088,7 +1128,9 @@ pub fn remove_role(conn: &Connection, target_user_id: i64, role_slug: &str) -> R } } let role_id: i64 = conn - .query_row("SELECT id FROM roles WHERE slug = ?1", [role_slug], |r| r.get(0)) + .query_row("SELECT id FROM roles WHERE slug = ?1", [role_slug], |r| { + r.get(0) + }) .map_err(|_| anyhow::anyhow!("role '{}' not found", role_slug))?; conn.execute( "DELETE FROM user_roles WHERE user_id = ?1 AND role_id = ?2", @@ -1122,7 +1164,9 @@ pub fn list_roles(conn: &Connection) -> Result> { /// Returns the created RoleRecord. pub fn create_custom_role(conn: &Connection, slug: &str, name: &str) -> Result { if slug.is_empty() || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { - anyhow::bail!("role slug must be non-empty and contain only ASCII letters, digits, or hyphens"); + anyhow::bail!( + "role slug must be non-empty and contain only ASCII letters, digits, or hyphens" + ); } let next_bit: i64 = conn.query_row( "SELECT COALESCE(MAX(bit_position) + 1, 4) FROM roles WHERE bit_position >= 4", @@ -1509,10 +1553,7 @@ pub fn cascade_cached_bytes_after_delete(conn: &Connection, entry_id: i64) -> Re /// "still there" during the recalculation. /// /// Must be called before any subtree rows are deleted so the `entry_artifacts` JOIN resolves. -fn cascade_cached_bytes_after_subtree_delete( - conn: &Connection, - subtree_ids: &[i64], -) -> Result<()> { +fn cascade_cached_bytes_after_subtree_delete(conn: &Connection, subtree_ids: &[i64]) -> Result<()> { if subtree_ids.is_empty() { return Ok(()); } @@ -1588,9 +1629,7 @@ pub fn delete_entry(conn: &Connection, entry_uid: &str) -> Result { // Collect the full subtree while rows still exist. let subtree_ids: Vec = { - let mut stmt = conn.prepare( - "SELECT id FROM archived_entries WHERE root_entry_id = ?1", - )?; + let mut stmt = conn.prepare("SELECT id FROM archived_entries WHERE root_entry_id = ?1")?; stmt.query_map([entry_id], |row| row.get(0))? .collect::>()? }; @@ -1615,10 +1654,7 @@ pub fn delete_entry(conn: &Connection, entry_uid: &str) -> Result { )?; // Root entry: CASCADE handles entry_artifacts, entry_tag_assignments, collection_entries. - conn.execute( - "DELETE FROM archived_entries WHERE id = ?1", - [entry_id], - )?; + conn.execute("DELETE FROM archived_entries WHERE id = ?1", [entry_id])?; Ok(true) } @@ -1703,7 +1739,9 @@ pub fn list_orphaned_blob_rows(conn: &Connection) -> Result Result> { +pub fn all_referenced_file_relpaths( + conn: &Connection, +) -> Result> { let mut set = std::collections::HashSet::new(); { let mut stmt = conn.prepare("SELECT DISTINCT relpath FROM entry_artifacts")?; @@ -1809,11 +1847,7 @@ pub fn add_entry_artifact(conn: &Connection, artifact: &NewArtifact) -> Result Result<()> { +pub fn remove_entry_tag_assignment(conn: &Connection, entry_id: i64, tag_id: i64) -> Result<()> { conn.execute( "DELETE FROM entry_tag_assignments WHERE entry_id = ?1 AND tag_id = ?2", params![entry_id, tag_id], @@ -1955,12 +1989,18 @@ pub fn main_archive_entry_count(conn: &Connection) -> Result { } pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result { - let segments = normalized_tag_segments(full_path)?; - let mut parent_tag_id = None; - let mut current_path = String::new(); - let mut current_id = 0; + let raw_segments = normalized_tag_segments(full_path)?; + // Slugify each segment consistently with rename_tag. + let segments: Vec = raw_segments + .into_iter() + .map(slugify_segment) + .collect::>>()?; - for segment in segments { + let mut parent_tag_id: Option = None; + let mut current_path = String::new(); + let mut current_id: i64 = 0; + + for segment in &segments { current_path.push('/'); current_path.push_str(segment); @@ -1984,7 +2024,7 @@ pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result { public_id("tag"), parent_tag_id, humanize_slug(segment), - segment, + segment.as_str(), current_path ], )?; @@ -2027,30 +2067,7 @@ pub fn rename_tag( tag_uid: &str, new_segment: &str, ) -> Result> { - // Slugify: spaces→hyphens, keep alphanumeric and hyphens (case preserved), collapse runs, strip edges. - let trimmed = new_segment.trim(); - let hyphenated: String = trimmed.chars().map(|c| if c == ' ' { '-' } else { c }).collect(); - let filtered: String = hyphenated - .chars() - .filter(|c| c.is_alphanumeric() || *c == '-') - .collect(); - let mut new_slug = String::new(); - let mut prev_hyphen = false; - for c in filtered.chars() { - if c == '-' { - if !prev_hyphen { - new_slug.push(c); - } - prev_hyphen = true; - } else { - new_slug.push(c); - prev_hyphen = false; - } - } - let new_slug = new_slug.trim_matches('-').to_string(); - if new_slug.is_empty() { - bail!("new segment slugifies to empty string"); - } + let new_slug = slugify_segment(new_segment)?; // Fetch existing tag. let tag = match get_tag_by_uid(conn, tag_uid)? { @@ -2084,15 +2101,18 @@ pub fn rename_tag( )?; let old_prefix_slash = format!("{}/", old_prefix); let new_prefix_slash = format!("{}/", new_full_path); - // Use hierarchy (recursive CTE over parent_tag_id) instead of LIKE to avoid - // treating '_'/'%' in historical slugs as wildcards. + // Replace the leading old_prefix_slash with new_prefix_slash across all + // descendants. REPLACE(full_path, old, new) would corrupt paths where the + // old prefix string appears again deeper in the tree (e.g. /foo/other/foo/bar). + // Use prefix-anchored substitution instead: keep everything after the prefix + // and prepend the new prefix. conn.execute( "WITH RECURSIVE descendants(id) AS (\ SELECT id FROM tags WHERE parent_tag_id = ?1 \ UNION ALL \ SELECT t.id FROM tags t JOIN descendants d ON t.parent_tag_id = d.id \ ) \ - UPDATE tags SET full_path = REPLACE(full_path, ?2, ?3) \ + UPDATE tags SET full_path = ?3 || substr(full_path, length(?2) + 1) \ WHERE id IN (SELECT id FROM descendants)", params![tag.id, old_prefix_slash, new_prefix_slash], )?; @@ -2129,6 +2149,97 @@ pub fn delete_tag(conn: &Connection, tag_uid: &str) -> Result { Ok(deleted > 0) } +/// Moves a tag and its entire subtree to a new parent. +/// +/// `new_parent_uid = None` promotes the tag to root level. +/// Returns `Ok(None)` if `tag_uid` is not found. +/// Returns an error if: +/// - `new_parent_uid` refers to the tag itself or a descendant of it +/// - the resulting path would collide with an existing tag +/// - `new_parent_uid` is provided but not found +pub fn move_tag( + conn: &Connection, + tag_uid: &str, + new_parent_uid: Option<&str>, +) -> Result> { + let tag = match get_tag_by_uid(conn, tag_uid)? { + Some(t) => t, + None => return Ok(None), + }; + + let new_parent: Option = match new_parent_uid { + Some(uid) => { + let parent = match get_tag_by_uid(conn, uid)? { + Some(p) => p, + None => bail!("parent tag not found"), + }; + if parent.tag_uid == tag.tag_uid { + bail!("cannot move a tag under itself"); + } + // Reject if the proposed parent is a descendant of the tag being moved. + if parent.full_path.starts_with(&format!("{}/", tag.full_path)) { + bail!("cannot move a tag under one of its own descendants"); + } + Some(parent) + } + None => None, + }; + + let new_full_path = match &new_parent { + Some(p) => format!("{}/{}", p.full_path, tag.slug), + None => format!("/{}", tag.slug), + }; + + // No-op when the path would not change. + if new_full_path == tag.full_path { + return Ok(Some(tag)); + } + + // Collision check. + if let Some(existing) = get_tag_by_path(conn, &new_full_path)? { + if existing.tag_uid != tag_uid { + bail!("a tag already exists at path: {new_full_path}"); + } + } + + let new_parent_id: Option = new_parent.as_ref().map(|p| p.id); + let tag_id = tag.id; + let old_prefix_slash = format!("{}/", tag.full_path); + let new_prefix_slash = format!("{}/", new_full_path); + + let result = (|| -> Result<()> { + conn.execute_batch("BEGIN")?; + // Update the moved tag itself: new parent and new full_path. + conn.execute( + "UPDATE tags SET parent_tag_id = ?1, full_path = ?2 WHERE tag_uid = ?3", + params![new_parent_id, new_full_path, tag_uid], + )?; + // Cascade the full_path prefix change to every descendant. + // Use prefix-anchored substitution: ?3 || substr(full_path, length(?2) + 1) + // rather than REPLACE, which would corrupt paths where the old prefix slug + // appears again at a non-overlapping position deeper in the tree. + conn.execute( + "WITH RECURSIVE descendants(id) AS (\ + SELECT id FROM tags WHERE parent_tag_id = ?1 \ + UNION ALL \ + SELECT t.id FROM tags t JOIN descendants d ON t.parent_tag_id = d.id \ + ) \ + UPDATE tags SET full_path = ?3 || substr(full_path, length(?2) + 1) \ + WHERE id IN (SELECT id FROM descendants)", + params![tag_id, old_prefix_slash, new_prefix_slash], + )?; + conn.execute_batch("COMMIT")?; + Ok(()) + })(); + + if let Err(e) = result { + let _ = conn.execute_batch("ROLLBACK"); + return Err(e); + } + + get_tag_by_uid(conn, tag_uid) +} + fn refresh_run_counters(conn: &Connection, run_id: i64) -> Result<()> { conn.execute( "UPDATE archive_runs @@ -2182,7 +2293,13 @@ pub fn create_collection( conn.execute( "INSERT INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5)", - params![collection_uid, name, slug, default_visibility_bits as i64, now], + params![ + collection_uid, + name, + slug, + default_visibility_bits as i64, + now + ], )?; let id = conn.last_insert_rowid(); Ok(CollectionRecord { @@ -2216,10 +2333,7 @@ pub fn list_collections(conn: &Connection) -> Result> { } /// Returns a collection by its uid, or None if not found. -pub fn get_collection_by_uid( - conn: &Connection, - uid: &str, -) -> Result> { +pub fn get_collection_by_uid(conn: &Connection, uid: &str) -> Result> { conn.query_row( "SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \ FROM collections WHERE collection_uid = ?1", @@ -2327,10 +2441,7 @@ pub fn update_collection( /// Deletes a collection and cascades to collection_entries. /// Returns true if deleted, false if not found. /// Refuses to delete the '_default_' collection. -pub fn delete_collection( - conn: &Connection, - collection_uid: &str, -) -> Result { +pub fn delete_collection(conn: &Connection, collection_uid: &str) -> Result { let coll = get_collection_by_uid(conn, collection_uid)?; let Some(coll) = coll else { return Ok(false) }; if coll.slug == "_default_" { @@ -2403,6 +2514,39 @@ fn humanize_slug(slug: &str) -> String { .join(" ") } +/// Converts a raw input string into a valid tag slug: +/// spaces → hyphens, strip non-(alphanumeric|hyphen), collapse runs, trim edge hyphens. +/// Returns an error if the result is empty. +fn slugify_segment(input: &str) -> Result { + let hyphenated: String = input + .trim() + .chars() + .map(|c| if c == ' ' { '-' } else { c }) + .collect(); + let filtered: String = hyphenated + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-') + .collect(); + let mut slug = String::new(); + let mut prev_hyphen = false; + for c in filtered.chars() { + if c == '-' { + if !prev_hyphen { + slug.push(c); + } + prev_hyphen = true; + } else { + slug.push(c); + prev_hyphen = false; + } + } + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + bail!("segment slugifies to empty string"); + } + Ok(slug) +} + /// A minimal view of an archived entry needed for re-archiving. pub struct EntryForRearchive { pub id: i64, @@ -2779,7 +2923,11 @@ mod tests { #[test] fn get_blob_by_sha256_returns_none_for_unknown() { let conn = conn(); - let result = get_blob_by_sha256(&conn, "0000000000000000000000000000000000000000000000000000000000000000").unwrap(); + let result = get_blob_by_sha256( + &conn, + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .unwrap(); assert!(result.is_none()); } @@ -2788,11 +2936,17 @@ mod tests { let conn = Connection::open_in_memory().unwrap(); initialize_auth_schema(&conn).unwrap(); let count: i64 = conn - .query_row("SELECT COUNT(*) FROM roles WHERE is_builtin = 1", [], |r| r.get(0)) + .query_row("SELECT COUNT(*) FROM roles WHERE is_builtin = 1", [], |r| { + r.get(0) + }) .unwrap(); assert_eq!(count, 4); let owner_bits: i64 = conn - .query_row("SELECT bit_position FROM roles WHERE slug = 'owner'", [], |r| r.get(0)) + .query_row( + "SELECT bit_position FROM roles WHERE slug = 'owner'", + [], + |r| r.get(0), + ) .unwrap(); assert_eq!(owner_bits, 3); } @@ -2892,7 +3046,8 @@ mod tests { let conn = conn(); let job_uid = create_capture_job(&conn, "test").unwrap(); update_capture_job_status(&conn, &job_uid, "running", None, None, None).unwrap(); - update_capture_job_status(&conn, &job_uid, "completed", Some("run_abc"), None, None).unwrap(); + update_capture_job_status(&conn, &job_uid, "completed", Some("run_abc"), None, None) + .unwrap(); let job = get_capture_job(&conn, &job_uid).unwrap().unwrap(); assert_eq!(job.status, "completed"); assert_eq!(job.run_uid.as_deref(), Some("run_abc")); @@ -2910,7 +3065,17 @@ mod tests { // (covers the case where run_uid was never written back before the crash). let user_id = ensure_default_user(&conn).unwrap(); let run = create_archive_run(&conn, user_id, 1).unwrap(); - create_archive_run_item(&conn, run.id, None, 0, "https://example.com", None, "web", "file").unwrap(); + create_archive_run_item( + &conn, + run.id, + None, + 0, + "https://example.com", + None, + "web", + "file", + ) + .unwrap(); let n = fail_stalled_capture_jobs(&conn).unwrap(); assert_eq!(n, 1); // one capture_job updated @@ -2921,19 +3086,23 @@ mod tests { assert!(job.error_text.as_deref().unwrap().contains("interrupted")); // archive_run is failed - let updated_run: String = conn.query_row( - "SELECT status FROM archive_runs WHERE id = ?1", - [run.id], - |r| r.get(0), - ).unwrap(); + let updated_run: String = conn + .query_row( + "SELECT status FROM archive_runs WHERE id = ?1", + [run.id], + |r| r.get(0), + ) + .unwrap(); assert_eq!(updated_run, "failed"); // archive_run_item is failed - let item_status: String = conn.query_row( - "SELECT status FROM archive_run_items WHERE run_id = ?1", - [run.id], - |r| r.get(0), - ).unwrap(); + let item_status: String = conn + .query_row( + "SELECT status FROM archive_run_items WHERE run_id = ?1", + [run.id], + |r| r.get(0), + ) + .unwrap(); assert_eq!(item_status, "failed"); } @@ -2947,7 +3116,8 @@ mod tests { fn user_create_and_list() { let conn = make_auth_conn_for_mgmt(); let owner_id = create_owner(&conn, "owner", "hash").unwrap(); - let uid = create_user(&conn, "alice", Some("alice@example.com"), "hash2", owner_id).unwrap(); + let uid = + create_user(&conn, "alice", Some("alice@example.com"), "hash2", owner_id).unwrap(); let users = list_users(&conn).unwrap(); assert_eq!(users.len(), 2); let alice = users.iter().find(|u| u.username == "alice").unwrap(); @@ -2961,10 +3131,20 @@ mod tests { let conn = make_auth_conn_for_mgmt(); let owner_id = create_owner(&conn, "owner", "hash").unwrap(); let uid = create_user(&conn, "bob", None, "hash", owner_id).unwrap(); - let bob_id: i64 = conn.query_row("SELECT id FROM users WHERE user_uid = ?1", [&uid], |r| r.get(0)).unwrap(); + let bob_id: i64 = conn + .query_row("SELECT id FROM users WHERE user_uid = ?1", [&uid], |r| { + r.get(0) + }) + .unwrap(); create_session(&conn, bob_id, 3, None).unwrap(); set_user_status(&conn, &uid, "disabled").unwrap(); - let sess_count: i64 = conn.query_row("SELECT COUNT(*) FROM sessions WHERE user_id = ?1", [bob_id], |r| r.get(0)).unwrap(); + let sess_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id = ?1", + [bob_id], + |r| r.get(0), + ) + .unwrap(); assert_eq!(sess_count, 0, "sessions should be cleared on disable"); let u = get_user_by_uid(&conn, &uid).unwrap().unwrap(); assert_eq!(u.status, "disabled"); @@ -3011,16 +3191,18 @@ mod tests { let _ = create_tag_path(&conn, "science/cs/algorithms").unwrap(); let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); - let cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap(); - let algo = get_tag_by_path(&conn, "/science/cs/algorithms").unwrap().unwrap(); + let cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap(); + let algo = get_tag_by_path(&conn, "/science/cs/algorithms") + .unwrap() + .unwrap(); // Rename "science" → "natural-science" let updated = rename_tag(&conn, &science.tag_uid, "natural-science") .unwrap() .expect("should return updated tag"); - assert_eq!(updated.slug, "natural-science"); - assert_eq!(updated.name, "Natural Science"); + assert_eq!(updated.slug, "natural-science"); + assert_eq!(updated.name, "Natural Science"); assert_eq!(updated.full_path, "/natural-science"); // /science must no longer exist @@ -3032,7 +3214,11 @@ mod tests { assert_eq!(cs_new.full_path, "/natural-science/cs"); // /science/cs/algorithms must have moved - assert!(get_tag_by_path(&conn, "/science/cs/algorithms").unwrap().is_none()); + assert!( + get_tag_by_path(&conn, "/science/cs/algorithms") + .unwrap() + .is_none() + ); let algo_new = get_tag_by_uid(&conn, &algo.tag_uid).unwrap().unwrap(); assert_eq!(algo_new.full_path, "/natural-science/cs/algorithms"); } @@ -3048,7 +3234,11 @@ mod tests { // Renaming /science → natural-science should collide let result = rename_tag(&conn, &science.tag_uid, "natural-science"); - assert!(result.is_err(), "expected collision error, got {:?}", result); + assert!( + result.is_err(), + "expected collision error, got {:?}", + result + ); } #[test] @@ -3074,7 +3264,7 @@ mod tests { fn delete_tag_removes_subtree_and_cascades_assignments() { let conn = conn(); // Build /science/cs and /science/math - let cs_id = create_tag_path(&conn, "science/cs").unwrap(); + let cs_id = create_tag_path(&conn, "science/cs").unwrap(); let math_id = create_tag_path(&conn, "science/math").unwrap(); let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); @@ -3114,12 +3304,16 @@ mod tests { // Verify by uid too (subtree ids: science, cs, math) assert!(get_tag_by_uid(&conn, &science.tag_uid).unwrap().is_none()); let cs_tag = conn - .query_row("SELECT tag_uid FROM tags WHERE id = ?1", [cs_id], |r| r.get::<_, String>(0)) + .query_row("SELECT tag_uid FROM tags WHERE id = ?1", [cs_id], |r| { + r.get::<_, String>(0) + }) .optional() .unwrap(); assert!(cs_tag.is_none(), "/science/cs should be deleted"); let math_tag = conn - .query_row("SELECT tag_uid FROM tags WHERE id = ?1", [math_id], |r| r.get::<_, String>(0)) + .query_row("SELECT tag_uid FROM tags WHERE id = ?1", [math_id], |r| { + r.get::<_, String>(0) + }) .optional() .unwrap(); assert!(math_tag.is_none(), "/science/math should be deleted"); @@ -3139,6 +3333,207 @@ mod tests { assert_eq!(updated.full_path, "/Natural-Science"); } + // ── move_tag tests ──────────────────────────────────────────────────────── + + #[test] + fn move_tag_unknown_uid_returns_none() { + let conn = conn(); + let result = move_tag(&conn, "tag_doesnotexist", None).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn move_tag_to_root_clears_parent_and_updates_path() { + let conn = conn(); + // Build /science/cs/algorithms + let _ = create_tag_path(&conn, "science/cs/algorithms").unwrap(); + let cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap(); + let algo = get_tag_by_path(&conn, "/science/cs/algorithms") + .unwrap() + .unwrap(); + + // Move /science/cs to root — new path should be /cs + let updated = move_tag(&conn, &cs.tag_uid, None) + .unwrap() + .expect("should return updated tag"); + + assert_eq!(updated.full_path, "/cs"); + assert!(updated.parent_tag_id.is_none()); + + // /science/cs must no longer exist at old path + assert!(get_tag_by_path(&conn, "/science/cs").unwrap().is_none()); + + // Descendant /science/cs/algorithms must have its path updated + let algo_updated = get_tag_by_uid(&conn, &algo.tag_uid).unwrap().unwrap(); + assert_eq!(algo_updated.full_path, "/cs/algorithms"); + } + + #[test] + fn move_tag_to_new_parent_updates_subtree_paths() { + let conn = conn(); + // Build /science/cs/algorithms and /archive + let _ = create_tag_path(&conn, "science/cs/algorithms").unwrap(); + let _ = create_tag_path(&conn, "archive").unwrap(); + + let cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap(); + let algo = get_tag_by_path(&conn, "/science/cs/algorithms") + .unwrap() + .unwrap(); + let archive = get_tag_by_path(&conn, "/archive").unwrap().unwrap(); + + // Move /science/cs under /archive + let updated = move_tag(&conn, &cs.tag_uid, Some(&archive.tag_uid)) + .unwrap() + .expect("should return updated tag"); + + assert_eq!(updated.full_path, "/archive/cs"); + assert_eq!(updated.parent_tag_id, Some(archive.id)); + + // Old path must be gone + assert!(get_tag_by_path(&conn, "/science/cs").unwrap().is_none()); + + // Descendant must have cascaded path + let algo_updated = get_tag_by_uid(&conn, &algo.tag_uid).unwrap().unwrap(); + assert_eq!(algo_updated.full_path, "/archive/cs/algorithms"); + } + + #[test] + fn move_tag_noop_when_parent_unchanged() { + let conn = conn(); + let _ = create_tag_path(&conn, "science/cs").unwrap(); + let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); + let cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap(); + + // Moving /science/cs under /science again is a no-op + let updated = move_tag(&conn, &cs.tag_uid, Some(&science.tag_uid)) + .unwrap() + .expect("should return tag"); + assert_eq!(updated.full_path, "/science/cs"); + } + + #[test] + fn move_tag_rejects_self_move() { + let conn = conn(); + let _ = create_tag_path(&conn, "science").unwrap(); + let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); + let result = move_tag(&conn, &science.tag_uid, Some(&science.tag_uid)); + assert!( + result.is_err(), + "expected error when moving tag under itself" + ); + } + + #[test] + fn move_tag_rejects_descendant_as_parent() { + let conn = conn(); + let _ = create_tag_path(&conn, "science/cs/algorithms").unwrap(); + let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); + let algo = get_tag_by_path(&conn, "/science/cs/algorithms") + .unwrap() + .unwrap(); + + // Moving /science under one of its own descendants must be rejected + let result = move_tag(&conn, &science.tag_uid, Some(&algo.tag_uid)); + assert!( + result.is_err(), + "expected error when moving tag under a descendant" + ); + } + + #[test] + fn move_tag_rejects_path_collision() { + let conn = conn(); + // /archive/cs and /science/cs both exist; moving /science/cs under /archive collides + let _ = create_tag_path(&conn, "science/cs").unwrap(); + let _ = create_tag_path(&conn, "archive/cs").unwrap(); + let science_cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap(); + let archive = get_tag_by_path(&conn, "/archive").unwrap().unwrap(); + + let result = move_tag(&conn, &science_cs.tag_uid, Some(&archive.tag_uid)); + assert!( + result.is_err(), + "expected collision error; /archive/cs already exists" + ); + } + + #[test] + fn move_tag_rejects_unknown_parent_uid() { + let conn = conn(); + let _ = create_tag_path(&conn, "science").unwrap(); + let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); + let result = move_tag(&conn, &science.tag_uid, Some("uid_does_not_exist")); + assert!(result.is_err(), "expected error for unknown parent uid"); + } + + #[test] + fn move_tag_cascade_only_replaces_leading_prefix() { + // Regression: SQLite REPLACE(full_path, old_prefix, new_prefix) rewrites + // every non-overlapping occurrence of the pattern in the string, not just + // the leading one. /foo/foo/bar is NOT a triggering case (the overlapping + // '/' hides the second match), but /foo/other/foo/bar has a second /foo/ + // at a non-overlapping position and does expose the bug. + let conn = conn(); + let _ = create_tag_path(&conn, "foo/other/foo/bar").unwrap(); + let foo = get_tag_by_path(&conn, "/foo").unwrap().unwrap(); + let other = get_tag_by_path(&conn, "/foo/other").unwrap().unwrap(); + let other_foo = get_tag_by_path(&conn, "/foo/other/foo").unwrap().unwrap(); + let bar = get_tag_by_path(&conn, "/foo/other/foo/bar") + .unwrap() + .unwrap(); + + let _ = create_tag_path(&conn, "dest").unwrap(); + let dest = get_tag_by_path(&conn, "/dest").unwrap().unwrap(); + + let updated = move_tag(&conn, &foo.tag_uid, Some(&dest.tag_uid)) + .unwrap() + .expect("should return updated tag"); + assert_eq!(updated.full_path, "/dest/foo"); + + let other_new = get_tag_by_uid(&conn, &other.tag_uid).unwrap().unwrap(); + assert_eq!(other_new.full_path, "/dest/foo/other"); + + // /foo/other/foo must become /dest/foo/other/foo — REPLACE gives /dest/foo/other/dest/foo. + let other_foo_new = get_tag_by_uid(&conn, &other_foo.tag_uid).unwrap().unwrap(); + assert_eq!( + other_foo_new.full_path, "/dest/foo/other/foo", + "cascade must only replace the leading /foo/ prefix, not every occurrence" + ); + + let bar_new = get_tag_by_uid(&conn, &bar.tag_uid).unwrap().unwrap(); + assert_eq!(bar_new.full_path, "/dest/foo/other/foo/bar"); + } + + #[test] + fn rename_tag_cascade_only_replaces_leading_prefix() { + // Same REPLACE bug applies to rename_tag's cascade. + let conn = conn(); + let _ = create_tag_path(&conn, "foo/other/foo/bar").unwrap(); + let foo = get_tag_by_path(&conn, "/foo").unwrap().unwrap(); + let other = get_tag_by_path(&conn, "/foo/other").unwrap().unwrap(); + let other_foo = get_tag_by_path(&conn, "/foo/other/foo").unwrap().unwrap(); + let bar = get_tag_by_path(&conn, "/foo/other/foo/bar") + .unwrap() + .unwrap(); + + let updated = rename_tag(&conn, &foo.tag_uid, "renamed") + .unwrap() + .expect("should return updated tag"); + assert_eq!(updated.full_path, "/renamed"); + + let other_new = get_tag_by_uid(&conn, &other.tag_uid).unwrap().unwrap(); + assert_eq!(other_new.full_path, "/renamed/other"); + + // /foo/other/foo must become /renamed/other/foo — REPLACE gives /renamed/other/renamed. + let other_foo_new = get_tag_by_uid(&conn, &other_foo.tag_uid).unwrap().unwrap(); + assert_eq!( + other_foo_new.full_path, "/renamed/other/foo", + "cascade must only replace the leading /foo/ prefix, not every occurrence" + ); + + let bar_new = get_tag_by_uid(&conn, &bar.tag_uid).unwrap().unwrap(); + assert_eq!(bar_new.full_path, "/renamed/other/foo/bar"); + } + // ── delete_entry tests ──────────────────────────────────────────────────── /// Helper: attach a shared blob to an entry and return the blob id. @@ -3151,15 +3546,19 @@ mod tests { raw_relpath: format!("raw/{sha256}"), }; let blob_id = upsert_blob(conn, &blob).unwrap(); - add_entry_artifact(conn, &NewArtifact { - entry_id, - artifact_role: "main".to_string(), - storage_area: "raw".to_string(), - relpath: format!("raw/{sha256}"), - blob_id: Some(blob_id), - logical_path: None, - metadata_json: None, - }).unwrap(); + add_entry_artifact( + conn, + &NewArtifact { + entry_id, + artifact_role: "main".to_string(), + storage_area: "raw".to_string(), + relpath: format!("raw/{sha256}"), + blob_id: Some(blob_id), + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); blob_id } @@ -3178,11 +3577,21 @@ mod tests { delete_entry(&conn, &root.entry_uid).unwrap(); let root_gone: Option = conn - .query_row("SELECT id FROM archived_entries WHERE id = ?1", [root.id], |r| r.get(0)) - .optional().unwrap(); + .query_row( + "SELECT id FROM archived_entries WHERE id = ?1", + [root.id], + |r| r.get(0), + ) + .optional() + .unwrap(); let child_gone: Option = conn - .query_row("SELECT id FROM archived_entries WHERE id = ?1", [child.id], |r| r.get(0)) - .optional().unwrap(); + .query_row( + "SELECT id FROM archived_entries WHERE id = ?1", + [child.id], + |r| r.get(0), + ) + .optional() + .unwrap(); assert!(root_gone.is_none(), "root should be gone"); assert!(child_gone.is_none(), "child should be gone"); } @@ -3194,8 +3603,16 @@ mod tests { let root = create_entry_fixture(&conn, "private", None, None); let run = create_archive_run(&conn, user_id, 1).unwrap(); let item = create_archive_run_item( - &conn, run.id, None, 0, "https://example.com", None, "web", "page", - ).unwrap(); + &conn, + run.id, + None, + 0, + "https://example.com", + None, + "web", + "page", + ) + .unwrap(); complete_archive_run_item(&conn, item.id, root.id).unwrap(); delete_entry(&conn, &root.entry_uid).unwrap(); @@ -3207,7 +3624,10 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert!(produced.is_none(), "produced_entry_id should be NULL after delete"); + assert!( + produced.is_none(), + "produced_entry_id should be NULL after delete" + ); } #[test] @@ -3230,17 +3650,31 @@ mod tests { // Compute external.cached_bytes before delete — root and child are older by id. refresh_entry_cached_bytes(&conn, external.id).unwrap(); let before: i64 = conn - .query_row("SELECT cached_bytes FROM archived_entries WHERE id = ?1", [external.id], |r| r.get(0)) + .query_row( + "SELECT cached_bytes FROM archived_entries WHERE id = ?1", + [external.id], + |r| r.get(0), + ) .unwrap(); - assert_eq!(before, 100, "external should see blob as cached before delete"); + assert_eq!( + before, 100, + "external should see blob as cached before delete" + ); delete_entry(&conn, &root.entry_uid).unwrap(); // external must still exist but with cached_bytes = 0. let after: i64 = conn - .query_row("SELECT cached_bytes FROM archived_entries WHERE id = ?1", [external.id], |r| r.get(0)) + .query_row( + "SELECT cached_bytes FROM archived_entries WHERE id = ?1", + [external.id], + |r| r.get(0), + ) .unwrap(); - assert_eq!(after, 0, "cached_bytes must be 0 after whole subtree is deleted"); + assert_eq!( + after, 0, + "cached_bytes must be 0 after whole subtree is deleted" + ); } // ── Orphan blob cleanup ─────────────────────────────────────────────────────── @@ -3286,29 +3720,39 @@ mod tests { raw_relpath: "raw/a/a/aaa111.mp4".to_string(), }; let blob_id = upsert_blob(&conn, &blob).unwrap(); - add_entry_artifact(&conn, &NewArtifact { - entry_id: entry.id, - artifact_role: "main".to_string(), - storage_area: "raw".to_string(), - relpath: blob.raw_relpath.clone(), - blob_id: Some(blob_id), - logical_path: None, - metadata_json: None, - }).unwrap(); - assert!(list_orphaned_blob_rows(&conn).unwrap().is_empty(), - "referenced blob must not appear as orphan"); + add_entry_artifact( + &conn, + &NewArtifact { + entry_id: entry.id, + artifact_role: "main".to_string(), + storage_area: "raw".to_string(), + relpath: blob.raw_relpath.clone(), + blob_id: Some(blob_id), + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); + assert!( + list_orphaned_blob_rows(&conn).unwrap().is_empty(), + "referenced blob must not appear as orphan" + ); } #[test] fn list_orphaned_blob_rows_finds_unreferenced_blob() { let conn = conn(); - upsert_blob(&conn, &BlobRecord { - sha256: "bbb222".to_string(), - byte_size: 200, - mime_type: None, - extension: Some("jpg".to_string()), - raw_relpath: "raw/b/b/bbb222.jpg".to_string(), - }).unwrap(); + upsert_blob( + &conn, + &BlobRecord { + sha256: "bbb222".to_string(), + byte_size: 200, + mime_type: None, + extension: Some("jpg".to_string()), + raw_relpath: "raw/b/b/bbb222.jpg".to_string(), + }, + ) + .unwrap(); let orphans = list_orphaned_blob_rows(&conn).unwrap(); assert_eq!(orphans.len(), 1, "unreferenced blob must appear as orphan"); assert_eq!(orphans[0].1, "raw/b/b/bbb222.jpg"); @@ -3320,31 +3764,49 @@ mod tests { let entry = create_entry_fixture(&conn, "private", None, None); // Live blob: linked via blob_id let blob = BlobRecord { - sha256: "live1".to_string(), byte_size: 50, - mime_type: None, extension: None, + sha256: "live1".to_string(), + byte_size: 50, + mime_type: None, + extension: None, raw_relpath: "raw/l/i/live1".to_string(), }; let blob_id = upsert_blob(&conn, &blob).unwrap(); - add_entry_artifact(&conn, &NewArtifact { - entry_id: entry.id, - artifact_role: "main".to_string(), - storage_area: "raw".to_string(), - relpath: blob.raw_relpath.clone(), - blob_id: Some(blob_id), - logical_path: None, metadata_json: None, - }).unwrap(); + add_entry_artifact( + &conn, + &NewArtifact { + entry_id: entry.id, + artifact_role: "main".to_string(), + storage_area: "raw".to_string(), + relpath: blob.raw_relpath.clone(), + blob_id: Some(blob_id), + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); // Artifact referencing a file directly (no blob_id) - add_entry_artifact(&conn, &NewArtifact { - entry_id: entry.id, - artifact_role: "sidecar".to_string(), - storage_area: "raw".to_string(), - relpath: "raw/s/i/sidecar.vtt".to_string(), - blob_id: None, - logical_path: None, metadata_json: None, - }).unwrap(); + add_entry_artifact( + &conn, + &NewArtifact { + entry_id: entry.id, + artifact_role: "sidecar".to_string(), + storage_area: "raw".to_string(), + relpath: "raw/s/i/sidecar.vtt".to_string(), + blob_id: None, + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); let refs = all_referenced_file_relpaths(&conn).unwrap(); - assert!(refs.contains("raw/l/i/live1"), "live blob relpath must be protected"); - assert!(refs.contains("raw/s/i/sidecar.vtt"), "direct artifact relpath must be protected"); + assert!( + refs.contains("raw/l/i/live1"), + "live blob relpath must be protected" + ); + assert!( + refs.contains("raw/s/i/sidecar.vtt"), + "direct artifact relpath must be protected" + ); } #[test] @@ -3353,29 +3815,47 @@ mod tests { let entry = create_entry_fixture(&conn, "private", None, None); // Referenced blob let live = BlobRecord { - sha256: "live9999".to_string(), byte_size: 10, - mime_type: None, extension: None, + sha256: "live9999".to_string(), + byte_size: 10, + mime_type: None, + extension: None, raw_relpath: "raw/l/v/live9999".to_string(), }; let live_id = upsert_blob(&conn, &live).unwrap(); - add_entry_artifact(&conn, &NewArtifact { - entry_id: entry.id, - artifact_role: "main".to_string(), - storage_area: "raw".to_string(), - relpath: live.raw_relpath.clone(), - blob_id: Some(live_id), - logical_path: None, metadata_json: None, - }).unwrap(); + add_entry_artifact( + &conn, + &NewArtifact { + entry_id: entry.id, + artifact_role: "main".to_string(), + storage_area: "raw".to_string(), + relpath: live.raw_relpath.clone(), + blob_id: Some(live_id), + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); // Orphaned blob - upsert_blob(&conn, &BlobRecord { - sha256: "dead0000".to_string(), byte_size: 20, - mime_type: None, extension: None, - raw_relpath: "raw/d/e/dead0000".to_string(), - }).unwrap(); + upsert_blob( + &conn, + &BlobRecord { + sha256: "dead0000".to_string(), + byte_size: 20, + mime_type: None, + extension: None, + raw_relpath: "raw/d/e/dead0000".to_string(), + }, + ) + .unwrap(); let deleted = delete_orphaned_blob_rows(&conn).unwrap(); - assert_eq!(deleted, 1, "only the unreferenced blob row should be deleted"); - assert!(get_blob_by_sha256(&conn, "live9999").unwrap().is_some(), - "referenced blob row must be preserved"); + assert_eq!( + deleted, 1, + "only the unreferenced blob row should be deleted" + ); + assert!( + get_blob_by_sha256(&conn, "live9999").unwrap().is_some(), + "referenced blob row must be preserved" + ); } #[test] @@ -3386,26 +3866,34 @@ mod tests { let conn = conn(); let entry = create_entry_fixture(&conn, "private", None, None); let blob = BlobRecord { - sha256: "edgecase".to_string(), byte_size: 30, - mime_type: None, extension: None, + sha256: "edgecase".to_string(), + byte_size: 30, + mime_type: None, + extension: None, raw_relpath: "raw/e/d/edgecase".to_string(), }; upsert_blob(&conn, &blob).unwrap(); // artifact uses same relpath but no blob_id - add_entry_artifact(&conn, &NewArtifact { - entry_id: entry.id, - artifact_role: "sidecar".to_string(), - storage_area: "raw".to_string(), - relpath: blob.raw_relpath.clone(), - blob_id: None, - logical_path: None, metadata_json: None, - }).unwrap(); + add_entry_artifact( + &conn, + &NewArtifact { + entry_id: entry.id, + artifact_role: "sidecar".to_string(), + storage_area: "raw".to_string(), + relpath: blob.raw_relpath.clone(), + blob_id: None, + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); // blob row is orphaned (no blob_id reference) assert_eq!(list_orphaned_blob_rows(&conn).unwrap().len(), 1); // but the file relpath is still protected let refs = all_referenced_file_relpaths(&conn).unwrap(); - assert!(refs.contains(&blob.raw_relpath), - "file must be protected because artifact.relpath references it directly"); + assert!( + refs.contains(&blob.raw_relpath), + "file must be protected because artifact.relpath references it directly" + ); } - } diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 28a86b6..3388a05 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -20,6 +20,7 @@ // GET/PATCH /api/admin/instance-settings // ──────────────────────────────────────────────────────────────────────────── +use parking_lot::Mutex; use std::{ collections::{HashMap, VecDeque}, net::{IpAddr, SocketAddr}, @@ -27,7 +28,6 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use parking_lot::Mutex; use archivr_core::{archive, capture, database, downloader}; use axum::{ @@ -38,12 +38,12 @@ use axum::{ response::{IntoResponse, Response}, routing::{delete, get, patch, post}, }; -use tower_http::services::{ServeDir, ServeFile}; use tower::ServiceExt; +use tower_http::services::{ServeDir, ServeFile}; -use crate::registry::{MountedArchive, ServerRegistry}; use crate::auth; pub use crate::auth::{AuthUser, ROLE_ADMIN, ROLE_GUEST, ROLE_OWNER, ROLE_USER}; +use crate::registry::{MountedArchive, ServerRegistry}; use axum_extra::extract::CookieJar; use rusqlite::OptionalExtension; @@ -64,11 +64,7 @@ pub struct EntrySearchParams { } /// Tower middleware: returns 503 on all non-exempt routes if setup hasn't been completed. -async fn setup_guard( - State(state): State, - req: Request, - next: Next, -) -> Response { +async fn setup_guard(State(state): State, req: Request, next: Next) -> Response { let path = req.uri().path().to_owned(); let exempt = path.starts_with("/api/auth/") || path.starts_with("/assets") @@ -105,7 +101,9 @@ async fn security_headers(req: Request, next: Next) -> Response { ); headers.insert( axum::http::header::HeaderName::from_static("permissions-policy"), - axum::http::HeaderValue::from_static("camera=(), microphone=(), geolocation=(), autoplay=()"), + axum::http::HeaderValue::from_static( + "camera=(), microphone=(), geolocation=(), autoplay=()", + ), ); if is_artifact { // Artifact responses are iframed by the preview modal (sandboxed, no allow-scripts). @@ -149,11 +147,7 @@ async fn security_headers(req: Request, next: Next) -> Response { response } -async fn login_rate_limit( - State(state): State, - req: Request, - next: Next, -) -> Response { +async fn login_rate_limit(State(state): State, req: Request, next: Next) -> Response { if req.method() != axum::http::Method::POST || req.uri().path() != "/api/auth/login" { return next.run(req).await; } @@ -237,10 +231,15 @@ pub fn app_with_state(state: AppState) -> Router { .route("/health", get(|| async { "ok" })) .route("/api/archives", get(list_archives)) .route("/api/archives/:archive_id/entries", get(list_entries)) - .route("/api/archives/:archive_id/entries/search", get(search_entries_handler)) + .route( + "/api/archives/:archive_id/entries/search", + get(search_entries_handler), + ) .route( "/api/archives/:archive_id/entries/:entry_uid", - get(entry_detail).patch(patch_entry_handler).delete(delete_entry_handler), + get(entry_detail) + .patch(patch_entry_handler) + .delete(delete_entry_handler), ) .route( "/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index", @@ -254,22 +253,29 @@ pub fn app_with_state(state: AppState) -> Router { "/api/archives/:archive_id/entries/:entry_uid/favicon", get(serve_entry_favicon), ) - .route( - "/api/archives/:archive_id/blobs/:sha256", - get(serve_blob), - ) + .route("/api/archives/:archive_id/blobs/:sha256", get(serve_blob)) .route("/api/archives/:archive_id/runs", get(list_runs)) .route("/api/archives/:archive_id/captures", post(capture_handler)) - .route("/api/archives/:archive_id/captures/probe", get(probe_handler)) + .route( + "/api/archives/:archive_id/captures/probe", + get(probe_handler), + ) .route( "/api/archives/:archive_id/capture_jobs/:job_uid", get(get_capture_job_handler), ) - .route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler)) + .route( + "/api/archives/:archive_id/tags", + get(list_tags).post(create_tag_handler), + ) .route( "/api/archives/:archive_id/tags/:tag_uid", patch(patch_tag_handler).delete(delete_tag_handler), ) + .route( + "/api/archives/:archive_id/tags/:tag_uid/move", + post(move_tag_handler), + ) .route( "/api/archives/:archive_id/entries/:entry_uid/tags", get(list_entry_tags).post(assign_entry_tag_handler), @@ -278,17 +284,41 @@ pub fn app_with_state(state: AppState) -> Router { "/api/archives/:archive_id/entries/:entry_uid/tags/:tag_uid", delete(remove_entry_tag_handler), ) - .route("/api/auth/setup", axum::routing::get(auth_setup_status).post(auth_setup)) - .route("/api/auth/login", axum::routing::post(auth_login)) + .route( + "/api/auth/setup", + axum::routing::get(auth_setup_status).post(auth_setup), + ) + .route("/api/auth/login", axum::routing::post(auth_login)) .route("/api/auth/logout", axum::routing::post(auth_logout)) - .route("/api/auth/me", axum::routing::get(auth_me).patch(patch_me)) - .route("/api/auth/tokens", axum::routing::get(list_tokens).post(create_token)) - .route("/api/auth/tokens/:token_uid", axum::routing::delete(delete_token)) - .route("/api/admin/users", get(admin_list_users).post(admin_create_user)) - .route("/api/admin/users/:uid/status", axum::routing::patch(admin_set_user_status)) - .route("/api/admin/users/:uid/roles", axum::routing::post(admin_assign_role)) - .route("/api/admin/users/:uid/roles/:role_slug", axum::routing::delete(admin_remove_role)) - .route("/api/admin/roles", get(admin_list_roles).post(admin_create_role)) + .route("/api/auth/me", axum::routing::get(auth_me).patch(patch_me)) + .route( + "/api/auth/tokens", + axum::routing::get(list_tokens).post(create_token), + ) + .route( + "/api/auth/tokens/:token_uid", + axum::routing::delete(delete_token), + ) + .route( + "/api/admin/users", + get(admin_list_users).post(admin_create_user), + ) + .route( + "/api/admin/users/:uid/status", + axum::routing::patch(admin_set_user_status), + ) + .route( + "/api/admin/users/:uid/roles", + axum::routing::post(admin_assign_role), + ) + .route( + "/api/admin/users/:uid/roles/:role_slug", + axum::routing::delete(admin_remove_role), + ) + .route( + "/api/admin/roles", + get(admin_list_roles).post(admin_create_role), + ) .route( "/api/admin/instance-settings", get(get_instance_settings_handler).patch(update_instance_settings_handler), @@ -317,8 +347,7 @@ pub fn app_with_state(state: AppState) -> Router { ) .route( "/api/archives/:archive_id/collections/:coll_uid/entries/:entry_uid", - delete(remove_entry_from_collection_handler) - .patch(update_entry_visibility_handler), + delete(remove_entry_from_collection_handler).patch(update_entry_visibility_handler), ) .route( "/api/archives/:archive_id/entries/:entry_uid/collections", @@ -331,8 +360,14 @@ pub fn app_with_state(state: AppState) -> Router { .route("/api/util/resolve-tco", post(resolve_tco_handler)) .nest_service("/assets", ServeDir::new(static_dir.join("assets"))) .fallback_service(ServeFile::new(static_dir.join("index.html"))) - .layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard)) - .layer(axum::middleware::from_fn_with_state(state.clone(), login_rate_limit)) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + setup_guard, + )) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + login_rate_limit, + )) .layer(axum::middleware::from_fn(security_headers)) .with_state(state) } @@ -509,7 +544,9 @@ struct CreateCollectionBody { default_visibility_bits: u32, } -fn default_user_visibility() -> u32 { 2 } +fn default_user_visibility() -> u32 { + 2 +} #[derive(Debug, serde::Deserialize)] struct AddEntryBody { @@ -638,6 +675,26 @@ async fn delete_tag_handler( } } +async fn move_tag_handler( + State(state): State, + auth_user: AuthUser, + Path((archive_id, tag_uid)): Path<(String, String)>, + Json(body): Json, +) -> Result, ApiError> { + auth_user.require_role(ROLE_USER)?; + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + match database::move_tag(&conn, &tag_uid, body.parent_uid.as_deref())? { + Some(record) => Ok(Json(archive::Tag { + tag_uid: record.tag_uid, + name: record.name, + slug: record.slug, + full_path: record.full_path, + })), + None => Err(ApiError::not_found("tag not found")), + } +} + async fn patch_entry_handler( State(state): State, auth_user: AuthUser, @@ -647,10 +704,14 @@ async fn patch_entry_handler( auth_user.require_role(ROLE_USER)?; let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; - let title = body.title.as_deref().map(|s| { - let t = s.trim(); - if t.is_empty() { None } else { Some(t) } - }).flatten(); + let title = body + .title + .as_deref() + .map(|s| { + let t = s.trim(); + if t.is_empty() { None } else { Some(t) } + }) + .flatten(); if database::update_entry_title(&conn, &entry_uid, title)? { Ok(StatusCode::NO_CONTENT) } else { @@ -720,6 +781,12 @@ struct PatchTagBody { name: String, } +#[derive(Debug, serde::Deserialize)] +struct MoveTagBody { + /// `None` promotes the tag to root; `Some(uid)` sets a new parent. + parent_uid: Option, +} + #[derive(Debug, serde::Deserialize)] struct CreateCookieRuleBody { url_pattern: Option, @@ -758,8 +825,8 @@ async fn capture_handler( } } let mounted = mounted_archive(&state, &archive_id)?; - let archive_paths = archive::read_archive_paths(&mounted.archive_path) - .map_err(ApiError::from)?; + let archive_paths = + archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?; // Create job record in the archive DB. let conn = database::open_or_initialize(&mounted.archive_path)?; @@ -772,7 +839,10 @@ async fn capture_handler( let rules = database::list_cookie_rules(&conn).unwrap_or_default(); let settings = database::get_instance_settings(&conn); let ublock = settings.as_ref().map(|s| s.ublock_enabled).unwrap_or(true); - let cookie_ext = settings.as_ref().map(|s| s.cookie_ext_enabled).unwrap_or(true); + let cookie_ext = settings + .as_ref() + .map(|s| s.cookie_ext_enabled) + .unwrap_or(true); let modal_closer = settings.map(|s| s.modal_closer_enabled).unwrap_or(true); (rules, ublock, cookie_ext, modal_closer) } @@ -807,7 +877,13 @@ async fn capture_handler( } }; database::update_capture_job_status(&conn, &job_uid_bg, "running", None, None, None).ok(); - match capture::perform_capture(&archive_paths, &locator, Some(&archive_id_bg), quality.as_deref(), &capture_config) { + match capture::perform_capture( + &archive_paths, + &locator, + Some(&archive_id_bg), + quality.as_deref(), + &capture_config, + ) { Ok(result) => { let mut notes_map = serde_json::Map::new(); if result.ublock_skipped { @@ -882,8 +958,8 @@ async fn rearchive_handler( ) -> Result<(StatusCode, Json), ApiError> { auth_user.require_role(ROLE_USER)?; let mounted = mounted_archive(&state, &archive_id)?; - let archive_paths = archive::read_archive_paths(&mounted.archive_path) - .map_err(ApiError::from)?; + let archive_paths = + archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?; // Create a capture job record so the client can poll for completion. let conn = database::open_or_initialize(&mounted.archive_path)?; @@ -918,22 +994,38 @@ async fn rearchive_handler( Ok(result) => { if result.status == "completed" { database::update_capture_job_status( - &conn, &job_uid_bg, "completed", None, None, None, - ).ok(); + &conn, + &job_uid_bg, + "completed", + None, + None, + None, + ) + .ok(); } else { // "not_a_tweet" or "scraper_failed" — surface as a job failure // so the client sees a meaningful error message. database::update_capture_job_status( - &conn, &job_uid_bg, "failed", - None, Some(&result.message), None, - ).ok(); + &conn, + &job_uid_bg, + "failed", + None, + Some(&result.message), + None, + ) + .ok(); } } Err(e) => { database::update_capture_job_status( - &conn, &job_uid_bg, "failed", - None, Some(&format!("{e:#}")), None, - ).ok(); + &conn, + &job_uid_bg, + "failed", + None, + Some(&format!("{e:#}")), + None, + ) + .ok(); } } }); @@ -974,7 +1066,9 @@ async fn probe_handler( // Resolve to a yt-dlp URL; return empty result immediately for non-video sources. let Some(ytdlp_url) = capture::locator_to_ytdlp_url(&locator) else { - return Ok(Json(serde_json::json!({ "has_video": false, "has_audio": false, "qualities": [] }))); + return Ok(Json( + serde_json::json!({ "has_video": false, "has_audio": false, "qualities": [] }), + )); }; // fetch_metadata shells out and can take several seconds — keep the async runtime free. @@ -999,7 +1093,11 @@ async fn probe_handler( message: "yt-dlp metadata fetch failed".to_string(), })?; - let qualities: Vec = result.video_heights.iter().map(|h| format!("{h}p")).collect(); + let qualities: Vec = result + .video_heights + .iter() + .map(|h| format!("{h}p")) + .collect(); Ok(Json(serde_json::json!({ "has_video": !qualities.is_empty(), "qualities": qualities, @@ -1027,16 +1125,21 @@ async fn auth_setup( }); } if body.username.trim().is_empty() || body.password.len() < 8 { - return Err(ApiError::bad_request("username required and password must be at least 8 characters")); + return Err(ApiError::bad_request( + "username required and password must be at least 8 characters", + )); } let hash = auth::hash_password(&body.password).map_err(ApiError::from)?; database::create_owner(&conn, &body.username, &hash)?; let user = database::get_user_by_username(&conn, &body.username)? .ok_or_else(|| ApiError::internal("user not found after creation"))?; - Ok((StatusCode::CREATED, Json(serde_json::json!({ - "user_uid": user.user_uid, - "username": user.username, - })))) + Ok(( + StatusCode::CREATED, + Json(serde_json::json!({ + "user_uid": user.user_uid, + "username": user.username, + })), + )) } async fn auth_login( @@ -1048,9 +1151,7 @@ async fn auth_login( let user = database::get_user_by_username(&conn, &body.username)? .filter(|u| u.status == "active") .ok_or_else(|| ApiError::unauthorized("invalid_credentials"))?; - if !auth::verify_password(&body.password, &user.password_hash) - .map_err(ApiError::from)? - { + if !auth::verify_password(&body.password, &user.password_hash).map_err(ApiError::from)? { return Err(ApiError::unauthorized("invalid_credentials")); } let role_bits = database::compute_role_bits(&conn, user.id)?; @@ -1070,14 +1171,20 @@ async fn auth_login( let mut resp_headers = axum::http::HeaderMap::new(); resp_headers.insert( axum::http::header::SET_COOKIE, - cookie_value.parse().map_err(|_| ApiError::internal("cookie error"))?, + cookie_value + .parse() + .map_err(|_| ApiError::internal("cookie error"))?, ); - Ok((StatusCode::OK, resp_headers, Json(serde_json::json!({ - "user_uid": user.user_uid, - "username": user.username, - "role_bits": role_bits, - })))) + Ok(( + StatusCode::OK, + resp_headers, + Json(serde_json::json!({ + "user_uid": user.user_uid, + "username": user.username, + "role_bits": role_bits, + })), + )) } async fn auth_logout( @@ -1108,7 +1215,7 @@ async fn auth_me( .query_row( "SELECT username, display_name, COALESCE(humanize_slugs, 0) FROM users WHERE id = ?1", [user_id], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .map_err(|e| ApiError::from(anyhow::anyhow!("db error: {e}")))?; let humanize_slugs = humanize_slugs_int != 0; @@ -1143,7 +1250,11 @@ async fn patch_me( } if let Some(ref dn) = body.display_name { - let v: Option<&str> = if dn.trim().is_empty() { None } else { Some(dn.as_str()) }; + let v: Option<&str> = if dn.trim().is_empty() { + None + } else { + Some(dn.as_str()) + }; database::update_user_display_name(&conn, user_id, v)?; } @@ -1173,8 +1284,14 @@ async fn get_instance_settings_handler( .unwrap_or(false); let mut val = serde_json::to_value(&settings).unwrap_or_default(); if let Some(obj) = val.as_object_mut() { - obj.insert("ublock_ext_available".into(), serde_json::Value::Bool(ublock_ext_available)); - obj.insert("cookie_ext_available".into(), serde_json::Value::Bool(cookie_ext_available)); + obj.insert( + "ublock_ext_available".into(), + serde_json::Value::Bool(ublock_ext_available), + ); + obj.insert( + "cookie_ext_available".into(), + serde_json::Value::Bool(cookie_ext_available), + ); } Ok(Json(val)) } @@ -1187,13 +1304,27 @@ async fn update_instance_settings_handler( auth_user.require_role(ROLE_ADMIN)?; let conn = database::open_auth_db(&state.auth_db_path)?; let mut settings = database::get_instance_settings(&conn)?; - if let Some(v) = body.public_index_enabled { settings.public_index_enabled = v; } - if let Some(v) = body.public_entry_content_enabled { settings.public_entry_content_enabled = v; } - if let Some(v) = body.open_registration_enabled { settings.open_registration_enabled = v; } - if let Some(v) = body.default_entry_visibility { settings.default_entry_visibility = v; } - if let Some(v) = body.ublock_enabled { settings.ublock_enabled = v; } - if let Some(v) = body.cookie_ext_enabled { settings.cookie_ext_enabled = v; } - if let Some(v) = body.modal_closer_enabled { settings.modal_closer_enabled = v; } + if let Some(v) = body.public_index_enabled { + settings.public_index_enabled = v; + } + if let Some(v) = body.public_entry_content_enabled { + settings.public_entry_content_enabled = v; + } + if let Some(v) = body.open_registration_enabled { + settings.open_registration_enabled = v; + } + if let Some(v) = body.default_entry_visibility { + settings.default_entry_visibility = v; + } + if let Some(v) = body.ublock_enabled { + settings.ublock_enabled = v; + } + if let Some(v) = body.cookie_ext_enabled { + settings.cookie_ext_enabled = v; + } + if let Some(v) = body.modal_closer_enabled { + settings.modal_closer_enabled = v; + } database::update_instance_settings(&conn, &settings)?; Ok(StatusCode::NO_CONTENT) } @@ -1220,13 +1351,14 @@ async fn create_cookie_rule_handler( "pattern_kind must be 'global', 'wildcard', or 'regex'", )); } - if serde_json::from_str::>(&body.cookies_json).is_err() { + if serde_json::from_str::>(&body.cookies_json) + .is_err() + { return Err(ApiError::bad_request( "cookies_json must be a JSON object whose values are all strings, e.g. {\"name\": \"value\"}", )); } - if body.pattern_kind != "global" - && body.url_pattern.as_deref().unwrap_or("").trim().is_empty() + if body.pattern_kind != "global" && body.url_pattern.as_deref().unwrap_or("").trim().is_empty() { return Err(ApiError::bad_request( "url_pattern is required for non-global rules", @@ -1239,11 +1371,9 @@ async fn create_cookie_rule_handler( } } let conn = database::open_auth_db(&state.auth_db_path)?; - let url_pattern = body - .url_pattern - .as_deref() - .filter(|s| !s.trim().is_empty()); - let rule = database::create_cookie_rule(&conn, url_pattern, &body.pattern_kind, &body.cookies_json)?; + let url_pattern = body.url_pattern.as_deref().filter(|s| !s.trim().is_empty()); + let rule = + database::create_cookie_rule(&conn, url_pattern, &body.pattern_kind, &body.cookies_json)?; Ok((StatusCode::CREATED, Json(rule))) } @@ -1386,7 +1516,6 @@ async fn blob_cleanup_delete_handler( )); } - // Delete orphaned blob rows from the database. let deleted_blob_rows = database::delete_orphaned_blob_rows(&conn)?; @@ -1406,7 +1535,11 @@ async fn blob_cleanup_delete_handler( eprintln!( "info: blob cleanup for '{}': {} blob rows, {} files deleted, {} bytes freed, {} errors", - archive_id, deleted_blob_rows, deleted_files, freed_bytes, errors.len() + archive_id, + deleted_blob_rows, + deleted_files, + freed_bytes, + errors.len() ); Ok(Json(serde_json::json!({ @@ -1467,11 +1600,14 @@ async fn create_token( let token_hash = auth::hash_token(&raw_token); let conn = database::open_auth_db(&state.auth_db_path)?; let token_uid = database::create_api_token(&conn, user_id, &token_hash, &body.name)?; - Ok((StatusCode::CREATED, Json(serde_json::json!({ - "token_uid": token_uid, - "raw_token": raw_token, - "name": body.name, - })))) + Ok(( + StatusCode::CREATED, + Json(serde_json::json!({ + "token_uid": token_uid, + "raw_token": raw_token, + "name": body.name, + })), + )) } async fn list_tokens( @@ -1498,16 +1634,27 @@ async fn delete_token( } #[derive(Debug, serde::Deserialize)] -struct AdminCreateUserBody { username: String, password: String, email: Option } +struct AdminCreateUserBody { + username: String, + password: String, + email: Option, +} #[derive(Debug, serde::Deserialize)] -struct AdminSetStatusBody { status: String } +struct AdminSetStatusBody { + status: String, +} #[derive(Debug, serde::Deserialize)] -struct AdminAssignRoleBody { role_slug: String } +struct AdminAssignRoleBody { + role_slug: String, +} #[derive(Debug, serde::Deserialize)] -struct AdminCreateRoleBody { slug: String, name: String } +struct AdminCreateRoleBody { + slug: String, + name: String, +} #[derive(Debug, serde::Deserialize)] struct UpdateProfileBody { @@ -1545,12 +1692,23 @@ async fn admin_create_user( auth_user.require_role(ROLE_ADMIN)?; let (caller_id, _) = auth_user.require_auth()?; if body.username.trim().is_empty() || body.password.len() < 8 { - return Err(ApiError::bad_request("username required, password >= 8 chars")); + return Err(ApiError::bad_request( + "username required, password >= 8 chars", + )); } let conn = database::open_auth_db(&state.auth_db_path)?; let hash = auth::hash_password(&body.password).map_err(ApiError::from)?; - let uid = database::create_user(&conn, &body.username, body.email.as_deref(), &hash, caller_id)?; - Ok((StatusCode::CREATED, Json(serde_json::json!({ "user_uid": uid, "username": body.username })))) + let uid = database::create_user( + &conn, + &body.username, + body.email.as_deref(), + &hash, + caller_id, + )?; + Ok(( + StatusCode::CREATED, + Json(serde_json::json!({ "user_uid": uid, "username": body.username })), + )) } async fn admin_set_user_status( @@ -1561,13 +1719,17 @@ async fn admin_set_user_status( ) -> Result, ApiError> { auth_user.require_role(ROLE_ADMIN)?; if body.status != "active" && body.status != "disabled" { - return Err(ApiError::bad_request("status must be 'active' or 'disabled'")); + return Err(ApiError::bad_request( + "status must be 'active' or 'disabled'", + )); } let conn = database::open_auth_db(&state.auth_db_path)?; if !database::set_user_status(&conn, &uid, &body.status)? { return Err(ApiError::not_found("user not found")); } - Ok(Json(serde_json::json!({ "user_uid": uid, "status": body.status }))) + Ok(Json( + serde_json::json!({ "user_uid": uid, "status": body.status }), + )) } async fn admin_assign_role( @@ -1614,8 +1776,8 @@ async fn admin_create_role( ) -> Result<(StatusCode, Json), ApiError> { auth_user.require_role(ROLE_ADMIN)?; let conn = database::open_auth_db(&state.auth_db_path)?; - let role = database::create_custom_role(&conn, &body.slug, &body.name) - .map_err(ApiError::from)?; + let role = + database::create_custom_role(&conn, &body.slug, &body.name).map_err(ApiError::from)?; Ok((StatusCode::CREATED, Json(role))) } @@ -1667,15 +1829,24 @@ impl ApiError { } pub fn unauthorized(message: &str) -> Self { - Self { status: StatusCode::UNAUTHORIZED, message: message.to_string() } + Self { + status: StatusCode::UNAUTHORIZED, + message: message.to_string(), + } } pub fn forbidden(message: &str) -> Self { - Self { status: StatusCode::FORBIDDEN, message: message.to_string() } + Self { + status: StatusCode::FORBIDDEN, + message: message.to_string(), + } } fn conflict(message: &str) -> Self { - Self { status: StatusCode::CONFLICT, message: message.to_string() } + Self { + status: StatusCode::CONFLICT, + message: message.to_string(), + } } } @@ -1723,19 +1894,25 @@ async fn create_collection_handler( return Err(ApiError::bad_request("collection name must not be empty")); } if body.slug.trim().is_empty() || body.slug.starts_with('_') { - return Err(ApiError::bad_request("collection slug must not be empty or start with underscore")); + return Err(ApiError::bad_request( + "collection slug must not be empty or start with underscore", + )); } let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; - let record = database::create_collection(&conn, &body.name, &body.slug, body.default_visibility_bits) - .map_err(|e| ApiError::bad_request(&format!("{e:#}")))?; - Ok((StatusCode::CREATED, Json(archive::CollectionSummary { - collection_uid: record.collection_uid, - name: record.name, - slug: record.slug, - default_visibility_bits: record.default_visibility_bits, - created_at: record.created_at, - }))) + let record = + database::create_collection(&conn, &body.name, &body.slug, body.default_visibility_bits) + .map_err(|e| ApiError::bad_request(&format!("{e:#}")))?; + Ok(( + StatusCode::CREATED, + Json(archive::CollectionSummary { + collection_uid: record.collection_uid, + name: record.name, + slug: record.slug, + default_visibility_bits: record.default_visibility_bits, + created_at: record.created_at, + }), + )) } async fn get_collection_handler( @@ -1768,16 +1945,22 @@ async fn get_collection_handler( } } } - let entries_json: Vec = entries.iter().map(|e| { - let vis = vis_map.get(&e.entry_uid).copied().unwrap_or(record.default_visibility_bits); - serde_json::json!({ - "entry_uid": e.entry_uid, - "title": e.title, - "source_kind": e.source_kind, - "archived_at": e.archived_at, - "collection_visibility_bits": vis, + let entries_json: Vec = entries + .iter() + .map(|e| { + let vis = vis_map + .get(&e.entry_uid) + .copied() + .unwrap_or(record.default_visibility_bits); + serde_json::json!({ + "entry_uid": e.entry_uid, + "title": e.title, + "source_kind": e.source_kind, + "archived_at": e.archived_at, + "collection_visibility_bits": vis, + }) }) - }).collect(); + .collect(); Ok(Json(serde_json::json!({ "collection_uid": record.collection_uid, "name": record.name, @@ -1800,7 +1983,9 @@ async fn add_entry_to_collection_handler( let coll = database::get_collection_by_uid(&conn, &coll_uid)? .ok_or(ApiError::not_found("collection not found"))?; if coll.slug == "_default_" { - return Err(ApiError::bad_request("cannot manually add entries to the default collection")); + return Err(ApiError::bad_request( + "cannot manually add entries to the default collection", + )); } let entry_id: i64 = conn .query_row( @@ -1825,7 +2010,9 @@ async fn remove_entry_from_collection_handler( let coll = database::get_collection_by_uid(&conn, &coll_uid)? .ok_or(ApiError::not_found("collection not found"))?; if coll.slug == "_default_" { - return Err(ApiError::bad_request("cannot manually remove entries from the default collection")); + return Err(ApiError::bad_request( + "cannot manually remove entries from the default collection", + )); } let entry_id: i64 = conn .query_row( @@ -1861,7 +2048,8 @@ async fn update_entry_visibility_handler( ) .optional()? .ok_or(ApiError::not_found("entry not found"))?; - if database::update_collection_entry_visibility(&conn, coll.id, entry_id, body.visibility_bits)? { + if database::update_collection_entry_visibility(&conn, coll.id, entry_id, body.visibility_bits)? + { Ok(StatusCode::NO_CONTENT) } else { Err(ApiError::not_found("entry not in collection")) @@ -1892,7 +2080,8 @@ async fn patch_collection_handler( let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; let name_ref: Option<&str> = body.name.as_deref(); - let updated = database::update_collection(&conn, &coll_uid, name_ref, body.default_visibility_bits)?; + let updated = + database::update_collection(&conn, &coll_uid, name_ref, body.default_visibility_bits)?; if updated { Ok(StatusCode::NO_CONTENT) } else { @@ -1928,9 +2117,8 @@ async fn delete_collection_handler( // - 3 s timeout, 1-hop max. // - No auth required (t.co is public; no data exposed). -static TCO_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { - regex::Regex::new(r"^https://t\.co/[A-Za-z0-9]+$").unwrap() -}); +static TCO_RE: std::sync::LazyLock = + std::sync::LazyLock::new(|| regex::Regex::new(r"^https://t\.co/[A-Za-z0-9]+$").unwrap()); async fn resolve_tco_handler( Json(urls): Json>, @@ -1949,31 +2137,34 @@ async fn resolve_tco_handler( .map_err(|e| ApiError::internal(&format!("http client: {e}")))?; let mut map = std::collections::HashMap::new(); - let futs: Vec<_> = urls.iter().map(|url| { - let client = client.clone(); - let url = url.clone(); - tokio::spawn(async move { - // Try HEAD first; fall back to GET if HEAD returns no Location. - // Neither follows redirects (Policy::none), so the server only - // ever connects to t.co itself — never to the destination. - // Only accept http/https destinations — never javascript:, data:, etc. - let safe_location = |resp: reqwest::Response| { - resp.headers() - .get(reqwest::header::LOCATION) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) - .filter(|s| s.starts_with("http://") || s.starts_with("https://")) - }; - let expanded = match client.head(&url).send().await.ok().and_then(safe_location) { - Some(loc) => loc, - None => match client.get(&url).send().await.ok().and_then(safe_location) { + let futs: Vec<_> = urls + .iter() + .map(|url| { + let client = client.clone(); + let url = url.clone(); + tokio::spawn(async move { + // Try HEAD first; fall back to GET if HEAD returns no Location. + // Neither follows redirects (Policy::none), so the server only + // ever connects to t.co itself — never to the destination. + // Only accept http/https destinations — never javascript:, data:, etc. + let safe_location = |resp: reqwest::Response| { + resp.headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .filter(|s| s.starts_with("http://") || s.starts_with("https://")) + }; + let expanded = match client.head(&url).send().await.ok().and_then(safe_location) { Some(loc) => loc, - None => url.clone(), - }, - }; - (url, expanded) + None => match client.get(&url).send().await.ok().and_then(safe_location) { + Some(loc) => loc, + None => url.clone(), + }, + }; + (url, expanded) + }) }) - }).collect(); + .collect(); for fut in futs { if let Ok((k, v)) = fut.await { @@ -1999,7 +2190,11 @@ mod tests { let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); archivr_core::database::create_owner(&conn, "testowner", "test_hash_not_real").unwrap(); } - let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; + let registry = ServerRegistry { + archives: vec![], + bind: None, + auth_db_path: None, + }; (app(registry, auth_path), dir) } @@ -2007,17 +2202,24 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let auth_path = dir.path().join("auth.sqlite"); // NO owner seeded - for testing setup-required behavior - let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; + let registry = ServerRegistry { + archives: vec![], + bind: None, + auth_db_path: None, + }; (app(registry, auth_path), dir) } - fn make_test_registry(dir: &tempfile::TempDir) -> (ServerRegistry, std::path::PathBuf, std::path::PathBuf) { + fn make_test_registry( + dir: &tempfile::TempDir, + ) -> (ServerRegistry, std::path::PathBuf, std::path::PathBuf) { let paths = archivr_core::archive::initialize_archive( dir.path(), &dir.path().join("store"), "test", false, - ).unwrap(); + ) + .unwrap(); let auth_path = dir.path().join("auth.sqlite"); { let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); @@ -2039,7 +2241,11 @@ mod tests { fn make_test_session(auth_path: &std::path::Path) -> String { let conn = archivr_core::database::open_auth_db(auth_path).unwrap(); let user_id: i64 = conn - .query_row("SELECT id FROM users WHERE username = 'testowner'", [], |r| r.get(0)) + .query_row( + "SELECT id FROM users WHERE username = 'testowner'", + [], + |r| r.get(0), + ) .unwrap(); let role_bits = archivr_core::database::compute_role_bits(&conn, user_id).unwrap(); let sess_uid = @@ -2052,7 +2258,10 @@ mod tests { let user_id = database::ensure_default_user(&conn).unwrap(); let run = database::create_archive_run(&conn, user_id, 1).unwrap(); let si = database::upsert_source_identity( - &conn, "web", "page", None, + &conn, + "web", + "page", + None, Some("https://example.com/test"), "https://example.com/test", ) @@ -2123,12 +2332,26 @@ mod tests { async fn missing_archive_returns_404() { let dir = tempfile::tempdir().unwrap(); let auth_path = dir.path().join("auth.sqlite"); - { let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); } + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } let session_cookie = make_test_session(&auth_path); - let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; + let registry = ServerRegistry { + archives: vec![], + bind: None, + auth_db_path: None, + }; let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/missing/entries").header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/missing/entries") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } @@ -2136,12 +2359,26 @@ mod tests { async fn artifact_missing_archive_returns_404() { let dir = tempfile::tempdir().unwrap(); let auth_path = dir.path().join("auth.sqlite"); - { let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); } + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } let session_cookie = make_test_session(&auth_path); - let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; + let registry = ServerRegistry { + archives: vec![], + bind: None, + auth_db_path: None, + }; let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/nope/entries/entry_abc/artifacts/0").header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/nope/entries/entry_abc/artifacts/0") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } @@ -2311,12 +2548,15 @@ mod tests { bind: None, auth_db_path: None, }; - let uri = format!( - "/api/archives/test/entries/{}/artifacts/0", - entry.entry_uid - ); + let uri = format!("/api/archives/test/entries/{}/artifacts/0", entry.entry_uid); let response = app(registry, auth_path) - .oneshot(Request::builder().uri(&uri).header("cookie", &session_cookie).body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri(&uri) + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); @@ -2326,12 +2566,26 @@ mod tests { async fn search_missing_archive_returns_404() { let dir = tempfile::tempdir().unwrap(); let auth_path = dir.path().join("auth.sqlite"); - { let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); } + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } let session_cookie = make_test_session(&auth_path); - let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; + let registry = ServerRegistry { + archives: vec![], + bind: None, + auth_db_path: None, + }; let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/nope/entries/search?q=anything").header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/nope/entries/search?q=anything") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } @@ -2341,8 +2595,15 @@ mod tests { let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/entries/search").header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/search") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -2352,8 +2613,15 @@ mod tests { let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/entries/search?q=unknownprefix%3Aval").header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/search?q=unknownprefix%3Aval") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } @@ -2363,12 +2631,26 @@ mod tests { async fn test_list_tags_unknown_archive() { let dir = tempfile::tempdir().unwrap(); let auth_path = dir.path().join("auth.sqlite"); - { let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); } + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } let session_cookie = make_test_session(&auth_path); - let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; + let registry = ServerRegistry { + archives: vec![], + bind: None, + auth_db_path: None, + }; let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/ghost/tags").header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/ghost/tags") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } @@ -2444,7 +2726,10 @@ mod tests { .iter() .map(|n| n["tag"]["slug"].as_str().unwrap()) .collect(); - assert!(slugs.contains(&"science"), "expected 'science' in tag tree, got {slugs:?}"); + assert!( + slugs.contains(&"science"), + "expected 'science' in tag tree, got {slugs:?}" + ); } #[tokio::test] @@ -2516,7 +2801,10 @@ mod tests { .unwrap(); assert_eq!(list2_response.status(), StatusCode::OK); let tags2 = body_json(list2_response).await; - assert!(tags2.as_array().unwrap().is_empty(), "tags should be empty after removal"); + assert!( + tags2.as_array().unwrap().is_empty(), + "tags should be empty after removal" + ); } #[tokio::test] @@ -2540,7 +2828,11 @@ mod tests { ) .await .unwrap(); - assert_eq!(assign_resp.status(), StatusCode::CREATED, "assign tag should return 201"); + assert_eq!( + assign_resp.status(), + StatusCode::CREATED, + "assign tag should return 201" + ); // Search with ?tag=/science — entry should appear (requires auth since entry is private) let response = app(registry.clone(), auth_path.clone()) @@ -2587,8 +2879,15 @@ mod tests { let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/entries/ghost_uid/tags").header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/ghost_uid/tags") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } @@ -2724,10 +3023,18 @@ mod tests { async fn setup_required_before_owner_created() { let (test_app, _dir) = make_setup_test_app(); let response = test_app - .oneshot(Request::builder().uri("/api/auth/setup").body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/auth/setup") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["setup_required"], true); } @@ -2741,14 +3048,23 @@ mod tests { let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); archivr_core::database::create_owner(&conn, "owner", "dummy").unwrap(); } - let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; + let registry = ServerRegistry { + archives: vec![], + bind: None, + auth_db_path: None, + }; let second_app = app(registry, auth_path); let r2 = second_app - .oneshot(Request::builder().method("POST").uri("/api/auth/setup") - .header("content-type", "application/json") - .body(Body::from(r#"{"username":"owner2","password":"hunter2!"}"#)) - .unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/setup") + .header("content-type", "application/json") + .body(Body::from(r#"{"username":"owner2","password":"hunter2!"}"#)) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(r2.status(), StatusCode::CONFLICT); } @@ -2761,13 +3077,22 @@ mod tests { let hash = crate::auth::hash_password("correct_password").unwrap(); archivr_core::database::create_owner(&conn, "owner", &hash).unwrap(); } - let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; + let registry = ServerRegistry { + archives: vec![], + bind: None, + auth_db_path: None, + }; let response = app(registry, auth_path) - .oneshot(Request::builder().method("POST").uri("/api/auth/login") - .header("content-type", "application/json") - .body(Body::from(r#"{"username":"owner","password":"wrong"}"#)) - .unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("content-type", "application/json") + .body(Body::from(r#"{"username":"owner","password":"wrong"}"#)) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -2775,11 +3100,16 @@ mod tests { async fn create_token_requires_auth() { let (test_app, _dir) = make_test_app(); let response = test_app - .oneshot(Request::builder().method("POST").uri("/api/auth/tokens") - .header("content-type", "application/json") - .body(Body::from(r#"{"name":"my token"}"#)) - .unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/tokens") + .header("content-type", "application/json") + .body(Body::from(r#"{"name":"my token"}"#)) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -2787,16 +3117,19 @@ mod tests { async fn capture_returns_401_for_unauthenticated() { let (test_app, _dir) = make_test_app(); let response = test_app - .oneshot(Request::builder().method("POST") - .uri("/api/archives/test/captures") - .header("content-type", "application/json") - .body(Body::from(r#"{"locator":"https://example.com"}"#)) - .unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures") + .header("content-type", "application/json") + .body(Body::from(r#"{"locator":"https://example.com"}"#)) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } - #[tokio::test] async fn capture_post_returns_accepted_with_job_uid() { let dir = tempfile::tempdir().unwrap(); @@ -2815,9 +3148,14 @@ mod tests { .await .unwrap(); assert_eq!(response.status(), StatusCode::ACCEPTED); - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert!(json["job_uid"].as_str().is_some(), "response must have job_uid"); + assert!( + json["job_uid"].as_str().is_some(), + "response must have job_uid" + ); assert_eq!(json["status"], "pending"); } @@ -2833,15 +3171,22 @@ mod tests { .uri("/api/archives/test/captures") .header("content-type", "application/json") .header("cookie", &session_cookie) - .body(Body::from(r#"{"locator":"local:/nonexistent","quality":"720p"}"#)) + .body(Body::from( + r#"{"locator":"local:/nonexistent","quality":"720p"}"#, + )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::ACCEPTED); - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert!(json["job_uid"].as_str().is_some(), "response must have job_uid"); + assert!( + json["job_uid"].as_str().is_some(), + "response must have job_uid" + ); assert_eq!(json["status"], "pending"); } @@ -2857,15 +3202,23 @@ mod tests { .uri("/api/archives/test/captures") .header("content-type", "application/json") .header("cookie", &session_cookie) - .body(Body::from(r#"{"locator":"local:/nonexistent","quality":"4K"}"#)) + .body(Body::from( + r#"{"locator":"local:/nonexistent","quality":"4K"}"#, + )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert!(json["error"].as_str().is_some_and(|e| e.contains("invalid quality"))); + assert!( + json["error"] + .as_str() + .is_some_and(|e| e.contains("invalid quality")) + ); } #[tokio::test] @@ -2901,7 +3254,9 @@ mod tests { .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["has_video"], false); assert_eq!(json["qualities"], serde_json::json!([])); @@ -2911,9 +3266,15 @@ mod tests { #[tokio::test] async fn admin_users_requires_admin_role() { let (test_app, _dir) = make_test_app(); - let response = test_app.oneshot( - Request::builder().uri("/api/admin/users").body(Body::empty()).unwrap() - ).await.unwrap(); + let response = test_app + .oneshot( + Request::builder() + .uri("/api/admin/users") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -2922,11 +3283,16 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); - let response = app(registry, auth_path).oneshot( - Request::builder().uri("/api/admin/users") - .header("cookie", &session_cookie) - .body(Body::empty()).unwrap() - ).await.unwrap(); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .uri("/api/admin/users") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -2935,15 +3301,25 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); - let response = app(registry, auth_path).oneshot( - Request::builder().uri("/api/auth/me") - .header("cookie", &session_cookie) - .body(Body::empty()).unwrap() - ).await.unwrap(); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .uri("/api/auth/me") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert!(json.get("display_name").is_some(), "auth/me must include display_name field"); + assert!( + json.get("display_name").is_some(), + "auth/me must include display_name field" + ); assert!(json.get("username").is_some()); } @@ -2952,25 +3328,35 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); - let response = app(registry, auth_path).oneshot( - Request::builder().method("PATCH").uri("/api/auth/me") - .header("content-type", "application/json") - .header("cookie", &session_cookie) - .body(Body::from(r#"{"display_name":"Test Owner"}"#)) - .unwrap() - ).await.unwrap(); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/auth/me") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(Body::from(r#"{"display_name":"Test Owner"}"#)) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::NO_CONTENT); } #[tokio::test] async fn patch_me_requires_auth() { let (test_app, _dir) = make_test_app(); - let response = test_app.oneshot( - Request::builder().method("PATCH").uri("/api/auth/me") - .header("content-type", "application/json") - .body(Body::from(r#"{"display_name":"anon"}"#)) - .unwrap() - ).await.unwrap(); + let response = test_app + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/auth/me") + .header("content-type", "application/json") + .body(Body::from(r#"{"display_name":"anon"}"#)) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -2982,29 +3368,45 @@ mod tests { { let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); let hash = crate::auth::hash_password("real_password").unwrap(); - let user_id: i64 = conn.query_row( - "SELECT id FROM users WHERE username = 'testowner'", [], |r| r.get(0) - ).unwrap(); + let user_id: i64 = conn + .query_row( + "SELECT id FROM users WHERE username = 'testowner'", + [], + |r| r.get(0), + ) + .unwrap(); archivr_core::database::update_user_password(&conn, user_id, &hash).unwrap(); } let session_cookie = make_test_session(&auth_path); - let response = app(registry, auth_path).oneshot( - Request::builder().method("PATCH").uri("/api/auth/me") - .header("content-type", "application/json") - .header("cookie", &session_cookie) - .body(Body::from(r#"{"current_password":"wrong","new_password":"newpass"}"#)) - .unwrap() - ).await.unwrap(); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/auth/me") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(Body::from( + r#"{"current_password":"wrong","new_password":"newpass"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn instance_settings_requires_admin() { let (test_app, _dir) = make_test_app(); - let response = test_app.oneshot( - Request::builder().uri("/api/admin/instance-settings") - .body(Body::empty()).unwrap() - ).await.unwrap(); + let response = test_app + .oneshot( + Request::builder() + .uri("/api/admin/instance-settings") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3013,13 +3415,20 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); - let response = app(registry, auth_path).oneshot( - Request::builder().uri("/api/admin/instance-settings") - .header("cookie", &session_cookie) - .body(Body::empty()).unwrap() - ).await.unwrap(); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .uri("/api/admin/instance-settings") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["public_index_enabled"], false); assert_eq!(json["open_registration_enabled"], false); @@ -3030,13 +3439,18 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); - let response = app(registry, auth_path).oneshot( - Request::builder().method("PATCH").uri("/api/admin/instance-settings") - .header("content-type", "application/json") - .header("cookie", &session_cookie) - .body(Body::from(r#"{"open_registration_enabled":true}"#)) - .unwrap() - ).await.unwrap(); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/admin/instance-settings") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(Body::from(r#"{"open_registration_enabled":true}"#)) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::NO_CONTENT); } #[tokio::test] @@ -3044,7 +3458,12 @@ mod tests { // Non-admin (no session) should get 401. let (test_app, _dir) = make_test_app(); let response = test_app - .oneshot(Request::builder().uri("/api/admin/cookie-rules").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/api/admin/cookie-rules") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); @@ -3067,15 +3486,20 @@ mod tests { .uri("/api/admin/cookie-rules") .header("content-type", "application/json") .header("cookie", &session) - .body(Body::from(r#"{"pattern_kind":"global","cookies_json":"{\"session\":\"abc\"}"}"#)) + .body(Body::from( + r#"{"pattern_kind":"global","cookies_json":"{\"session\":\"abc\"}"}"#, + )) .unwrap(), ) .await .unwrap(); assert_eq!(create_resp.status(), StatusCode::CREATED); let body: serde_json::Value = serde_json::from_slice( - &axum::body::to_bytes(create_resp.into_body(), usize::MAX).await.unwrap(), - ).unwrap(); + &axum::body::to_bytes(create_resp.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); let rule_uid = body["rule_uid"].as_str().unwrap().to_string(); assert_eq!(body["pattern_kind"], "global"); @@ -3093,8 +3517,11 @@ mod tests { .unwrap(); assert_eq!(list_resp.status(), StatusCode::OK); let list: serde_json::Value = serde_json::from_slice( - &axum::body::to_bytes(list_resp.into_body(), usize::MAX).await.unwrap(), - ).unwrap(); + &axum::body::to_bytes(list_resp.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); assert_eq!(list.as_array().unwrap().len(), 1); // Delete. @@ -3127,7 +3554,9 @@ mod tests { .uri("/api/admin/cookie-rules") .header("content-type", "application/json") .header("cookie", &session) - .body(Body::from(r#"{"pattern_kind":"global","cookies_json":"{\"session\":123}"}"#)) + .body(Body::from( + r#"{"pattern_kind":"global","cookies_json":"{\"session\":123}"}"#, + )) .unwrap(), ) .await @@ -3172,10 +3601,7 @@ mod tests { response.headers().get("x-content-type-options").unwrap(), "nosniff" ); - assert_eq!( - response.headers().get("x-frame-options").unwrap(), - "DENY" - ); + assert_eq!(response.headers().get("x-frame-options").unwrap(), "DENY"); assert_eq!( response.headers().get("referrer-policy").unwrap(), "strict-origin-when-cross-origin" @@ -3241,7 +3667,11 @@ mod tests { ) .await .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "attempt within limit should reach handler"); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "attempt within limit should reach handler" + ); } let resp = app_with_state(state) .oneshot( @@ -3255,8 +3685,15 @@ mod tests { ) .await .unwrap(); - assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS, "attempt over limit must be 429"); - assert!(resp.headers().contains_key("retry-after"), "429 must carry Retry-After header"); + assert_eq!( + resp.status(), + StatusCode::TOO_MANY_REQUESTS, + "attempt over limit must be 429" + ); + assert!( + resp.headers().contains_key("retry-after"), + "429 must carry Retry-After header" + ); let body = body_json(resp).await; assert_eq!(body["error"], "rate_limited"); assert!(body["retry_after_secs"].as_i64().unwrap() > 0); @@ -3271,7 +3708,11 @@ mod tests { archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); } let state = AppState { - registry: Arc::new(ServerRegistry { archives: vec![], bind: None, auth_db_path: None }), + registry: Arc::new(ServerRegistry { + archives: vec![], + bind: None, + auth_db_path: None, + }), auth_db_path: Arc::new(auth_path), login_attempts: Arc::new(Mutex::new(HashMap::new())), }; @@ -3291,7 +3732,12 @@ mod tests { .unwrap(); } let resp = app_with_state(state) - .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK, "/health must be unaffected"); @@ -3304,8 +3750,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/entries/fake_uid").body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/fake_uid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3317,8 +3769,15 @@ mod tests { let session_cookie = make_test_session(&auth_path); let uri = format!("/api/archives/test/entries/{}", entry.entry_uid); let response = app(registry, auth_path) - .oneshot(Request::builder().uri(&uri).header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri(&uri) + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -3327,8 +3786,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/runs").body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/runs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3338,8 +3803,15 @@ mod tests { let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/runs").header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/runs") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -3348,8 +3820,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/entries/fake_uid/artifacts/0").body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/fake_uid/artifacts/0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3357,41 +3835,96 @@ mod tests { async fn serve_artifact_with_auth_returns_ok() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("store"); - let paths = archivr_core::archive::initialize_archive(dir.path(), &store_path, "test", false).unwrap(); + let paths = + archivr_core::archive::initialize_archive(dir.path(), &store_path, "test", false) + .unwrap(); let artifact_relpath = "raw/a/u/page.html"; let artifact_dir = store_path.join("raw").join("a").join("u"); std::fs::create_dir_all(&artifact_dir).unwrap(); std::fs::write(artifact_dir.join("page.html"), b"auth test").unwrap(); let conn = database::open_or_initialize(&paths.archive_path).unwrap(); let user_id = database::ensure_default_user(&conn).unwrap(); - let sid = database::upsert_source_identity(&conn, "web", "page", Some("auth-page"), Some("https://example.com/auth"), "https://example.com/auth").unwrap(); + let sid = database::upsert_source_identity( + &conn, + "web", + "page", + Some("auth-page"), + Some("https://example.com/auth"), + "https://example.com/auth", + ) + .unwrap(); let run = database::create_archive_run(&conn, user_id, 1).unwrap(); - let entry = database::create_archived_entry(&conn, &database::NewEntry { - source_identity_id: sid, archive_run_id: run.id, parent_entry_id: None, root_entry_id: None, - created_by_user_id: user_id, owned_by_user_id: user_id, - source_kind: "web".to_string(), entity_kind: "page".to_string(), - title: Some("Auth Test Page".to_string()), visibility: "private".to_string(), - representation_kind: "html".to_string(), source_metadata_json: "{}".to_string(), display_metadata_json: None, - }).unwrap(); - let blob_id = database::upsert_blob(&conn, &database::BlobRecord { - sha256: "aaaa1111bbbb2222cccc3333dddd4444aaaa1111bbbb2222cccc3333dddd4444".to_string(), - byte_size: 21, mime_type: Some("text/html".to_string()), extension: Some("html".to_string()), - raw_relpath: artifact_relpath.to_string(), - }).unwrap(); - database::add_entry_artifact(&conn, &database::NewArtifact { - entry_id: entry.id, artifact_role: "primary_media".to_string(), - storage_area: "raw".to_string(), relpath: artifact_relpath.to_string(), - blob_id: Some(blob_id), logical_path: None, metadata_json: None, - }).unwrap(); + let entry = database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: sid, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "web".to_string(), + entity_kind: "page".to_string(), + title: Some("Auth Test Page".to_string()), + visibility: "private".to_string(), + representation_kind: "html".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ) + .unwrap(); + let blob_id = database::upsert_blob( + &conn, + &database::BlobRecord { + sha256: "aaaa1111bbbb2222cccc3333dddd4444aaaa1111bbbb2222cccc3333dddd4444" + .to_string(), + byte_size: 21, + mime_type: Some("text/html".to_string()), + extension: Some("html".to_string()), + raw_relpath: artifact_relpath.to_string(), + }, + ) + .unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id: entry.id, + artifact_role: "primary_media".to_string(), + storage_area: "raw".to_string(), + relpath: artifact_relpath.to_string(), + blob_id: Some(blob_id), + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); drop(conn); let auth_path = dir.path().join("auth.sqlite"); - { let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); } + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } let session_cookie = make_test_session(&auth_path); - let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), label: "Test".to_string(), archive_path: paths.archive_path.clone() }], bind: None, auth_db_path: None }; + let registry = ServerRegistry { + archives: vec![MountedArchive { + id: "test".to_string(), + label: "Test".to_string(), + archive_path: paths.archive_path.clone(), + }], + bind: None, + auth_db_path: None, + }; let uri = format!("/api/archives/test/entries/{}/artifacts/0", entry.entry_uid); let response = app(registry, auth_path) - .oneshot(Request::builder().uri(&uri).header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri(&uri) + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -3400,8 +3933,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/entries/fake_uid/favicon").body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/fake_uid/favicon") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3409,41 +3948,100 @@ mod tests { async fn serve_entry_favicon_with_auth_returns_ok() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("store"); - let paths = archivr_core::archive::initialize_archive(dir.path(), &store_path, "test", false).unwrap(); + let paths = + archivr_core::archive::initialize_archive(dir.path(), &store_path, "test", false) + .unwrap(); let favicon_relpath = "raw/f/a/favicon.png"; let favicon_dir = store_path.join("raw").join("f").join("a"); std::fs::create_dir_all(&favicon_dir).unwrap(); - std::fs::write(favicon_dir.join("favicon.png"), &[0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).unwrap(); + std::fs::write( + favicon_dir.join("favicon.png"), + &[0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], + ) + .unwrap(); let conn = database::open_or_initialize(&paths.archive_path).unwrap(); let user_id = database::ensure_default_user(&conn).unwrap(); - let sid = database::upsert_source_identity(&conn, "web", "page", Some("fav-page"), Some("https://example.com/fav"), "https://example.com/fav").unwrap(); + let sid = database::upsert_source_identity( + &conn, + "web", + "page", + Some("fav-page"), + Some("https://example.com/fav"), + "https://example.com/fav", + ) + .unwrap(); let run = database::create_archive_run(&conn, user_id, 1).unwrap(); - let entry = database::create_archived_entry(&conn, &database::NewEntry { - source_identity_id: sid, archive_run_id: run.id, parent_entry_id: None, root_entry_id: None, - created_by_user_id: user_id, owned_by_user_id: user_id, - source_kind: "web".to_string(), entity_kind: "page".to_string(), - title: Some("Favicon Test".to_string()), visibility: "private".to_string(), - representation_kind: "html".to_string(), source_metadata_json: "{}".to_string(), display_metadata_json: None, - }).unwrap(); - let blob_id = database::upsert_blob(&conn, &database::BlobRecord { - sha256: "ffffffffffff1111ffffffffffff1111ffffffffffff1111ffffffffffff1111".to_string(), - byte_size: 8, mime_type: Some("image/png".to_string()), extension: Some("png".to_string()), - raw_relpath: favicon_relpath.to_string(), - }).unwrap(); - database::add_entry_artifact(&conn, &database::NewArtifact { - entry_id: entry.id, artifact_role: "favicon".to_string(), - storage_area: "raw".to_string(), relpath: favicon_relpath.to_string(), - blob_id: Some(blob_id), logical_path: None, metadata_json: None, - }).unwrap(); + let entry = database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: sid, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "web".to_string(), + entity_kind: "page".to_string(), + title: Some("Favicon Test".to_string()), + visibility: "private".to_string(), + representation_kind: "html".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ) + .unwrap(); + let blob_id = database::upsert_blob( + &conn, + &database::BlobRecord { + sha256: "ffffffffffff1111ffffffffffff1111ffffffffffff1111ffffffffffff1111" + .to_string(), + byte_size: 8, + mime_type: Some("image/png".to_string()), + extension: Some("png".to_string()), + raw_relpath: favicon_relpath.to_string(), + }, + ) + .unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id: entry.id, + artifact_role: "favicon".to_string(), + storage_area: "raw".to_string(), + relpath: favicon_relpath.to_string(), + blob_id: Some(blob_id), + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); drop(conn); let auth_path = dir.path().join("auth.sqlite"); - { let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); } + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } let session_cookie = make_test_session(&auth_path); - let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), label: "Test".to_string(), archive_path: paths.archive_path.clone() }], bind: None, auth_db_path: None }; + let registry = ServerRegistry { + archives: vec![MountedArchive { + id: "test".to_string(), + label: "Test".to_string(), + archive_path: paths.archive_path.clone(), + }], + bind: None, + auth_db_path: None, + }; let uri = format!("/api/archives/test/entries/{}/favicon", entry.entry_uid); let response = app(registry, auth_path) - .oneshot(Request::builder().uri(&uri).header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri(&uri) + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -3453,8 +4051,14 @@ mod tests { let (registry, _, auth_path) = make_test_registry(&dir); let sha256 = "0000000000000000000000000000000000000000000000000000000000000000"; let response = app(registry, auth_path) - .oneshot(Request::builder().uri(&format!("/api/archives/test/blobs/{sha256}")).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri(&format!("/api/archives/test/blobs/{sha256}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3462,26 +4066,52 @@ mod tests { async fn serve_blob_with_auth_returns_ok() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("store"); - let paths = archivr_core::archive::initialize_archive(dir.path(), &store_path, "test", false).unwrap(); + let paths = + archivr_core::archive::initialize_archive(dir.path(), &store_path, "test", false) + .unwrap(); let blob_relpath = "raw/b/l/data.bin"; let blob_dir = store_path.join("raw").join("b").join("l"); std::fs::create_dir_all(&blob_dir).unwrap(); std::fs::write(blob_dir.join("data.bin"), b"blob content here").unwrap(); let sha256 = "bbbb2222cccc4444bbbb2222cccc4444bbbb2222cccc4444bbbb2222cccc4444"; let conn = database::open_or_initialize(&paths.archive_path).unwrap(); - database::upsert_blob(&conn, &database::BlobRecord { - sha256: sha256.to_string(), byte_size: 17, - mime_type: Some("application/octet-stream".to_string()), extension: Some("bin".to_string()), - raw_relpath: blob_relpath.to_string(), - }).unwrap(); + database::upsert_blob( + &conn, + &database::BlobRecord { + sha256: sha256.to_string(), + byte_size: 17, + mime_type: Some("application/octet-stream".to_string()), + extension: Some("bin".to_string()), + raw_relpath: blob_relpath.to_string(), + }, + ) + .unwrap(); drop(conn); let auth_path = dir.path().join("auth.sqlite"); - { let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); } + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } let session_cookie = make_test_session(&auth_path); - let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), label: "Test".to_string(), archive_path: paths.archive_path.clone() }], bind: None, auth_db_path: None }; + let registry = ServerRegistry { + archives: vec![MountedArchive { + id: "test".to_string(), + label: "Test".to_string(), + archive_path: paths.archive_path.clone(), + }], + bind: None, + auth_db_path: None, + }; let response = app(registry, auth_path) - .oneshot(Request::builder().uri(&format!("/api/archives/test/blobs/{sha256}")).header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri(&format!("/api/archives/test/blobs/{sha256}")) + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -3490,8 +4120,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/tags").body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/tags") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3501,8 +4137,15 @@ mod tests { let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/tags").header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/tags") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -3511,8 +4154,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/entries/fake_uid/tags").body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/fake_uid/tags") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3524,8 +4173,15 @@ mod tests { let session_cookie = make_test_session(&auth_path); let uri = format!("/api/archives/test/entries/{}/tags", entry.entry_uid); let response = app(registry, auth_path) - .oneshot(Request::builder().uri(&uri).header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri(&uri) + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -3534,8 +4190,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/collections").body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/collections") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3545,8 +4207,15 @@ mod tests { let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/collections").header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/collections") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -3555,8 +4224,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/entries/fake_uid/collections").body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/fake_uid/collections") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3568,8 +4243,15 @@ mod tests { let session_cookie = make_test_session(&auth_path); let uri = format!("/api/archives/test/entries/{}/collections", entry.entry_uid); let response = app(registry, auth_path) - .oneshot(Request::builder().uri(&uri).header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri(&uri) + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -3578,8 +4260,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/collections/coll_notexist").body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/collections/coll_notexist") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3598,8 +4286,15 @@ mod tests { let coll_uid = coll["collection_uid"].as_str().unwrap().to_string(); let uri = format!("/api/archives/test/collections/{coll_uid}"); let response = app(registry, auth_path) - .oneshot(Request::builder().uri(&uri).header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri(&uri) + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -3610,8 +4305,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/entries").body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/entries") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3621,8 +4322,15 @@ mod tests { let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/entries").header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/entries") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -3631,8 +4339,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (registry, _, auth_path) = make_test_registry(&dir); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/entries/search").body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/search") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } @@ -3642,8 +4356,15 @@ mod tests { let (registry, _, auth_path) = make_test_registry(&dir); let session_cookie = make_test_session(&auth_path); let response = app(registry, auth_path) - .oneshot(Request::builder().uri("/api/archives/test/entries/search").header("cookie", &session_cookie).body(Body::empty()).unwrap()) - .await.unwrap(); + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/search") + .header("cookie", &session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -3755,7 +4476,11 @@ mod tests { archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); } let state = AppState { - registry: Arc::new(ServerRegistry { archives: vec![], bind: None, auth_db_path: None }), + registry: Arc::new(ServerRegistry { + archives: vec![], + bind: None, + auth_db_path: None, + }), auth_db_path: Arc::new(auth_path.clone()), login_attempts: Arc::new(Mutex::new(HashMap::new())), }; @@ -3947,9 +4672,9 @@ mod tests { async fn blob_cleanup_delete_removes_orphan_and_preserves_referenced() { let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("store"); - let paths = archivr_core::archive::initialize_archive( - dir.path(), &store_path, "test", false, - ).unwrap(); + let paths = + archivr_core::archive::initialize_archive(dir.path(), &store_path, "test", false) + .unwrap(); let auth_path = dir.path().join("auth.sqlite"); { let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); @@ -3965,25 +4690,44 @@ mod tests { { let conn = database::open_or_initialize(&paths.archive_path).unwrap(); // Referenced blob - let live_id = database::upsert_blob(&conn, &database::BlobRecord { - sha256: "aaaa1111bbbb2222cccc3333dddd4444aaaa1111bbbb2222cccc3333dddd4444".to_string(), - byte_size: 10, mime_type: None, extension: Some("bin".to_string()), - raw_relpath: live_relpath.to_string(), - }).unwrap(); - database::add_entry_artifact(&conn, &database::NewArtifact { - entry_id: entry.id, - artifact_role: "main".to_string(), - storage_area: "raw".to_string(), - relpath: live_relpath.to_string(), - blob_id: Some(live_id), - logical_path: None, metadata_json: None, - }).unwrap(); + let live_id = database::upsert_blob( + &conn, + &database::BlobRecord { + sha256: "aaaa1111bbbb2222cccc3333dddd4444aaaa1111bbbb2222cccc3333dddd4444" + .to_string(), + byte_size: 10, + mime_type: None, + extension: Some("bin".to_string()), + raw_relpath: live_relpath.to_string(), + }, + ) + .unwrap(); + database::add_entry_artifact( + &conn, + &database::NewArtifact { + entry_id: entry.id, + artifact_role: "main".to_string(), + storage_area: "raw".to_string(), + relpath: live_relpath.to_string(), + blob_id: Some(live_id), + logical_path: None, + metadata_json: None, + }, + ) + .unwrap(); // Orphaned blob (no artifact references it) - database::upsert_blob(&conn, &database::BlobRecord { - sha256: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string(), - byte_size: 20, mime_type: None, extension: Some("bin".to_string()), - raw_relpath: orphan_relpath.to_string(), - }).unwrap(); + database::upsert_blob( + &conn, + &database::BlobRecord { + sha256: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + .to_string(), + byte_size: 20, + mime_type: None, + extension: Some("bin".to_string()), + raw_relpath: orphan_relpath.to_string(), + }, + ) + .unwrap(); } // Write all three files to disk @@ -4018,12 +4762,23 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let body = body_json(response).await; assert_eq!(body["deleted_blob_rows"], 1, "one orphaned DB row removed"); - assert_eq!(body["deleted_files"], 2, "orphan blob file and extra disk file removed"); + assert_eq!( + body["deleted_files"], 2, + "orphan blob file and extra disk file removed" + ); assert!(body["errors"].as_array().unwrap().is_empty()); - assert!(store_path.join(live_relpath).exists(), "referenced file must be preserved"); - assert!(!store_path.join(orphan_relpath).exists(), "orphaned blob file must be deleted"); - assert!(!store_path.join(extra_relpath).exists(), "extra disk-only file must be deleted"); + assert!( + store_path.join(live_relpath).exists(), + "referenced file must be preserved" + ); + assert!( + !store_path.join(orphan_relpath).exists(), + "orphaned blob file must be deleted" + ); + assert!( + !store_path.join(extra_relpath).exists(), + "extra disk-only file must be deleted" + ); } - }