diff --git a/crates/archivr-core/src/archive.rs b/crates/archivr-core/src/archive.rs index 89235bc..0961830 100644 --- a/crates/archivr-core/src/archive.rs +++ b/crates/archivr-core/src/archive.rs @@ -392,6 +392,45 @@ pub fn get_entry_collections( )) } +/// Returns entries belonging to a collection, filtered by caller_bits visibility. +/// Caller with admin/owner bits (4|8) sees all entries regardless of visibility_bits. +pub fn list_entries_for_collection( + conn: &rusqlite::Connection, + collection_id: i64, + caller_bits: u32, +) -> Result> { + let sql = format!( + "{} {} \ + JOIN collection_entries ce ON ce.entry_id = e.id \ + WHERE ce.collection_id = ?1 \ + AND (CAST(?2 AS INTEGER) & 12 != 0 \ + 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, + ); + let mut stmt = conn.prepare(&sql)?; + let entries = stmt + .query_map([collection_id, caller_bits as i64], |row| { + Ok(EntrySummary { + entry_uid: row.get(0)?, + archived_at: row.get(1)?, + source_kind: row.get(2)?, + entity_kind: row.get(3)?, + title: row.get(4)?, + visibility: row.get(5)?, + original_url: row.get(6)?, + artifact_count: row.get(7)?, + total_artifact_bytes: row.get(8)?, + parent_entry_uid: row.get(9)?, + has_favicon: row.get::<_, i64>(10)? != 0, + }) + })? + .collect::>>()?; + Ok(entries) +} + /// Resolves an artifact to its absolute on-disk path under `store_path`. /// /// `artifact.relpath` is a store-relative path (e.g. `raw/a/b/abc.pdf`). @@ -417,7 +456,7 @@ pub fn resolve_artifact_path( Ok(canonical_artifact) } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct SearchEntriesQuery { /// Free-text term: LIKE-matched against title, canonical_url, entry_uid, source_kind, entity_kind, visibility pub q: Option, @@ -440,6 +479,22 @@ pub struct SearchEntriesQuery { pub caller_bits: u32, } +impl Default for SearchEntriesQuery { + fn default() -> Self { + Self { + q: None, + source_kind: None, + entity_kind: None, + url: None, + title: None, + after: None, + before: None, + tag: None, + caller_bits: u32::MAX, + } + } +} + /// Parses a raw search string into a [`SearchEntriesQuery`]. /// /// Recognized prefixes: `source:`, `type:`, `url:`, `title:`, `after:`, `before:`, `tag:`. @@ -928,7 +983,7 @@ mod tests { database::complete_archive_run_item(&conn, item.id, entry.id).unwrap(); database::finish_archive_run(&conn, run.id).unwrap(); - let entries = list_root_entries(&conn).unwrap(); + let entries = list_root_entries(&conn, u32::MAX).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].title.as_deref(), Some("Saved Article")); assert_eq!(entries[0].artifact_count, 1); @@ -1099,7 +1154,7 @@ mod tests { #[test] fn search_empty_query_returns_all_root_entries() { let conn = make_test_db_with_entries(); - let all = list_root_entries(&conn).unwrap(); + let all = list_root_entries(&conn, u32::MAX).unwrap(); let searched = search_entries(&conn, &SearchEntriesQuery::default()).unwrap(); assert_eq!(all.len(), searched.len()); } diff --git a/crates/archivr-server/Cargo.toml b/crates/archivr-server/Cargo.toml index 371950a..b647f08 100644 --- a/crates/archivr-server/Cargo.toml +++ b/crates/archivr-server/Cargo.toml @@ -18,6 +18,7 @@ axum-extra.workspace = true chrono.workspace = true base64.workspace = true serde_json.workspace = true +rusqlite.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index ba4f571..b158656 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -26,15 +26,16 @@ use axum::{ http::StatusCode, middleware::Next, response::{IntoResponse, Response}, - routing::{delete, get, post}, + routing::{delete, get, patch, post}, }; use tower_http::services::{ServeDir, ServeFile}; use tower::ServiceExt; use crate::registry::{MountedArchive, ServerRegistry}; use crate::auth; -pub use crate::auth::{AuthUser, ROLE_ADMIN, ROLE_OWNER, ROLE_USER}; +pub use crate::auth::{AuthUser, ROLE_ADMIN, ROLE_GUEST, ROLE_OWNER, ROLE_USER}; use axum_extra::extract::CookieJar; +use rusqlite::OptionalExtension; #[derive(Clone)] pub struct AppState { @@ -127,6 +128,17 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router .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/archives/:archive_id/collections", + get(list_collections_handler).post(create_collection_handler)) + .route("/api/archives/:archive_id/collections/:coll_uid", + get(get_collection_handler)) + .route("/api/archives/:archive_id/collections/:coll_uid/entries", + post(add_entry_to_collection_handler)) + .route("/api/archives/:archive_id/collections/:coll_uid/entries/:entry_uid", + delete(remove_entry_from_collection_handler) + .patch(update_entry_visibility_handler)) + .route("/api/archives/:archive_id/entries/:entry_uid/collections", + get(list_entry_collections_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)) @@ -145,15 +157,18 @@ async fn list_archives(State(state): State) -> Json, + auth: AuthUser, Path(archive_id): Path, ) -> Result>, ApiError> { let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; - Ok(Json(archive::list_root_entries(&conn)?)) + let caller_bits = auth_to_caller_bits(&auth); + Ok(Json(archive::list_root_entries(&conn, caller_bits)?)) } async fn search_entries_handler( State(state): State, + auth: AuthUser, Path(archive_id): Path, Query(params): Query, ) -> Result>, ApiError> { @@ -165,6 +180,7 @@ async fn search_entries_handler( if let Some(tag) = params.tag { search_query.tag = Some(tag); } + search_query.caller_bits = auth_to_caller_bits(&auth); Ok(Json(archive::search_entries(&conn, &search_query)?)) } @@ -278,6 +294,28 @@ struct AssignTagBody { tag_path: String, } +#[derive(Debug, serde::Deserialize)] +struct CreateCollectionBody { + name: String, + slug: String, + #[serde(default = "default_user_visibility")] + default_visibility_bits: u32, +} + +fn default_user_visibility() -> u32 { 2 } + +#[derive(Debug, serde::Deserialize)] +struct AddEntryBody { + entry_uid: String, + #[serde(default = "default_user_visibility")] + visibility_bits: u32, +} + +#[derive(Debug, serde::Deserialize)] +struct UpdateVisibilityBody { + visibility_bits: u32, +} + async fn list_tags( State(state): State, Path(archive_id): Path, @@ -698,6 +736,13 @@ async fn admin_create_role( Ok((StatusCode::CREATED, Json(role))) } +fn auth_to_caller_bits(auth: &AuthUser) -> u32 { + match auth { + AuthUser::Authenticated { role_bits, .. } => *role_bits, + AuthUser::Guest => ROLE_GUEST, + } +} + fn mounted_archive<'a>( state: &'a AppState, archive_id: &str, @@ -767,6 +812,156 @@ impl IntoResponse for ApiError { } } +// ── Collection handlers ──────────────────────────────────────────────────────── + +async fn list_collections_handler( + State(state): State, + Path(archive_id): Path, +) -> Result>, ApiError> { + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + Ok(Json(archive::list_collections(&conn)?)) +} + +async fn create_collection_handler( + State(state): State, + auth: AuthUser, + Path(archive_id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + auth.require_role(ROLE_USER)?; + if body.name.trim().is_empty() { + 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")); + } + 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, + }))) +} + +async fn get_collection_handler( + State(state): State, + auth: AuthUser, + Path((archive_id, coll_uid)): Path<(String, String)>, +) -> Result, ApiError> { + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + let record = database::get_collection_by_uid(&conn, &coll_uid)? + .ok_or(ApiError::not_found("collection not found"))?; + let caller_bits = auth_to_caller_bits(&auth); + let entries = archive::list_entries_for_collection(&conn, record.id, caller_bits)?; + Ok(Json(serde_json::json!({ + "collection_uid": record.collection_uid, + "name": record.name, + "slug": record.slug, + "default_visibility_bits": record.default_visibility_bits, + "created_at": record.created_at, + "entries": entries, + }))) +} + +async fn add_entry_to_collection_handler( + State(state): State, + auth: AuthUser, + Path((archive_id, coll_uid)): Path<(String, String)>, + Json(body): Json, +) -> Result { + auth.require_role(ROLE_USER)?; + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + 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")); + } + let entry_id: i64 = conn + .query_row( + "SELECT id FROM archived_entries WHERE entry_uid = ?1", + [body.entry_uid.as_str()], + |row| row.get(0), + ) + .optional()? + .ok_or(ApiError::not_found("entry not found"))?; + database::add_entry_to_collection(&conn, coll.id, entry_id, body.visibility_bits)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn remove_entry_from_collection_handler( + State(state): State, + auth: AuthUser, + Path((archive_id, coll_uid, entry_uid)): Path<(String, String, String)>, +) -> Result { + auth.require_role(ROLE_USER)?; + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + 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")); + } + let entry_id: i64 = conn + .query_row( + "SELECT id FROM archived_entries WHERE entry_uid = ?1", + [entry_uid.as_str()], + |row| row.get(0), + ) + .optional()? + .ok_or(ApiError::not_found("entry not found"))?; + if database::remove_entry_from_collection(&conn, coll.id, entry_id)? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::not_found("entry not in collection")) + } +} + +async fn update_entry_visibility_handler( + State(state): State, + auth: AuthUser, + Path((archive_id, coll_uid, entry_uid)): Path<(String, String, String)>, + Json(body): Json, +) -> Result { + auth.require_role(ROLE_USER)?; + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + let coll = database::get_collection_by_uid(&conn, &coll_uid)? + .ok_or(ApiError::not_found("collection not found"))?; + let entry_id: i64 = conn + .query_row( + "SELECT id FROM archived_entries WHERE entry_uid = ?1", + [entry_uid.as_str()], + |row| row.get(0), + ) + .optional()? + .ok_or(ApiError::not_found("entry not found"))?; + 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")) + } +} + +async fn list_entry_collections_handler( + State(state): State, + Path((archive_id, entry_uid)): Path<(String, String)>, +) -> Result>, ApiError> { + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + match archive::get_entry_collections(&conn, &entry_uid)? { + Some(memberships) => Ok(Json(memberships)), + None => Err(ApiError::not_found("entry not found")), + } +} + #[cfg(test)] mod tests { use super::*;