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

feat(collections): Track 6 permissions & visibility — collection model, role-bitmask visibility, collection UI

This commit is contained in:
TheGeneralist 2026-06-26 17:06:18 +02:00
commit 915878ff10
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
12 changed files with 862 additions and 22 deletions

25
NEXT.md
View file

@ -118,11 +118,20 @@ custom roles get bit_position≥4. Ban invalidates all sessions. Only-owner guar
---
### 6. Permissions & visibility — collection model
### ~~6. Permissions & visibility — collection model~~ ✅ Done
**What:** Replace `archived_entries.visibility` with a `collections` + `collection_entries`
model where visibility is a role-bitmask per entry-in-collection. Enforce visibility on all
API queries. Collection UI (create, set visibility, add entries).
**Implemented:** `database.rs`: `collections` + `collection_entries` tables; seed default collection ('All Entries');
migration inserts existing entries into default collection with `visibility_bits` derived from legacy `visibility` string
(`'public'`→3, `'unlisted'`→2, `'private'`→0). 8 new pub functions (`ensure_default_collection`, `create_collection`,
`list_collections`, `get_collection_by_uid`, `add_entry_to_collection`, `update_collection_entry_visibility`,
`remove_entry_from_collection`, `get_entry_collection_memberships`). `create_archived_entry` auto-enrolls new entries
into the default collection. `archive.rs`: `list_root_entries` + `search_entries` accept `caller_bits: u32` and enforce
collection visibility (`admin/owner bypass`; others filtered by `visibility_bits & caller_bits`). `list_entries_for_collection`,
`get_entry_collections`, `EntryCollectionMembership` added. `routes.rs`: read routes extract auth and pass caller_bits;
5 collection-entry route groups (`GET|POST /api/archives/:id/collections`, `GET /api/archives/:id/collections/:uid`,
`POST|DELETE|PATCH /api/archives/:id/collections/:uid/entries(/:entry_uid)`,
`GET /api/archives/:id/entries/:uid/collections`). Frontend: `CollectionsView.jsx` (list + detail + create form);
`collections` nav item in Topbar; entry collection memberships + visibility shown in ContextRail. 169 tests green.
Depends on Track 5.
---
@ -196,12 +205,12 @@ and a corresponding downloader module. Consider `rclone` as a shell-out strategy
## What to Do First
Tracks 1, 2, 3, 4, and 5 are complete. Track 6 (permissions & visibility) is the next priority.
Tracks 1, 2, 3, 4, 5, and 6 are complete. Track 7 (settings) is the next priority.
Open the next thread with:
```text
Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 6: permissions and
visibility — the collection model. Create a task-level implementation plan first, then wait
for approval.
Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 7: settings —
account profile, password change, instance settings UI, API token management.
Create a task-level implementation plan first, then wait for approval.
```

View file

