1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat(collections): collection CRUD + entry-visibility routes

This commit is contained in:
TheGeneralist 2026-06-26 17:00:19 +02:00
parent 02cab149fa
commit 24c5321f6e
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
3 changed files with 257 additions and 6 deletions

View file

@ -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<Vec<EntrySummary>> {
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::<rusqlite::Result<Vec<_>>>()?;
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<String>,
@ -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());
}

View file

@ -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

View file

@ -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<AppState>) -> Json<Vec<MountedArchive
async fn list_entries(
State(state): State<AppState>,
auth: AuthUser,
Path(archive_id): Path<String>,
) -> Result<Json<Vec<archive::EntrySummary>>, 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<AppState>,
auth: AuthUser,
Path(archive_id): Path<String>,
Query(params): Query<EntrySearchParams>,
) -> Result<Json<Vec<archive::EntrySummary>>, 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<AppState>,
Path(archive_id): Path<String>,
@ -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<AppState>,
Path(archive_id): Path<String>,
) -> Result<Json<Vec<archive::CollectionSummary>>, 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<AppState>,
auth: AuthUser,
Path(archive_id): Path<String>,
Json(body): Json<CreateCollectionBody>,
) -> Result<(StatusCode, Json<archive::CollectionSummary>), 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<AppState>,
auth: AuthUser,
Path((archive_id, coll_uid)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
auth: AuthUser,
Path((archive_id, coll_uid)): Path<(String, String)>,
Json(body): Json<AddEntryBody>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
auth: AuthUser,
Path((archive_id, coll_uid, entry_uid)): Path<(String, String, String)>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
auth: AuthUser,
Path((archive_id, coll_uid, entry_uid)): Path<(String, String, String)>,
Json(body): Json<UpdateVisibilityBody>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
Path((archive_id, entry_uid)): Path<(String, String)>,
) -> Result<Json<Vec<archive::EntryCollectionMembership>>, 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::*;