@ -85,6 +85,15 @@ pub struct TagNode {
pub children: Vec<TagNode>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct CollectionSummary {
pub collection_uid: String,
pub name: String,
pub slug: String,
pub default_visibility_bits: u32,
pub created_at: String,
}
pub fn find_archive_path_from(start: &Path) -> Result<Option<PathBuf>> {
let mut dir = start.to_path_buf();
loop {
@ -184,7 +193,7 @@ pub fn initialize_store_directories(store_path: &Path) -> Result<()> {
Ok(())
}
pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary>> {
pub fn list_root_entries(conn: &rusqlite::Connection, caller_bits: u32) -> Result<Vec<EntrySummary>> {
let mut stmt = conn.prepare(
"SELECT
e.entry_uid,
@ -203,12 +212,20 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id
LEFT JOIN blobs b ON b.id = ea.blob_id
WHERE e.parent_entry_id IS NULL
AND (
CAST(?1 AS INTEGER) & 12 != 0
OR EXISTS (
SELECT 1 FROM collection_entries ce
WHERE ce.entry_id = e.id
AND ce.visibility_bits & CAST(?1 AS INTEGER) != 0
)
)
GROUP BY e.id
ORDER BY e.archived_at DESC, e.id DESC",
)?;
let entries = stmt
.query_map([], |row| {
.query_map([caller_bits as i64], |row| {
Ok(EntrySummary {
entry_uid: row.get(0)?,
archived_at: row.get(1)?,
@ -252,7 +269,7 @@ pub fn get_entry_detail(
return Ok(None);
};
let summary = list_root_entries(conn)?
let summary = list_root_entries(conn, u32::MAX)?
.into_iter()
.find(|entry| entry.entry_uid == entry_uid)
.context("entry disappeared while loading detail")?;
@ -325,6 +342,95 @@ pub fn get_capture_job(
}))
}
/// Lists all collections in the archive.
pub fn list_collections(conn: &rusqlite::Connection) -> Result<Vec<CollectionSummary>> {
let records = database::list_collections(conn)?;
Ok(records
.into_iter()
.map(|r| CollectionSummary {
collection_uid: r.collection_uid,
name: r.name,
slug: r.slug,
default_visibility_bits: r.default_visibility_bits,
created_at: r.created_at,
})
.collect())
}
/// Represents an entry's membership in a collection with its visibility bits.
#[derive(Debug, Clone, serde::Serialize)]
pub struct EntryCollectionMembership {
pub collection_uid: String,
pub visibility_bits: u32,
}
/// Returns collection memberships for the given entry_uid.
/// Returns Ok(None) if the entry_uid does not exist.
pub fn get_entry_collections(
conn: &rusqlite::Connection,
entry_uid: &str,
) -> Result<Option<Vec<EntryCollectionMembership>>> {
let Some(entry_id) = conn
.query_row(
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
[entry_uid],
|row| row.get::<_, i64>(0),
)
.optional()?
else {
return Ok(None);
};
let memberships = database::get_entry_collection_memberships(conn, entry_id)?;
Ok(Some(
memberships
.into_iter()
.map(|(_, uid, bits)| EntryCollectionMembership {
collection_uid: uid,
visibility_bits: bits,
})
.collect(),
))
}
/// 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`).
@ -350,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>,
@ -368,6 +474,25 @@ pub struct SearchEntriesQuery {
pub before: Option<String>,
/// Tag full_path filter; includes all entries (root + child) matching the tag subtree
pub tag: Option<String>,
/// Role bits of the caller for visibility filtering. Admins (bits 4/8) bypass all filters.
/// Pass `u32::MAX` internally to bypass all visibility. Pass 0 for unauthenticated guests only.
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`].
@ -498,6 +623,15 @@ pub fn search_entries(
params.push(b.clone());
}
// Visibility filter
let n = params.len() + 1;
sql.push_str(&format!(
" AND (CAST(?{n} AS INTEGER) & 12 != 0 \
OR EXISTS (SELECT 1 FROM collection_entries ce \
WHERE ce.entry_id = e.id AND ce.visibility_bits & CAST(?{n} AS INTEGER) != 0))"
));
params.push(query.caller_bits.to_string());
sql.push_str(" GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC");
let mut stmt = conn.prepare(&sql)?;
@ -849,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);
@ -1020,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

@ -130,6 +130,16 @@ pub struct RoleRecord {
pub is_builtin: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CollectionRecord {
pub id: i64,
pub collection_uid: String,
pub name: String,
pub slug: String,
pub default_visibility_bits: u32,
pub created_at: String,
}
pub fn database_path(archive_path: &Path) -> PathBuf {
archive_path.join(DATABASE_FILE_NAME)
}
@ -293,6 +303,43 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_entry_artifacts_blob_id ON entry_artifacts(blob_id);
CREATE INDEX IF NOT EXISTS idx_tags_parent_tag_id ON tags(parent_tag_id);
CREATE INDEX IF NOT EXISTS idx_entry_tag_assignments_tag_id ON entry_tag_assignments(tag_id);
CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY,
collection_uid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
default_visibility_bits INTEGER NOT NULL DEFAULT 2,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS collection_entries (
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
entry_id INTEGER NOT NULL REFERENCES archived_entries(id) ON DELETE CASCADE,
visibility_bits INTEGER NOT NULL DEFAULT 2,
added_at TEXT NOT NULL,
PRIMARY KEY (collection_id, entry_id)
);
CREATE INDEX IF NOT EXISTS idx_collection_entries_entry_id ON collection_entries(entry_id);
CREATE INDEX IF NOT EXISTS idx_collection_entries_collection_id ON collection_entries(collection_id);
-- Seed default collection (idempotent)
INSERT OR IGNORE INTO collections (collection_uid, name, slug, default_visibility_bits, created_at)
VALUES ('coll_default', 'All Entries', '_default_', 2, datetime('now'));
-- Migrate existing entries to default collection (idempotent)
INSERT OR IGNORE INTO collection_entries (collection_id, entry_id, visibility_bits, added_at)
SELECT
(SELECT id FROM collections WHERE slug = '_default_'),
ae.id,
CASE ae.visibility
WHEN 'public' THEN 3
WHEN 'unlisted' THEN 2
ELSE 0
END,
ae.archived_at
FROM archived_entries ae;
"#,
)?;
Ok(())
@ -1146,6 +1193,11 @@ pub fn create_archived_entry(conn: &Connection, entry: &NewEntry) -> Result<Arch
)?;
}
// Auto-enroll in the default collection with appropriate visibility_bits.
let default_coll_id = ensure_default_collection(conn)?;
let vbits = visibility_to_bits(&entry.visibility);
add_entry_to_collection(conn, default_coll_id, id, vbits)?;
Ok(ArchivedEntry {
id,
entry_uid,
@ -1396,6 +1448,166 @@ fn refresh_run_counters(conn: &Connection, run_id: i64) -> Result<()> {
Ok(())
}
/// Maps legacy visibility strings to collection_entries.visibility_bits.
/// 'public'→3 (guest|user), 'unlisted'→2 (user only), 'private'→0 (nobody).
pub fn visibility_to_bits(visibility: &str) -> u32 {
match visibility {
"public" => 3,
"unlisted" => 2,
_ => 0,
}
}
/// Returns the id of the '_default_' collection, creating it if absent.
pub fn ensure_default_collection(conn: &Connection) -> Result<i64> {
let now = now_timestamp();
conn.execute(
"INSERT OR IGNORE INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) \
VALUES ('coll_default', 'All Entries', '_default_', 2, ?1)",
[&now],
)?;
let id: i64 = conn.query_row(
"SELECT id FROM collections WHERE slug = '_default_'",
[],
|row| row.get(0),
)?;
Ok(id)
}
/// Creates a new collection. Returns the created record.
pub fn create_collection(
conn: &Connection,
name: &str,
slug: &str,
default_visibility_bits: u32,
) -> Result<CollectionRecord> {
if slug.is_empty() || slug.starts_with('_') {
anyhow::bail!("collection slug must be non-empty and not start with underscore");
}
let collection_uid = public_id("coll");
let now = now_timestamp();
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],
)?;
let id = conn.last_insert_rowid();
Ok(CollectionRecord {
id,
collection_uid,
name: name.to_string(),
slug: slug.to_string(),
default_visibility_bits,
created_at: now,
})
}
/// Lists all collections ordered by creation date.
pub fn list_collections(conn: &Connection) -> Result<Vec<CollectionRecord>> {
let mut stmt = conn.prepare(
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \
FROM collections ORDER BY created_at ASC",
)?;
stmt.query_map([], |row| {
Ok(CollectionRecord {
id: row.get(0)?,
collection_uid: row.get(1)?,
name: row.get(2)?,
slug: row.get(3)?,
default_visibility_bits: row.get::<_, i64>(4)? as u32,
created_at: row.get(5)?,
})
})?
.collect::<Result<_, _>>()
.map_err(Into::into)
}
/// Returns a collection by its uid, or None if not found.
pub fn get_collection_by_uid(
conn: &Connection,
uid: &str,
) -> Result<Option<CollectionRecord>> {
conn.query_row(
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \
FROM collections WHERE collection_uid = ?1",
[uid],
|row| {
Ok(CollectionRecord {
id: row.get(0)?,
collection_uid: row.get(1)?,
name: row.get(2)?,
slug: row.get(3)?,
default_visibility_bits: row.get::<_, i64>(4)? as u32,
created_at: row.get(5)?,
})
},
)
.optional()
.map_err(Into::into)
}
/// Adds an entry to a collection with given visibility_bits. Idempotent (INSERT OR IGNORE).
pub fn add_entry_to_collection(
conn: &Connection,
collection_id: i64,
entry_id: i64,
visibility_bits: u32,
) -> Result<()> {
let now = now_timestamp();
conn.execute(
"INSERT OR IGNORE INTO collection_entries (collection_id, entry_id, visibility_bits, added_at) \
VALUES (?1, ?2, ?3, ?4)",
params![collection_id, entry_id, visibility_bits as i64, now],
)?;
Ok(())
}
/// Updates the visibility_bits of an entry in a collection. Returns true if updated.
pub fn update_collection_entry_visibility(
conn: &Connection,
collection_id: i64,
entry_id: i64,
visibility_bits: u32,
) -> Result<bool> {
let n = conn.execute(
"UPDATE collection_entries SET visibility_bits = ?1 \
WHERE collection_id = ?2 AND entry_id = ?3",
params![visibility_bits as i64, collection_id, entry_id],
)?;
Ok(n > 0)
}
/// Removes an entry from a collection. Returns true if removed.
pub fn remove_entry_from_collection(
conn: &Connection,
collection_id: i64,
entry_id: i64,
) -> Result<bool> {
let n = conn.execute(
"DELETE FROM collection_entries WHERE collection_id = ?1 AND entry_id = ?2",
params![collection_id, entry_id],
)?;
Ok(n > 0)
}
/// Returns (collection_id, collection_uid, visibility_bits) for all collections containing an entry.
pub fn get_entry_collection_memberships(
conn: &Connection,
entry_id: i64,
) -> Result<Vec<(i64, String, u32)>> {
let mut stmt = conn.prepare(
"SELECT ce.collection_id, c.collection_uid, ce.visibility_bits \
FROM collection_entries ce \
JOIN collections c ON c.id = ce.collection_id \
WHERE ce.entry_id = ?1",
)?;
stmt.query_map([entry_id], |row| {
Ok((row.get(0)?, row.get(1)?, row.get::<_, i64>(2)? as u32))
})?
.collect::<Result<_, _>>()
.map_err(Into::into)
}
fn run_id_for_item(conn: &Connection, item_id: i64) -> Result<i64> {
let run_id = conn.query_row(
"SELECT run_id FROM archive_run_items WHERE id = ?1",

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::*;
@ -1336,11 +1531,12 @@ mod tests {
.unwrap();
assert_eq!(assign_resp.status(), StatusCode::CREATED, "assign tag should return 201");
// Search with ?tag=/science — entry should appear
// Search with ?tag=/science — entry should appear (requires auth since entry is private)
let response = app(registry.clone(), auth_path.clone())
.oneshot(
Request::builder()
.uri("/api/archives/test/entries/search?tag=%2Fscience")
.header("cookie", &session_cookie)
.body(Body::empty())
.unwrap(),
)
@ -1360,6 +1556,7 @@ mod tests {
.oneshot(
Request::builder()
.uri("/api/archives/test/entries/search?tag=%2Fart")
.header("cookie", &session_cookie)
.body(Body::empty())
.unwrap(),
)

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-Dfnwsgsa.js"></script>
<script type="module" crossorigin src="/assets/index-BfwsUa2j.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJpQthbx.css">
</head>
<body>

View file

@ -9,6 +9,7 @@ import EntriesView from './components/EntriesView'
import RunsView from './components/RunsView'
import AdminView from './components/AdminView'
import TagsView from './components/TagsView'
import CollectionsView from './components/CollectionsView'
import ContextRail from './components/ContextRail'
export const AuthContext = createContext(null);
@ -213,6 +214,9 @@ export default function App() {
onViewChange={handleViewChange}
/>
)}
{view === 'collections' && (
<CollectionsView archiveId={archiveId} />
)}
</div>
<ContextRail
archiveId={archiveId}

View file

@ -168,6 +168,67 @@ export async function createRole(slug, name) {
return res.json();
}
// ── Collection helpers ─────────────────────────────────────────────────────
export async function listCollections(archiveId) {
return getJson(`/api/archives/${archiveId}/collections`);
}
export async function createCollection(archiveId, name, slug, defaultVisibilityBits = 2) {
const res = await fetch(`/api/archives/${archiveId}/collections`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, slug, default_visibility_bits: defaultVisibilityBits }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
return res.json();
}
export async function getCollection(archiveId, collUid) {
return getJson(`/api/archives/${archiveId}/collections/${collUid}`);
}
export async function addEntryToCollection(archiveId, collUid, entryUid, visibilityBits = 2) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ entry_uid: entryUid, visibility_bits: visibilityBits }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
export async function removeEntryFromCollection(archiveId, collUid, entryUid) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries/${entryUid}`, {
method: 'DELETE',
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
export async function updateEntryVisibility(archiveId, collUid, entryUid, visibilityBits) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries/${entryUid}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ visibility_bits: visibilityBits }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
export async function listEntryCollections(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/collections`);
}
// ── 401 interceptor ───────────────────────────────────────────────────────────
const _origFetch = window.fetch;
window.fetch = async (...args) => {

View file

@ -0,0 +1,165 @@
import { useState, useEffect, useCallback } from 'react'
import { listCollections, createCollection, getCollection } from '../api.js'
const VIS_LABELS = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
export default function CollectionsView({ archiveId }) {
const [collections, setCollections] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [selectedColl, setSelectedColl] = useState(null)
const [collDetail, setCollDetail] = useState(null)
const [detailLoading, setDetailLoading] = useState(false)
// Create form
const [newName, setNewName] = useState('')
const [newSlug, setNewSlug] = useState('')
const [newVis, setNewVis] = useState(2)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState(null)
const refresh = useCallback(async () => {
if (!archiveId) return
setLoading(true)
setError(null)
try {
const cols = await listCollections(archiveId)
setCollections(cols)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}, [archiveId])
useEffect(() => { refresh() }, [refresh])
useEffect(() => {
if (!selectedColl) { setCollDetail(null); return }
setDetailLoading(true)
getCollection(archiveId, selectedColl.collection_uid)
.then(d => setCollDetail(d))
.catch(e => setError(e.message))
.finally(() => setDetailLoading(false))
}, [selectedColl, archiveId])
async function handleCreate(e) {
e.preventDefault()
const name = newName.trim()
const slug = newSlug.trim()
if (!name || !slug) return
setCreating(true)
setCreateError(null)
try {
await createCollection(archiveId, name, slug, newVis)
setNewName('')
setNewSlug('')
setNewVis(2)
await refresh()
} catch (err) {
setCreateError(err.message)
} finally {
setCreating(false)
}
}
if (!archiveId) return <div className="view-placeholder">Select an archive.</div>
return (
<div className="collections-view">
<h2 className="view-heading">Collections</h2>
{loading && <div className="muted">Loading</div>}
{error && <div className="error-text">{error}</div>}
<div className="collections-layout">
<div className="collections-list">
{collections.map(c => (
<div
key={c.collection_uid}
className={`collection-row${selectedColl?.collection_uid === c.collection_uid ? ' is-selected' : ''}`}
onClick={() => setSelectedColl(c)}
>
<span className="collection-name">{c.name}</span>
<span className="muted" style={{ fontSize: '0.8em' }}>
{VIS_LABELS[c.default_visibility_bits] ?? c.default_visibility_bits}
</span>
</div>
))}
{collections.length === 0 && !loading && (
<div className="muted">No collections yet.</div>
)}
</div>
{selectedColl && (
<div className="collection-detail">
<h3 className="collection-detail-name">{selectedColl.name}</h3>
<div className="muted" style={{ marginBottom: '0.75rem' }}>
Default visibility: {VIS_LABELS[selectedColl.default_visibility_bits] ?? selectedColl.default_visibility_bits}
</div>
{detailLoading ? (
<div className="muted">Loading entries</div>
) : collDetail ? (
collDetail.entries.length === 0 ? (
<div className="muted">No entries visible to you in this collection.</div>
) : (
<ul className="collection-entries-list">
{collDetail.entries.map(entry => (
<li key={entry.entry_uid} className="collection-entry-item">
<span className="entry-title">
{entry.title || entry.entry_uid}
</span>
<span className="muted" style={{ fontSize: '0.8em' }}>
{entry.source_kind}
</span>
</li>
))}
</ul>
)
) : null}
</div>
)}
</div>
<details className="create-collection-form" style={{ marginTop: '1.5rem' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Create collection</summary>
<form onSubmit={handleCreate} style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: 400 }}>
<label>
Name
<input
className="form-input"
type="text"
value={newName}
onChange={e => { setNewName(e.target.value); if (!newSlug) setNewSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) }}
placeholder="My Collection"
required
/>
</label>
<label>
Slug
<input
className="form-input"
type="text"
value={newSlug}
onChange={e => setNewSlug(e.target.value)}
placeholder="my-collection"
required
/>
</label>
<label>
Default visibility
<select className="form-input" value={newVis} onChange={e => setNewVis(Number(e.target.value))}>
<option value={0}>Private (admin/owner only)</option>
<option value={2}>Users only (logged in)</option>
<option value={3}>Public</option>
</select>
</label>
{createError && <div className="muted" style={{ color: 'var(--accent)' }}>{createError}</div>}
<button className="btn" type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create'}
</button>
</form>
</details>
</div>
)
}

View file

@ -1,11 +1,12 @@
import { useState, useEffect, useRef } from 'react'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag } from '../api'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections } from '../api'
import { formatTimestamp, formatBytes, valueText } from '../utils'
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) {
const [detail, setDetail] = useState(null)
const [tags, setTags] = useState([])
const [assignInput, setAssignInput] = useState('')
const [entryCollections, setEntryCollections] = useState([])
const [assignError, setAssignError] = useState('')
const selectSeqRef = useRef(0)
@ -13,6 +14,7 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
if (!selectedEntry || !archiveId) {
setDetail(null)
setTags([])
setEntryCollections([])
return
}
const seq = ++selectSeqRef.current
@ -21,10 +23,12 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
Promise.all([
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
fetchEntryTags(archiveId, selectedEntry.entry_uid),
]).then(([det, tgs]) => {
listEntryCollections(archiveId, selectedEntry.entry_uid),
]).then(([det, tgs, ecs]) => {
if (seq !== selectSeqRef.current) return
setDetail(det)
setTags(tgs)
setEntryCollections(ecs)
}).catch(() => {})
}, [selectedEntry, archiveId])
@ -156,6 +160,19 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
</div>
)}
</div>
{selectedEntry && entryCollections.length > 0 && (
<div className="rail-section">
<div className="rail-section-heading">Collections</div>
{entryCollections.map(c => (
<div key={c.collection_uid} className="rail-item">
<span className="rail-label">{c.collection_uid}</span>:{' '}
<span className="muted">
{{ 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }[c.visibility_bits] ?? `bits:${c.visibility_bits}`}
</span>
</div>
))}
</div>
)}
</>
)}
</aside>

View file

@ -21,7 +21,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
</select>
<nav className="nav" aria-label="Primary">
{['archive', 'runs', 'admin', 'tags'].map(name => (
{['archive', 'runs', 'admin', 'tags', 'collections'].map(name => (
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
onClick={() => onViewChange(name)}>
{name.charAt(0).toUpperCase() + name.slice(1)}