1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 12:55:00 +02:00

feat(collections): public collections, per-collection auth, UX improvements (#34)

* feat(core): add requires_auth to collections; include name in entry-collection memberships

- Add `requires_auth INTEGER NOT NULL DEFAULT 1` column to the
  collections DDL and as an idempotent ALTER TABLE migration in
  initialize_schema (archive DB), not initialize_auth_schema.
- CollectionRecord and CollectionSummary gain `requires_auth: bool`.
- create_collection() and update_collection() accept the new field.
- get_entry_collection_memberships() now returns collection name as the
  third tuple element; EntryCollectionMembership gains a `name` field
  so the sidebar can show human-readable names instead of raw UIDs.

* feat(server): conditional auth for public collections; add requires_auth + original_url to API

- CreateCollectionBody gains requires_auth (default true).
- PatchCollectionBody gains requires_auth: Option<bool>.
- get_collection_handler: load record first, then skip auth.require_auth()
  when record.requires_auth == false so public collections are accessible
  to unauthenticated callers; caller_bits falls back to ROLE_GUEST (1)
  so only visibility_bits=3 entries are returned to guests.
- Collection JSON response includes requires_auth and each entry now
  includes original_url for use by the public collection page.
- list_collections_handler keeps require_auth (management UI).

* feat(frontend): public collection page at /c/:archiveId/:collUid

- Detect PUBLIC_COLL_ROUTE at module load time (like PREVIEW_ROUTE) and
  return <PublicCollectionPage> before any auth checks so unauthenticated
  users can view public collections without hitting the login gate.
- PublicCollectionPage fetches via getCollection() and renders the
  server-filtered entry list (no client-side bitmask filtering - the
  server already applies caller_bits=GUEST for unauthenticated requests).
  Entry titles link to original_url when present; fall back to plain
  text when original_url is null.
- api.js createCollection() gains requiresAuth param (default true),
  sent as requires_auth in the request body.
- Storybook story covers WithEntries, Empty, and LoadError states.

* feat(frontend): collections view improvements

- addVis in the 'Add entry' form now syncs to the selected collection's
  default_visibility_bits via useEffect on collDetail, so the default
  matches the collection's configured entry visibility.
- Rename 'Default visibility' label to 'Entries\' default visibility'
  in both the detail pane and the create form to distinguish it from
  the new collection-level access setting.
- Add 'Require authentication to view' checkbox in the detail pane
  backed by a PATCH to requires_auth; reads collDetail?.requires_auth
  with fallback to the list-level selected record.
- Create form gains a matching requires_auth checkbox (default: true),
  passed as 5th arg to createCollection().

* feat(frontend): context rail collection improvements

- Show collection name (c.name) instead of raw UID in the sidebar
  Collections section; names now come from the updated
  EntryCollectionMembership API response.
- Fix horizontal overflow on long collection names: coll-name gains
  overflow:hidden + text-overflow:ellipsis + white-space:nowrap +
  min-width:0; coll-row gets overflow:hidden.
- Single-entry 'Add to collection' UI: dropdown + button inside the
  Collections rail section lets users add the current entry to any
  non-default collection without multi-selecting. After add, the
  membership list refreshes automatically.
- Collections section now shows even when entryCollections is empty,
  as long as non-default collections exist (so the add form is
  accessible for un-membered entries).
- Bulk 'Add to collection' now uses the target collection's
  default_visibility_bits instead of hardcoded 2 (Users only).
- Both bulk and single-entry dropdowns filter out slug='_default_'
  to match the backend rejection in add_entry_to_collection_handler.
- Collections list is now fetched on archiveId change (not just on
  bulk mode entry) so it is available for single-entry mode too.

* build(frontend): update static assets

* feat(frontend): public collection link UX + app-styled public page

CollectionsView:
- When a collection has requires_auth=false, show a read-only URL input
  and Copy button below the auth checkbox so the public link is
  immediately discoverable. The input auto-selects on focus so manual
  copy always works. Copy button tries navigator.clipboard.writeText
  first; falls back to execCommand('copy') for HTTP deployments where
  the Clipboard API is unavailable in non-secure contexts.

PublicCollectionPage:
- Rewritten to use the app's CSS classes and variables instead of
  bare inline styles, so it visually matches the main archive UI.
  Dark topbar (.pub-coll-topbar) with brand + collection name, paper
  background body, entry list via .coll-entries-list / .coll-entry-row /
  .coll-entry-info / .coll-entry-kind — the same classes used in the
  authenticated Collections view.

styles.css:
- .coll-public-link-row / -wrap / -input / .coll-copy-btn for the
  new link field in CollectionsView detail pane.
- .pub-coll-* classes for the public page layout and typography.

* feat(core): add get_collection_by_slug; scope search to active collection

- get_collection_by_slug(): new function mirroring get_collection_by_uid
  but matching on slug, used to resolve the _default_ collection when no
  ?collection param is supplied.
- SearchEntriesQuery gains collection_id: Option<i64>. When set, the
  search SQL adds an EXISTS subquery that checks collection_entries cef
  for both membership (cef.collection_id = ?) and visibility bits in
  that specific collection — preventing cross-collection visibility
  leaks where an entry is public in one collection but private in the
  current one. Without collection_id the original cross-collection
  visibility fallback is kept.

* feat(server): collection-scoped entries/search with uniform auth gate

All entry listing and search now route through the active collection:

list_entries (?collection=<uid>|main|<omitted>):
- Resolves the target collection; omitted or 'main' resolves to _default_.
- Checks requires_auth on that collection; gates auth conditionally.
- Returns list_entries_for_collection() — same EntrySummary shape.

search_entries_handler:
- Same collection resolution + conditional auth as list_entries.
- Sets search_query.collection_id so SQL scopes membership + visibility
  to the specific collection, not cross-collection fallback.

list_collections_handler:
- Dropped require_auth() — collection summaries (name/slug/uid/
  requires_auth/default_visibility_bits) are public metadata needed for
  the guest collection-switcher dropdown.

Tests:
- list_collections_requires_auth → list_collections_is_public (200).
- list_entries_requires_auth and search coverage still pass.

* feat(frontend): integrate collection switching into main Archive view

Replaces the standalone /c/:archiveId/:collUid public page with a
unified main-view approach where all collection logic lives at /.

URL param:
- ?collection=<uid> selects a collection; omitted or 'main' = default.
- 'main' is normalized to null in parseLocation() so the dropdown shows
  'All entries' and the URL stays clean.

Collection switcher (Topbar):
- Dropdown always visible (guests need it to navigate public collections).
- Non-default collections only (All entries = no param = _default_).
- Guest selecting an auth-required collection calls onSignInClick().
- handleCollectionChange checks both named and _default_ requires_auth
  before proceeding, redirecting guests to login if needed.

listCollections fetched for all users (guests too) since the endpoint
is now public; used to populate the switcher without auth.

Public-session mode (authenticated state, no currentUser):
- Auth gate: fetchArchives() + fetchEntries() with collection param;
  401 falls through to login, 200 proceeds as guest.
- auth:expired suppressed when !currentUser.
- fetchEntryDetail skipped; ContextRail shows entry summary + sign-in prompt.
- ContextRail selection effect skips tag/collection API calls.
- runs/tags not fetched in guest mode.
- Child row expansion disabled in EntryRow (hasChildren = false).

api.js:
- fetchEntries/searchEntries both thread ?collection=<uid> to server.

Deleted: PublicCollectionPage.jsx, PublicCollectionPage.stories.jsx,
copy-link UI from CollectionsView, pub-coll-*/copy-link CSS.

* build(frontend): update static assets

* feat(core): add is_entry_publicly_accessible; checks entry+parent vs public collections

* feat(server): allow guests to fetch detail/children/artifacts for public entries

* feat(frontend): guest collection dropdown filtering; public entry detail without auth wall

* build(frontend): update static assets

* test(server): public entry detail/artifact/children contract for guests

* feat(server): filter auth-required collections from guest list_collections response
This commit is contained in:
TheGeneralist 2026-07-24 20:16:17 +02:00 • committed by GitHub
parent 1af920eb63
commit e1ee05bd41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1218 additions and 185 deletions

View file

@ -100,6 +100,7 @@ pub struct CollectionSummary {
pub name: String, pub name: String,
pub slug: String, pub slug: String,
pub default_visibility_bits: u32, pub default_visibility_bits: u32,
pub requires_auth: bool,
pub created_at: String, pub created_at: String,
} }
@ -405,6 +406,7 @@ pub fn list_collections(conn: &rusqlite::Connection) -> Result<Vec<CollectionSum
name: r.name, name: r.name,
slug: r.slug, slug: r.slug,
default_visibility_bits: r.default_visibility_bits, default_visibility_bits: r.default_visibility_bits,
requires_auth: r.requires_auth,
created_at: r.created_at, created_at: r.created_at,
}) })
.collect()) .collect())
@ -414,6 +416,7 @@ pub fn list_collections(conn: &rusqlite::Connection) -> Result<Vec<CollectionSum
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
pub struct EntryCollectionMembership { pub struct EntryCollectionMembership {
pub collection_uid: String, pub collection_uid: String,
pub name: String,
pub visibility_bits: u32, pub visibility_bits: u32,
} }
@ -437,8 +440,9 @@ pub fn get_entry_collections(
Ok(Some( Ok(Some(
memberships memberships
.into_iter() .into_iter()
.map(|(_, uid, bits)| EntryCollectionMembership { .map(|(_, uid, name, bits)| EntryCollectionMembership {
collection_uid: uid, collection_uid: uid,
name,
visibility_bits: bits, visibility_bits: bits,
}) })
.collect(), .collect(),
@ -642,6 +646,7 @@ pub struct SearchEntriesQuery {
/// Role bits of the caller for visibility filtering. Admins (bits 4/8) bypass all filters. /// 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. /// Pass `u32::MAX` internally to bypass all visibility. Pass 0 for unauthenticated guests only.
pub caller_bits: u32, pub caller_bits: u32,
pub collection_id: Option<i64>,
} }
impl Default for SearchEntriesQuery { impl Default for SearchEntriesQuery {
@ -656,6 +661,7 @@ impl Default for SearchEntriesQuery {
before: None, before: None,
tag: None, tag: None,
caller_bits: u32::MAX, caller_bits: u32::MAX,
collection_id: None,
} }
} }
} }
@ -787,14 +793,29 @@ pub fn search_entries(
params.push(b.clone()); params.push(b.clone());
} }
// Visibility filter // Collection scope + visibility filter.
let n = params.len() + 1; if let Some(coll_id) = query.collection_id {
sql.push_str(&format!( let cn = params.len() + 1;
" AND (CAST(?{n} AS INTEGER) & 12 != 0 \ let vn = params.len() + 2;
OR EXISTS (SELECT 1 FROM collection_entries ce \ sql.push_str(&format!(
WHERE ce.entry_id = e.id AND ce.visibility_bits & CAST(?{n} AS INTEGER) != 0))" " AND EXISTS (\
)); SELECT 1 FROM collection_entries cef \
params.push(query.caller_bits.to_string()); WHERE cef.entry_id = e.id AND cef.collection_id = ?{cn} \
AND (CAST(?{vn} AS INTEGER) & 12 != 0 \
OR cef.visibility_bits & CAST(?{vn} AS INTEGER) != 0)\
)"
));
params.push(coll_id.to_string());
params.push(query.caller_bits.to_string());
} else {
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"); sql.push_str(" GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC");
@ -2021,7 +2042,7 @@ mod tests {
"Video 1", "https://example.com/pl/v1"); "Video 1", "https://example.com/pl/v1");
// Enroll the container (but NOT the child) in a USER-visible collection (bits=2). // Enroll the container (but NOT the child) in a USER-visible collection (bits=2).
let coll = database::create_collection(&conn, "My List", "my-list", 2).unwrap(); let coll = database::create_collection(&conn, "My List", "my-list", 2, true).unwrap();
database::add_entry_to_collection(&conn, coll.id, container.id, 2).unwrap(); database::add_entry_to_collection(&conn, coll.id, container.id, 2).unwrap();
// USER caller (bits=2): child must be visible through parent's collection. // USER caller (bits=2): child must be visible through parent's collection.

View file

@ -163,6 +163,7 @@ pub struct CollectionRecord {
pub name: String, pub name: String,
pub slug: String, pub slug: String,
pub default_visibility_bits: u32, pub default_visibility_bits: u32,
pub requires_auth: bool,
pub created_at: String, pub created_at: String,
} }
@ -339,6 +340,7 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
name TEXT NOT NULL, name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE,
default_visibility_bits INTEGER NOT NULL DEFAULT 2, default_visibility_bits INTEGER NOT NULL DEFAULT 2,
requires_auth INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@ -434,6 +436,12 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
// Migration: add notes_json column to existing capture_jobs tables. // Migration: add notes_json column to existing capture_jobs tables.
// Silently ignored when the column already exists (idempotent). // Silently ignored when the column already exists (idempotent).
let _ = conn.execute("ALTER TABLE capture_jobs ADD COLUMN notes_json TEXT", []); let _ = conn.execute("ALTER TABLE capture_jobs ADD COLUMN notes_json TEXT", []);
// Migration: add requires_auth column to existing collections tables.
// Silently ignored when the column already exists (idempotent).
let _ = conn.execute(
"ALTER TABLE collections ADD COLUMN requires_auth INTEGER NOT NULL DEFAULT 1",
[],
);
Ok(()) Ok(())
} }
@ -2349,6 +2357,7 @@ pub fn create_collection(
name: &str, name: &str,
slug: &str, slug: &str,
default_visibility_bits: u32, default_visibility_bits: u32,
requires_auth: bool,
) -> Result<CollectionRecord> { ) -> Result<CollectionRecord> {
if slug.is_empty() || slug.starts_with('_') { if slug.is_empty() || slug.starts_with('_') {
anyhow::bail!("collection slug must be non-empty and not start with underscore"); anyhow::bail!("collection slug must be non-empty and not start with underscore");
@ -2356,13 +2365,14 @@ pub fn create_collection(
let collection_uid = public_id("coll"); let collection_uid = public_id("coll");
let now = now_timestamp(); let now = now_timestamp();
conn.execute( conn.execute(
"INSERT INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) \ "INSERT INTO collections (collection_uid, name, slug, default_visibility_bits, requires_auth, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5)", VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![ params![
collection_uid, collection_uid,
name, name,
slug, slug,
default_visibility_bits as i64, default_visibility_bits as i64,
requires_auth as i64,
now now
], ],
)?; )?;
@ -2373,6 +2383,7 @@ pub fn create_collection(
name: name.to_string(), name: name.to_string(),
slug: slug.to_string(), slug: slug.to_string(),
default_visibility_bits, default_visibility_bits,
requires_auth,
created_at: now, created_at: now,
}) })
} }
@ -2380,7 +2391,7 @@ pub fn create_collection(
/// Lists all collections ordered by creation date. /// Lists all collections ordered by creation date.
pub fn list_collections(conn: &Connection) -> Result<Vec<CollectionRecord>> { pub fn list_collections(conn: &Connection) -> Result<Vec<CollectionRecord>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \ "SELECT id, collection_uid, name, slug, default_visibility_bits, created_at, requires_auth \
FROM collections ORDER BY created_at ASC", FROM collections ORDER BY created_at ASC",
)?; )?;
stmt.query_map([], |row| { stmt.query_map([], |row| {
@ -2391,6 +2402,7 @@ pub fn list_collections(conn: &Connection) -> Result<Vec<CollectionRecord>> {
slug: row.get(3)?, slug: row.get(3)?,
default_visibility_bits: row.get::<_, i64>(4)? as u32, default_visibility_bits: row.get::<_, i64>(4)? as u32,
created_at: row.get(5)?, created_at: row.get(5)?,
requires_auth: row.get::<_, i64>(6)? != 0,
}) })
})? })?
.collect::<Result<_, _>>() .collect::<Result<_, _>>()
@ -2400,7 +2412,7 @@ pub fn list_collections(conn: &Connection) -> Result<Vec<CollectionRecord>> {
/// Returns a collection by its uid, or None if not found. /// Returns a collection by its uid, or None if not found.
pub fn get_collection_by_uid(conn: &Connection, uid: &str) -> Result<Option<CollectionRecord>> { pub fn get_collection_by_uid(conn: &Connection, uid: &str) -> Result<Option<CollectionRecord>> {
conn.query_row( conn.query_row(
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \ "SELECT id, collection_uid, name, slug, default_visibility_bits, created_at, requires_auth \
FROM collections WHERE collection_uid = ?1", FROM collections WHERE collection_uid = ?1",
[uid], [uid],
|row| { |row| {
@ -2411,6 +2423,7 @@ pub fn get_collection_by_uid(conn: &Connection, uid: &str) -> Result<Option<Coll
slug: row.get(3)?, slug: row.get(3)?,
default_visibility_bits: row.get::<_, i64>(4)? as u32, default_visibility_bits: row.get::<_, i64>(4)? as u32,
created_at: row.get(5)?, created_at: row.get(5)?,
requires_auth: row.get::<_, i64>(6)? != 0,
}) })
}, },
) )
@ -2418,6 +2431,24 @@ pub fn get_collection_by_uid(conn: &Connection, uid: &str) -> Result<Option<Coll
.map_err(Into::into) .map_err(Into::into)
} }
/// Returns a collection by its slug, or None if not found.
pub fn get_collection_by_slug(conn: &Connection, slug: &str) -> Result<Option<CollectionRecord>> {
conn.query_row(
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at, requires_auth \
FROM collections WHERE slug = ?1",
[slug],
|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)?,
requires_auth: row.get::<_, i64>(6)? != 0,
}),
).optional().map_err(Into::into)
}
/// Adds an entry to a collection with given visibility_bits. Idempotent (INSERT OR IGNORE). /// Adds an entry to a collection with given visibility_bits. Idempotent (INSERT OR IGNORE).
pub fn add_entry_to_collection( pub fn add_entry_to_collection(
conn: &Connection, conn: &Connection,
@ -2462,24 +2493,55 @@ pub fn remove_entry_from_collection(
Ok(n > 0) Ok(n > 0)
} }
/// Returns (collection_id, collection_uid, visibility_bits) for all collections containing an entry. /// Returns (collection_id, collection_uid, name, visibility_bits) for all collections containing an entry.
pub fn get_entry_collection_memberships( pub fn get_entry_collection_memberships(
conn: &Connection, conn: &Connection,
entry_id: i64, entry_id: i64,
) -> Result<Vec<(i64, String, u32)>> { ) -> Result<Vec<(i64, String, String, u32)>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT ce.collection_id, c.collection_uid, ce.visibility_bits \ "SELECT ce.collection_id, c.collection_uid, c.name, ce.visibility_bits \
FROM collection_entries ce \ FROM collection_entries ce \
JOIN collections c ON c.id = ce.collection_id \ JOIN collections c ON c.id = ce.collection_id \
WHERE ce.entry_id = ?1", WHERE ce.entry_id = ?1",
)?; )?;
stmt.query_map([entry_id], |row| { stmt.query_map([entry_id], |row| {
Ok((row.get(0)?, row.get(1)?, row.get::<_, i64>(2)? as u32)) Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get::<_, i64>(3)? as u32))
})? })?
.collect::<Result<_, _>>() .collect::<Result<_, _>>()
.map_err(Into::into) .map_err(Into::into)
} }
/// Returns true if this entry (or its direct parent, for child entries) is in at least one
/// collection with `requires_auth = false` AND `collection_entries.visibility_bits & ROLE_GUEST (1) != 0`.
///
/// Child entries are not directly assigned to collections; they inherit visibility from their
/// parent's collection membership, matching the logic in `list_child_entries`.
pub fn is_entry_publicly_accessible(conn: &Connection, entry_uid: &str) -> Result<bool> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM archived_entries e \
WHERE e.entry_uid = ?1 \
AND (\
EXISTS (\
SELECT 1 FROM collection_entries ce \
JOIN collections c ON c.id = ce.collection_id \
WHERE ce.entry_id = e.id \
AND c.requires_auth = 0 \
AND (ce.visibility_bits & 1) != 0\
) \
OR (e.parent_entry_id IS NOT NULL AND EXISTS (\
SELECT 1 FROM collection_entries ce_p \
JOIN collections c ON c.id = ce_p.collection_id \
WHERE ce_p.entry_id = e.parent_entry_id \
AND c.requires_auth = 0 \
AND (ce_p.visibility_bits & 1) != 0\
))\
)",
[entry_uid],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// Renames a collection and/or updates its default_visibility_bits. /// Renames a collection and/or updates its default_visibility_bits.
/// Returns true if updated, false if not found. /// Returns true if updated, false if not found.
/// Refuses to rename the '_default_' collection but allows changing its /// Refuses to rename the '_default_' collection but allows changing its
@ -2489,6 +2551,7 @@ pub fn update_collection(
collection_uid: &str, collection_uid: &str,
new_name: Option<&str>, new_name: Option<&str>,
new_visibility_bits: Option<u32>, new_visibility_bits: Option<u32>,
requires_auth: Option<bool>,
) -> Result<bool> { ) -> Result<bool> {
let coll = get_collection_by_uid(conn, collection_uid)?; let coll = get_collection_by_uid(conn, collection_uid)?;
let Some(coll) = coll else { return Ok(false) }; let Some(coll) = coll else { return Ok(false) };
@ -2497,9 +2560,10 @@ pub fn update_collection(
} }
let name = new_name.unwrap_or(&coll.name); let name = new_name.unwrap_or(&coll.name);
let vbits = new_visibility_bits.unwrap_or(coll.default_visibility_bits); let vbits = new_visibility_bits.unwrap_or(coll.default_visibility_bits);
let auth = requires_auth.unwrap_or(coll.requires_auth);
conn.execute( conn.execute(
"UPDATE collections SET name = ?1, default_visibility_bits = ?2 WHERE id = ?3", "UPDATE collections SET name = ?1, default_visibility_bits = ?2, requires_auth = ?3 WHERE id = ?4",
params![name, vbits as i64, coll.id], params![name, vbits as i64, auth as i64, coll.id],
)?; )?;
Ok(true) Ok(true)
} }
@ -2962,7 +3026,7 @@ mod tests {
}; };
// Changing default_visibility_bits on _default_ must succeed. // Changing default_visibility_bits on _default_ must succeed.
let updated = update_collection(&conn, &coll_uid, None, Some(3)).unwrap(); let updated = update_collection(&conn, &coll_uid, None, Some(3), None).unwrap();
assert!(updated, "visibility change on _default_ should succeed"); assert!(updated, "visibility change on _default_ should succeed");
let bits: u32 = conn let bits: u32 = conn
.query_row( .query_row(
@ -2974,7 +3038,7 @@ mod tests {
assert_eq!(bits, 3, "default_visibility_bits should be updated to 3"); assert_eq!(bits, 3, "default_visibility_bits should be updated to 3");
// Renaming _default_ must still be rejected. // Renaming _default_ must still be rejected.
let err = update_collection(&conn, &coll_uid, Some("My Archive"), None); let err = update_collection(&conn, &coll_uid, Some("My Archive"), None, None);
assert!(err.is_err(), "renaming _default_ must be rejected"); assert!(err.is_err(), "renaming _default_ must be rejected");
assert!( assert!(
err.unwrap_err().to_string().contains("cannot rename"), err.unwrap_err().to_string().contains("cannot rename"),

View file

@ -71,6 +71,7 @@ pub struct AppState {
pub struct EntrySearchParams { pub struct EntrySearchParams {
pub q: Option<String>, pub q: Option<String>,
pub tag: Option<String>, pub tag: Option<String>,
pub collection: Option<String>,
} }
/// Tower middleware: returns 503 on all non-exempt routes if setup hasn't been completed. /// Tower middleware: returns 503 on all non-exempt routes if setup hasn't been completed.
@ -421,16 +422,31 @@ async fn list_archives(State(state): State<AppState>) -> Json<Vec<MountedArchive
Json(state.registry.archives.clone()) Json(state.registry.archives.clone())
} }
#[derive(Debug, serde::Deserialize, Default)]
struct EntriesFilter {
collection: Option<String>,
}
async fn list_entries( async fn list_entries(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthUser, auth: AuthUser,
Path(archive_id): Path<String>, Path(archive_id): Path<String>,
Query(filter): Query<EntriesFilter>,
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> { ) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
auth.require_auth()?;
let mounted = mounted_archive(&state, &archive_id)?; let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?; let conn = database::open_or_initialize(&mounted.archive_path)?;
// "main" is a URL-friendly alias for the default collection; no param also resolves to it.
let coll = match filter.collection.as_deref() {
None | Some("main") => database::get_collection_by_slug(&conn, "_default_")?
.ok_or(ApiError::not_found("default collection missing"))?,
Some(uid) => database::get_collection_by_uid(&conn, uid)?
.ok_or(ApiError::not_found("collection not found"))?,
};
if coll.requires_auth {
auth.require_auth()?;
}
let caller_bits = auth_to_caller_bits(&auth); let caller_bits = auth_to_caller_bits(&auth);
Ok(Json(archive::list_root_entries(&conn, caller_bits)?)) Ok(Json(archive::list_entries_for_collection(&conn, coll.id, caller_bits)?))
} }
async fn list_entry_children( async fn list_entry_children(
@ -438,15 +454,19 @@ async fn list_entry_children(
auth: AuthUser, auth: AuthUser,
Path((archive_id, entry_uid)): Path<(String, String)>, Path((archive_id, entry_uid)): Path<(String, String)>,
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> { ) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
auth.require_auth()?;
let mounted = mounted_archive(&state, &archive_id)?; let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?; let conn = database::open_or_initialize(&mounted.archive_path)?;
if matches!(auth, AuthUser::Guest) {
// list_child_entries checks visibility_bits but not collections.requires_auth;
// gate on the parent being publicly accessible before opening the endpoint.
if !database::is_entry_publicly_accessible(&conn, &entry_uid)? {
return Err(ApiError::unauthorized("login required"));
}
} else {
auth.require_auth()?;
}
let caller_bits = auth_to_caller_bits(&auth); let caller_bits = auth_to_caller_bits(&auth);
Ok(Json(archive::list_child_entries( Ok(Json(archive::list_child_entries(&conn, &entry_uid, caller_bits)?))
&conn,
&entry_uid,
caller_bits,
)?))
} }
async fn search_entries_handler( async fn search_entries_handler(
@ -455,9 +475,17 @@ async fn search_entries_handler(
Path(archive_id): Path<String>, Path(archive_id): Path<String>,
Query(params): Query<EntrySearchParams>, Query(params): Query<EntrySearchParams>,
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> { ) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
auth.require_auth()?;
let mounted = mounted_archive(&state, &archive_id)?; let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?; let conn = database::open_or_initialize(&mounted.archive_path)?;
let coll = match params.collection.as_deref() {
None | Some("main") => database::get_collection_by_slug(&conn, "_default_")?
.ok_or(ApiError::not_found("default collection missing"))?,
Some(uid) => database::get_collection_by_uid(&conn, uid)?
.ok_or(ApiError::not_found("collection not found"))?,
};
if coll.requires_auth {
auth.require_auth()?;
}
let raw = params.q.as_deref().unwrap_or(""); let raw = params.q.as_deref().unwrap_or("");
let mut search_query = archive::parse_search_query(raw) let mut search_query = archive::parse_search_query(raw)
.map_err(|prefix| ApiError::bad_request(&format!("unknown search prefix: {prefix}")))?; .map_err(|prefix| ApiError::bad_request(&format!("unknown search prefix: {prefix}")))?;
@ -465,6 +493,7 @@ async fn search_entries_handler(
search_query.tag = Some(tag); search_query.tag = Some(tag);
} }
search_query.caller_bits = auth_to_caller_bits(&auth); search_query.caller_bits = auth_to_caller_bits(&auth);
search_query.collection_id = Some(coll.id);
Ok(Json(archive::search_entries(&conn, &search_query)?)) Ok(Json(archive::search_entries(&conn, &search_query)?))
} }
@ -473,9 +502,13 @@ async fn entry_detail(
auth_user: AuthUser, auth_user: AuthUser,
Path((archive_id, entry_uid)): Path<(String, String)>, Path((archive_id, entry_uid)): Path<(String, String)>,
) -> Result<Json<archive::EntryDetail>, ApiError> { ) -> Result<Json<archive::EntryDetail>, ApiError> {
auth_user.require_auth()?;
let mounted = mounted_archive(&state, &archive_id)?; let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?; let conn = database::open_or_initialize(&mounted.archive_path)?;
if matches!(auth_user, AuthUser::Guest) {
if !database::is_entry_publicly_accessible(&conn, &entry_uid)? {
return Err(ApiError::unauthorized("login required"));
}
}
let detail = archive::get_entry_detail(&conn, &entry_uid)? let detail = archive::get_entry_detail(&conn, &entry_uid)?
.ok_or(ApiError::not_found("entry not found"))?; .ok_or(ApiError::not_found("entry not found"))?;
Ok(Json(detail)) Ok(Json(detail))
@ -511,8 +544,8 @@ async fn serve_artifact(
Query(params): Query<ArtifactQuery>, Query(params): Query<ArtifactQuery>,
req: Request, req: Request,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
// Auth: valid scoped token OR authenticated session (OR both). // Auth: valid scoped token OR authenticated session OR publicly accessible entry.
// A token present but invalid/expired falls back to session auth so that // A token present but invalid/expired falls back to session/public check so that
// a logged-in browser player keeps working after a token expires. // a logged-in browser player keeps working after a token expires.
let token_valid = params.token.as_deref().map_or(false, |tok| { let token_valid = params.token.as_deref().map_or(false, |tok| {
let tokens = state.media_tokens.lock(); let tokens = state.media_tokens.lock();
@ -524,7 +557,15 @@ async fn serve_artifact(
}) })
}); });
if !token_valid { if !token_valid {
auth_user.require_auth()?; if matches!(auth_user, AuthUser::Guest) {
let mounted_check = mounted_archive(&state, &archive_id)?;
let conn_check = database::open_or_initialize(&mounted_check.archive_path)?;
if !database::is_entry_publicly_accessible(&conn_check, &entry_uid)? {
return Err(ApiError::unauthorized("login required"));
}
} else {
auth_user.require_auth()?;
}
} }
let mounted = mounted_archive(&state, &archive_id)?; let mounted = mounted_archive(&state, &archive_id)?;
let paths = archive::read_archive_paths(&mounted.archive_path)?; let paths = archive::read_archive_paths(&mounted.archive_path)?;
@ -659,12 +700,18 @@ struct CreateCollectionBody {
slug: String, slug: String,
#[serde(default = "default_user_visibility")] #[serde(default = "default_user_visibility")]
default_visibility_bits: u32, default_visibility_bits: u32,
#[serde(default = "default_requires_auth")]
requires_auth: bool,
} }
fn default_user_visibility() -> u32 { fn default_user_visibility() -> u32 {
2 2
} }
fn default_requires_auth() -> bool {
true
}
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
struct AddEntryBody { struct AddEntryBody {
entry_uid: String, entry_uid: String,
@ -681,6 +728,7 @@ struct UpdateVisibilityBody {
struct PatchCollectionBody { struct PatchCollectionBody {
name: Option<String>, name: Option<String>,
default_visibility_bits: Option<u32>, default_visibility_bits: Option<u32>,
requires_auth: Option<bool>,
} }
async fn list_tags( async fn list_tags(
@ -2275,13 +2323,19 @@ impl IntoResponse for ApiError {
async fn list_collections_handler( async fn list_collections_handler(
State(state): State<AppState>, State(state): State<AppState>,
auth_user: AuthUser, auth: AuthUser,
Path(archive_id): Path<String>, Path(archive_id): Path<String>,
) -> Result<Json<Vec<archive::CollectionSummary>>, ApiError> { ) -> Result<Json<Vec<archive::CollectionSummary>>, ApiError> {
auth_user.require_auth()?;
let mounted = mounted_archive(&state, &archive_id)?; let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?; let conn = database::open_or_initialize(&mounted.archive_path)?;
Ok(Json(archive::list_collections(&conn)?)) let all = archive::list_collections(&conn)?;
// Guests only see public collections; authenticated users see all.
let visible = if matches!(auth, AuthUser::Guest) {
all.into_iter().filter(|c| !c.requires_auth).collect()
} else {
all
};
Ok(Json(visible))
} }
async fn create_collection_handler( async fn create_collection_handler(
@ -2302,7 +2356,7 @@ async fn create_collection_handler(
let mounted = mounted_archive(&state, &archive_id)?; let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?; let conn = database::open_or_initialize(&mounted.archive_path)?;
let record = let record =
database::create_collection(&conn, &body.name, &body.slug, body.default_visibility_bits) database::create_collection(&conn, &body.name, &body.slug, body.default_visibility_bits, body.requires_auth)
.map_err(|e| ApiError::bad_request(&format!("{e:#}")))?; .map_err(|e| ApiError::bad_request(&format!("{e:#}")))?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
@ -2311,6 +2365,7 @@ async fn create_collection_handler(
name: record.name, name: record.name,
slug: record.slug, slug: record.slug,
default_visibility_bits: record.default_visibility_bits, default_visibility_bits: record.default_visibility_bits,
requires_auth: record.requires_auth,
created_at: record.created_at, created_at: record.created_at,
}), }),
)) ))
@ -2321,11 +2376,13 @@ async fn get_collection_handler(
auth: AuthUser, auth: AuthUser,
Path((archive_id, coll_uid)): Path<(String, String)>, Path((archive_id, coll_uid)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
auth.require_auth()?;
let mounted = mounted_archive(&state, &archive_id)?; let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?; let conn = database::open_or_initialize(&mounted.archive_path)?;
let record = database::get_collection_by_uid(&conn, &coll_uid)? let record = database::get_collection_by_uid(&conn, &coll_uid)?
.ok_or(ApiError::not_found("collection not found"))?; .ok_or(ApiError::not_found("collection not found"))?;
if record.requires_auth {
auth.require_auth()?;
}
let caller_bits = auth_to_caller_bits(&auth); let caller_bits = auth_to_caller_bits(&auth);
let entries = archive::list_entries_for_collection(&conn, record.id, caller_bits)?; let entries = archive::list_entries_for_collection(&conn, record.id, caller_bits)?;
// Collect per-entry visibility bits from collection_entries // Collect per-entry visibility bits from collection_entries
@ -2358,6 +2415,7 @@ async fn get_collection_handler(
"title": e.title, "title": e.title,
"source_kind": e.source_kind, "source_kind": e.source_kind,
"archived_at": e.archived_at, "archived_at": e.archived_at,
"original_url": e.original_url,
"collection_visibility_bits": vis, "collection_visibility_bits": vis,
}) })
}) })
@ -2367,6 +2425,7 @@ async fn get_collection_handler(
"name": record.name, "name": record.name,
"slug": record.slug, "slug": record.slug,
"default_visibility_bits": record.default_visibility_bits, "default_visibility_bits": record.default_visibility_bits,
"requires_auth": record.requires_auth,
"created_at": record.created_at, "created_at": record.created_at,
"entries": entries_json, "entries": entries_json,
}))) })))
@ -2482,7 +2541,7 @@ async fn patch_collection_handler(
let conn = database::open_or_initialize(&mounted.archive_path)?; let conn = database::open_or_initialize(&mounted.archive_path)?;
let name_ref: Option<&str> = body.name.as_deref(); let name_ref: Option<&str> = body.name.as_deref();
let updated = let updated =
database::update_collection(&conn, &coll_uid, name_ref, body.default_visibility_bits)?; database::update_collection(&conn, &coll_uid, name_ref, body.default_visibility_bits, body.requires_auth)?;
if updated { if updated {
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} else { } else {
@ -4589,7 +4648,9 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn list_collections_requires_auth() { async fn list_collections_is_public() {
// list_collections no longer requires auth — collection summaries are public metadata
// needed for the collection switcher in guest mode.
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir); let (registry, _, auth_path) = make_test_registry(&dir);
let response = app(registry, auth_path) let response = app(registry, auth_path)
@ -4601,7 +4662,7 @@ mod tests {
) )
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::OK);
} }
#[tokio::test] #[tokio::test]
@ -4660,12 +4721,37 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn get_collection_requires_auth() { async fn get_collection_requires_auth() {
// Create a collection (requires_auth defaults to true), then confirm
// that an unauthenticated GET returns 401, not 404.
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir); let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let create_resp = app(registry.clone(), auth_path.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/test/collections")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(json_body(&serde_json::json!({
"name": "Auth Required",
"slug": "auth-required",
"default_visibility_bits": 2,
"requires_auth": true
})))
.unwrap(),
)
.await
.unwrap();
assert_eq!(create_resp.status(), StatusCode::CREATED);
let coll = body_json(create_resp).await;
let coll_uid = coll["collection_uid"].as_str().unwrap().to_string();
// Now GET without auth — must be 401 because requires_auth == true.
let uri = format!("/api/archives/test/collections/{coll_uid}");
let response = app(registry, auth_path) let response = app(registry, auth_path)
.oneshot( .oneshot(
Request::builder() Request::builder()
.uri("/api/archives/test/collections/coll_notexist") .uri(&uri)
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
) )
@ -4674,6 +4760,50 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test]
async fn get_public_collection_no_auth_returns_ok() {
// A collection with requires_auth=false must be reachable by guests.
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let create_resp = app(registry.clone(), auth_path.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/test/collections")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(json_body(&serde_json::json!({
"name": "Public Collection",
"slug": "public-coll",
"default_visibility_bits": 3,
"requires_auth": false
})))
.unwrap(),
)
.await
.unwrap();
assert_eq!(create_resp.status(), StatusCode::CREATED);
let coll = body_json(create_resp).await;
let coll_uid = coll["collection_uid"].as_str().unwrap().to_string();
assert_eq!(coll["requires_auth"], false, "requires_auth should be false");
// GET without any auth cookie — must be 200.
let uri = format!("/api/archives/test/collections/{coll_uid}");
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.uri(&uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["requires_auth"], false);
assert_eq!(body["name"], "Public Collection");
}
#[tokio::test] #[tokio::test]
async fn get_collection_with_auth_returns_ok() { async fn get_collection_with_auth_returns_ok() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@ -4771,6 +4901,246 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
} }
// ── Collection-scoped entries + search security tests ──────────────────
#[tokio::test]
async fn list_entries_public_named_collection_allows_guest() {
// A named collection with requires_auth=false must be accessible without a cookie.
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
// Create a public collection.
let create = app(registry.clone(), auth_path.clone())
.oneshot(Request::builder().method("POST")
.uri("/api/archives/test/collections")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(json_body(&serde_json::json!({
"name": "Public Coll", "slug": "public-coll",
"default_visibility_bits": 3, "requires_auth": false
}))).unwrap()).await.unwrap();
assert_eq!(create.status(), StatusCode::CREATED);
let coll = body_json(create).await;
let uid = coll["collection_uid"].as_str().unwrap().to_string();
// Guest access to entries scoped to that collection → 200.
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries?collection={uid}"))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn list_entries_auth_required_named_collection_blocks_guest() {
// A named collection with requires_auth=true must block guests.
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let create = app(registry.clone(), auth_path.clone())
.oneshot(Request::builder().method("POST")
.uri("/api/archives/test/collections")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(json_body(&serde_json::json!({
"name": "Private Coll", "slug": "private-coll",
"default_visibility_bits": 2, "requires_auth": true
}))).unwrap()).await.unwrap();
assert_eq!(create.status(), StatusCode::CREATED);
let coll = body_json(create).await;
let uid = coll["collection_uid"].as_str().unwrap().to_string();
// Guest access → 401.
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries?collection={uid}"))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn list_entries_public_default_collection_allows_guest() {
// When the _default_ collection is set to requires_auth=false, guests can list entries.
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
// PATCH the default collection to make it public.
let default_uid = {
let conn = database::open_or_initialize(&archive_path).unwrap();
database::get_collection_by_slug(&conn, "_default_").unwrap().unwrap().collection_uid
};
let patch = app(registry.clone(), auth_path.clone())
.oneshot(Request::builder().method("PATCH")
.uri(format!("/api/archives/test/collections/{default_uid}"))
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(json_body(&serde_json::json!({ "requires_auth": false }))).unwrap())
.await.unwrap();
assert_eq!(patch.status(), StatusCode::NO_CONTENT);
// Guest access to /entries (no collection param → resolves to _default_) → 200.
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri("/api/archives/test/entries")
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_entries_public_collection_allows_guest() {
// Guest can search within a public collection.
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let create = app(registry.clone(), auth_path.clone())
.oneshot(Request::builder().method("POST")
.uri("/api/archives/test/collections")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(json_body(&serde_json::json!({
"name": "Public Search", "slug": "public-search",
"default_visibility_bits": 3, "requires_auth": false
}))).unwrap()).await.unwrap();
assert_eq!(create.status(), StatusCode::CREATED);
let coll = body_json(create).await;
let uid = coll["collection_uid"].as_str().unwrap().to_string();
// Guest search scoped to that collection → 200.
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries/search?collection={uid}&q=test"))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_entries_auth_required_collection_blocks_guest() {
// Guest cannot search within an auth-required collection.
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let create = app(registry.clone(), auth_path.clone())
.oneshot(Request::builder().method("POST")
.uri("/api/archives/test/collections")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(json_body(&serde_json::json!({
"name": "Private Search", "slug": "private-search",
"default_visibility_bits": 2, "requires_auth": true
}))).unwrap()).await.unwrap();
assert_eq!(create.status(), StatusCode::CREATED);
let coll = body_json(create).await;
let uid = coll["collection_uid"].as_str().unwrap().to_string();
// Guest search → 401.
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries/search?collection={uid}&q=test"))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn collection_scoped_visibility_does_not_leak_across_collections() {
// Entry is users-only (visibility_bits=2) in CollA and public (visibility_bits=3)
// in CollB. Guest list + search of CollA must return 0 results; CollB must return 1.
// This catches the bug where cross-collection visibility check would return the entry
// because it is public *somewhere*, regardless of the requested collection.
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
// Two public collections: CollA (default vis=2) and CollB (default vis=3).
let coll_a_uid = {
let r = app(registry.clone(), auth_path.clone())
.oneshot(Request::builder().method("POST")
.uri("/api/archives/test/collections")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(json_body(&serde_json::json!({
"name": "CollA", "slug": "coll-a",
"default_visibility_bits": 2, "requires_auth": false
}))).unwrap()).await.unwrap();
assert_eq!(r.status(), StatusCode::CREATED);
body_json(r).await["collection_uid"].as_str().unwrap().to_string()
};
let coll_b_uid = {
let r = app(registry.clone(), auth_path.clone())
.oneshot(Request::builder().method("POST")
.uri("/api/archives/test/collections")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(json_body(&serde_json::json!({
"name": "CollB", "slug": "coll-b",
"default_visibility_bits": 3, "requires_auth": false
}))).unwrap()).await.unwrap();
assert_eq!(r.status(), StatusCode::CREATED);
body_json(r).await["collection_uid"].as_str().unwrap().to_string()
};
// Create a fixture entry (title "Test Entry" is searchable).
let entry = make_test_entry(&archive_path);
// Add entry to CollA with visibility_bits=2 (users-only in CollA).
let add_a = app(registry.clone(), auth_path.clone())
.oneshot(Request::builder().method("POST")
.uri(format!("/api/archives/test/collections/{coll_a_uid}/entries"))
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(json_body(&serde_json::json!({
"entry_uid": entry.entry_uid, "visibility_bits": 2
}))).unwrap()).await.unwrap();
assert_eq!(add_a.status(), StatusCode::NO_CONTENT);
// Add same entry to CollB with visibility_bits=3 (public in CollB).
let add_b = app(registry.clone(), auth_path.clone())
.oneshot(Request::builder().method("POST")
.uri(format!("/api/archives/test/collections/{coll_b_uid}/entries"))
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(json_body(&serde_json::json!({
"entry_uid": entry.entry_uid, "visibility_bits": 3
}))).unwrap()).await.unwrap();
assert_eq!(add_b.status(), StatusCode::NO_CONTENT);
// ── list_entries scoping ──────────────────────────────────────────
// Guest list CollA → 0 results (users-only there).
let list_a = app(registry.clone(), auth_path.clone())
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries?collection={coll_a_uid}"))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(list_a.status(), StatusCode::OK);
let body_a = body_json(list_a).await;
assert_eq!(body_a.as_array().unwrap().len(), 0,
"guest must not see users-only entry in CollA via list");
// Guest list CollB → 1 result (public there).
let list_b = app(registry.clone(), auth_path.clone())
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries?collection={coll_b_uid}"))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(list_b.status(), StatusCode::OK);
let body_b = body_json(list_b).await;
assert_eq!(body_b.as_array().unwrap().len(), 1,
"guest should see public entry in CollB via list");
// ── search_entries scoping ────────────────────────────────────────
// Guest search CollA → 0 results (entry is users-only there, not leaked by CollB).
let search_a = app(registry.clone(), auth_path.clone())
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries/search?collection={coll_a_uid}&q=Test"))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(search_a.status(), StatusCode::OK);
let srch_a = body_json(search_a).await;
assert_eq!(srch_a.as_array().unwrap().len(), 0,
"guest search in CollA must not return entry visible only in CollB");
// Guest search CollB → 1 result (public there).
let search_b = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries/search?collection={coll_b_uid}&q=Test"))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(search_b.status(), StatusCode::OK);
let srch_b = body_json(search_b).await;
assert_eq!(srch_b.as_array().unwrap().len(), 1,
"guest search in CollB must return the public entry");
}
#[tokio::test] #[tokio::test]
async fn patch_entry_title_requires_auth() { async fn patch_entry_title_requires_auth() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@ -5418,4 +5788,336 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
// ── Public preview contract tests ──────────────────────────────────────────
// entry_detail and serve_artifact are open to guests iff the entry lives in a
// public collection (requires_auth=false) with visibility_bits & ROLE_GUEST (1).
// list_entry_children is open to guests iff the parent passes the same check.
// Children inherit public accessibility from their parent.
/// Build a collection via API; return its uid.
async fn api_make_collection(
registry: ServerRegistry, auth_path: std::path::PathBuf,
session: &str, name: &str, slug: &str, vis: u32, requires_auth: bool,
) -> String {
let r = app(registry, auth_path)
.oneshot(Request::builder().method("POST")
.uri("/api/archives/test/collections")
.header("content-type", "application/json").header("cookie", session)
.body(json_body(&serde_json::json!({
"name": name, "slug": slug,
"default_visibility_bits": vis, "requires_auth": requires_auth
}))).unwrap()).await.unwrap();
assert_eq!(r.status(), StatusCode::CREATED);
body_json(r).await["collection_uid"].as_str().unwrap().to_string()
}
/// Add an entry to a collection via API.
async fn api_add_to_coll(
registry: ServerRegistry, auth_path: std::path::PathBuf,
session: &str, coll_uid: &str, entry_uid: &str, vis: u32,
) {
let r = app(registry, auth_path)
.oneshot(Request::builder().method("POST")
.uri(format!("/api/archives/test/collections/{coll_uid}/entries"))
.header("content-type", "application/json").header("cookie", session)
.body(json_body(&serde_json::json!({
"entry_uid": entry_uid, "visibility_bits": vis
}))).unwrap()).await.unwrap();
assert_eq!(r.status(), StatusCode::NO_CONTENT);
}
/// Create an entry with one artifact file; returns (entry, artifact uri component).
fn make_entry_with_artifact(
archive_path: &std::path::Path,
store_path: &std::path::Path,
) -> archivr_core::database::ArchivedEntry {
let conn = database::open_or_initialize(archive_path).unwrap();
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,
Some("https://example.com/artitest"), "https://example.com/artitest",
).unwrap();
let entry = database::create_archived_entry(&conn, &database::NewEntry {
source_identity_id: si, 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("Artifact Test".to_string()), visibility: "private".to_string(),
representation_kind: "html".to_string(),
source_metadata_json: "{}".to_string(), display_metadata_json: None,
}).unwrap();
let relpath = "raw/pp/qq/test.html";
let file_dir = store_path.join("raw").join("pp").join("qq");
std::fs::create_dir_all(&file_dir).unwrap();
std::fs::write(file_dir.join("test.html"), b"<html>pub</html>").unwrap();
let blob_id = database::upsert_blob(&conn, &database::BlobRecord {
sha256: "cccc3333dddd4444eeee5555ffff6666cccc3333dddd4444eeee5555ffff6666".to_string(),
byte_size: 16, mime_type: Some("text/html".to_string()),
extension: Some("html".to_string()), raw_relpath: 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: relpath.to_string(),
blob_id: Some(blob_id), logical_path: None, metadata_json: None,
}).unwrap();
entry
}
#[tokio::test]
async fn guest_entry_detail_public_entry_succeeds() {
// Entry in a requires_auth=false collection with vis=3 (ROLE_GUEST) → 200 without cookie.
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let session = make_test_session(&auth_path);
let entry = make_test_entry(&archive_path);
let coll = api_make_collection(
registry.clone(), auth_path.clone(), &session,
"PubD", "pub-d", 3, false,
).await;
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &entry.entry_uid, 3).await;
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries/{}", entry.entry_uid))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn guest_entry_detail_users_only_entry_blocked() {
// Entry in requires_auth=false collection but visibility_bits=2 (no ROLE_GUEST) → 401.
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let session = make_test_session(&auth_path);
let entry = make_test_entry(&archive_path);
let coll = api_make_collection(
registry.clone(), auth_path.clone(), &session,
"SemiPub", "semi-pub", 3, false,
).await;
// Add with visibility_bits=2: users-only, ROLE_GUEST bit not set.
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &entry.entry_uid, 2).await;
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries/{}", entry.entry_uid))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn guest_entry_detail_auth_required_collection_blocked() {
// Entry in requires_auth=true collection even with guest visibility bits → 401.
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let session = make_test_session(&auth_path);
let entry = make_test_entry(&archive_path);
let coll = api_make_collection(
registry.clone(), auth_path.clone(), &session,
"AuthColl", "auth-coll", 3, true,
).await;
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &entry.entry_uid, 3).await;
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries/{}", entry.entry_uid))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn guest_serve_artifact_public_entry_succeeds() {
// Artifact of an entry in a public collection is accessible without cookie.
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 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 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 session = make_test_session(&auth_path);
let entry = make_entry_with_artifact(&paths.archive_path, &store_path);
let coll = api_make_collection(
registry.clone(), auth_path.clone(), &session,
"PubArt", "pub-art", 3, false,
).await;
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &entry.entry_uid, 3).await;
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries/{}/artifacts/0", entry.entry_uid))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn guest_serve_artifact_users_only_entry_blocked() {
// Artifact of a users-only entry is blocked for guests even in a public collection.
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 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 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 session = make_test_session(&auth_path);
let entry = make_entry_with_artifact(&paths.archive_path, &store_path);
let coll = api_make_collection(
registry.clone(), auth_path.clone(), &session,
"PubArt2", "pub-art2", 3, false,
).await;
// visibility_bits=2: users-only, guest cannot see.
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &entry.entry_uid, 2).await;
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries/{}/artifacts/0", entry.entry_uid))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn guest_list_children_public_parent_succeeds() {
// Children of a public parent are accessible to guests.
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let session = make_test_session(&auth_path);
// Create parent entry.
let parent = make_test_entry(&archive_path);
// Create child entry referencing parent.
let child = {
let conn = database::open_or_initialize(&archive_path).unwrap();
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,
Some("https://example.com/child"), "https://example.com/child",
).unwrap();
database::create_archived_entry(&conn, &database::NewEntry {
source_identity_id: si, archive_run_id: run.id,
parent_entry_id: Some(parent.id), root_entry_id: Some(parent.id),
created_by_user_id: user_id, owned_by_user_id: user_id,
source_kind: "web".to_string(), entity_kind: "page".to_string(),
title: Some("Child Entry".to_string()), visibility: "private".to_string(),
representation_kind: "html".to_string(),
source_metadata_json: "{}".to_string(), display_metadata_json: None,
}).unwrap()
};
// Put parent in a public collection with guest visibility.
let coll = api_make_collection(
registry.clone(), auth_path.clone(), &session, "PubParent", "pub-parent", 3, false,
).await;
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &parent.entry_uid, 3).await;
// Guest requests children of the public parent — must see the child, not just 200.
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries/{}/children", parent.entry_uid))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let uids: Vec<&str> = body.as_array().unwrap()
.iter().map(|e| e["entry_uid"].as_str().unwrap()).collect();
assert!(uids.contains(&child.entry_uid.as_str()),
"guest must see child uid in children response; got {:?}", uids);
}
#[tokio::test]
async fn guest_list_children_private_parent_blocked() {
// Children of a non-public parent are blocked for guests.
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
// Parent only in _default_ (requires_auth=true by default); not in any public collection.
let parent = make_test_entry(&archive_path);
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries/{}/children", parent.entry_uid))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn guest_child_entry_detail_via_public_parent_succeeds() {
// A child entry inherits public accessibility from its parent's collection membership.
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let session = make_test_session(&auth_path);
let parent = make_test_entry(&archive_path);
let child = {
let conn = database::open_or_initialize(&archive_path).unwrap();
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,
Some("https://example.com/child2"), "https://example.com/child2",
).unwrap();
database::create_archived_entry(&conn, &database::NewEntry {
source_identity_id: si, archive_run_id: run.id,
parent_entry_id: Some(parent.id), root_entry_id: Some(parent.id),
created_by_user_id: user_id, owned_by_user_id: user_id,
source_kind: "web".to_string(), entity_kind: "page".to_string(),
title: Some("Child Detail Test".to_string()), visibility: "private".to_string(),
representation_kind: "html".to_string(),
source_metadata_json: "{}".to_string(), display_metadata_json: None,
}).unwrap()
};
// Parent in a public collection with guest visibility.
let coll = api_make_collection(
registry.clone(), auth_path.clone(), &session, "PubParent2", "pub-parent2", 3, false,
).await;
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &parent.entry_uid, 3).await;
// Child detail accessible to guest via parent's public membership.
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri(format!("/api/archives/test/entries/{}", child.entry_uid))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn guest_list_collections_returns_only_public() {
// GET /api/archives/:id/collections for a guest must omit auth-required collections
// so their names are never leaked to unsigned users.
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session = make_test_session(&auth_path);
// Create one public and one auth-required collection.
let _ = api_make_collection(
registry.clone(), auth_path.clone(), &session,
"PublicColl", "public-coll", 3, false,
).await;
let auth_coll_name = "SecretColl";
let _ = api_make_collection(
registry.clone(), auth_path.clone(), &session,
auth_coll_name, "secret-coll", 2, true,
).await;
// Guest fetches the collection list.
let resp = app(registry, auth_path)
.oneshot(Request::builder()
.uri("/api/archives/test/collections")
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let names: Vec<&str> = body.as_array().unwrap()
.iter().map(|c| c["name"].as_str().unwrap()).collect();
assert!(!names.contains(&auth_coll_name),
"auth-required collection name must not be returned to guests; got {:?}", names);
assert!(names.contains(&"PublicColl"),
"public collection must be returned to guests; got {:?}", names);
}
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title> <title>Archivr</title>
<script type="module" crossorigin src="/assets/index-D_BVhPjQ.js"></script> <script type="module" crossorigin src="/assets/index-B67momER.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-5jBL9-i7.css"> <link rel="stylesheet" crossorigin href="/assets/index-DicH9MNh.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo, createContext } from 'react' import { useState, useEffect, useCallback, useRef, useMemo, createContext } from 'react'
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe, fetchEntryDetail } from './api' import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe, fetchEntryDetail, listCollections } from './api'
import LoginPage from './components/LoginPage.jsx' import LoginPage from './components/LoginPage.jsx'
import SetupPage from './components/SetupPage.jsx' import SetupPage from './components/SetupPage.jsx'
@ -20,15 +20,15 @@ import ToastStack from './components/ToastStack'
export const AuthContext = createContext(null); export const AuthContext = createContext(null);
const VIEWS = ['archive','tags','collections','runs','admin','settings']
const SETTINGS_TABS = ['profile','tokens','instance','cookies','extensions','storage']
// Detect /preview/:archiveId/:entryUid at load time (static — no navigation) // Detect /preview/:archiveId/:entryUid at load time (static — no navigation)
const PREVIEW_ROUTE = (() => { const PREVIEW_ROUTE = (() => {
const m = window.location.pathname.match(/^\/preview\/([^/]+)\/([^/]+)/) const m = window.location.pathname.match(/^\/preview\/([^/]+)\/([^/]+)/)
return m ? { archiveId: m[1], entryUid: m[2] } : null return m ? { archiveId: m[1], entryUid: m[2] } : null
})() })()
const VIEWS = ['archive','tags','collections','runs','admin','settings']
const SETTINGS_TABS = ['profile','tokens','instance','cookies','extensions','storage']
function parseLocation() { function parseLocation() {
const parts = window.location.pathname.split('/').filter(Boolean) const parts = window.location.pathname.split('/').filter(Boolean)
const view = VIEWS.includes(parts[0]) ? parts[0] : 'archive' const view = VIEWS.includes(parts[0]) ? parts[0] : 'archive'
@ -37,7 +37,14 @@ function parseLocation() {
const q = params.get('q') ?? '' const q = params.get('q') ?? ''
const tag = view === 'archive' ? (params.get('tag') ?? null) : null const tag = view === 'archive' ? (params.get('tag') ?? null) : null
const entry = view === 'archive' ? (params.get('entry') ?? null) : null const entry = view === 'archive' ? (params.get('entry') ?? null) : null
return { view, settingsTab, q, tag, entry } // 'main' is the user-facing alias for the default collection; normalize to null
// so the collection dropdown shows "All entries" and no ?collection param is needed.
const rawColl = view === 'archive' ? (params.get('collection') ?? null) : null
const collection = rawColl === 'main' ? null : rawColl
// ?archive=<id> pins a specific archive for public-collection sharing in multi-archive
// setups; without it the bootstrap tries archives[0] which may be the wrong one.
const archive = params.get('archive') ?? null
return { view, settingsTab, q, tag, entry, collection, archive }
} }
function locationPath(view, settingsTab) { function locationPath(view, settingsTab) {
@ -55,22 +62,48 @@ export default function App() {
const needsSetup = await checkSetup(); const needsSetup = await checkSetup();
if (needsSetup) { setAuthState('setup'); return; } if (needsSetup) { setAuthState('setup'); return; }
const user = await fetchMe(); const user = await fetchMe();
if (!user) { setAuthState('login'); return; } if (!user) {
// Before showing login: check whether the active collection is publicly accessible.
// fetchArchives is unauthenticated. ?archive=<id> pins the archive for multi-archive
// setups; without it we try archives[0] (works for the common single-archive case).
const { collection, archive: archiveParam } = parseLocation();
try {
const archiveList = await fetchArchives();
const aid = archiveParam
? (archiveList.find(a => a.id === archiveParam)?.id ?? archiveList[0]?.id)
: archiveList[0]?.id;
if (aid) {
await fetchEntries(aid, collection); // 401 if collection requires auth
setArchives(archiveList);
setArchiveId(aid);
setSelectedCollectionUid(collection);
setAuthState('authenticated');
return;
}
} catch { /* not public or error — fall through to login */ }
setAuthState('login');
return;
}
setCurrentUser(user); setCurrentUser(user);
setAuthState('authenticated'); setAuthState('authenticated');
})(); })();
}, []); }, []);
// Suppress auth:expired redirect when browsing as a public guest (no currentUser).
// Without this, incidental 401s on auth-required tabs would kick guests to login.
useEffect(() => { useEffect(() => {
const handler = () => { setCurrentUser(null); setAuthState('login'); }; const handler = () => {
if (!currentUser) return;
setCurrentUser(null); setAuthState('login');
};
window.addEventListener('auth:expired', handler); window.addEventListener('auth:expired', handler);
return () => window.removeEventListener('auth:expired', handler); return () => window.removeEventListener('auth:expired', handler);
}, []); }, [currentUser]);
// Sync URL → state on back/forward // Sync URL → state on back/forward
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => {
const { view, settingsTab, q, tag, entry } = parseLocation() const { view, settingsTab, q, tag, entry, collection } = parseLocation()
setView(view) setView(view)
setSettingsTab(settingsTab) setSettingsTab(settingsTab)
setSearchQuery(q) setSearchQuery(q)
@ -78,6 +111,7 @@ export default function App() {
setSelectedEntryUid(entry) setSelectedEntryUid(entry)
setSelectedEntry(null) setSelectedEntry(null)
setSelectedUids(entry ? new Set([entry]) : new Set()) setSelectedUids(entry ? new Set([entry]) : new Set())
setSelectedCollectionUid(collection)
} }
window.addEventListener('popstate', handler) window.addEventListener('popstate', handler)
return () => window.removeEventListener('popstate', handler) return () => window.removeEventListener('popstate', handler)
@ -85,6 +119,8 @@ export default function App() {
const [archives, setArchives] = useState([]) const [archives, setArchives] = useState([])
const [archiveId, setArchiveId] = useState(null) const [archiveId, setArchiveId] = useState(null)
const [selectedCollectionUid, setSelectedCollectionUid] = useState(() => parseLocation().collection)
const [collections, setCollections] = useState([])
const [entries, setEntries] = useState([]) const [entries, setEntries] = useState([])
const [deletedUids, setDeletedUids] = useState(() => new Set()) const [deletedUids, setDeletedUids] = useState(() => new Set())
const [selectedEntryUid, setSelectedEntryUid] = useState(() => parseLocation().entry) const [selectedEntryUid, setSelectedEntryUid] = useState(() => parseLocation().entry)
@ -140,7 +176,8 @@ export default function App() {
const humanizeTags = currentUser?.humanize_slugs ?? false; const humanizeTags = currentUser?.humanize_slugs ?? false;
// Fetch entry detail whenever selected entry changes // Fetch entry detail whenever selected entry changes.
// The backend gates by public accessibility, so guests get detail for public entries too.
useEffect(() => { useEffect(() => {
const seq = ++detailSeqRef.current const seq = ++detailSeqRef.current
setEntryDetail(null) setEntryDetail(null)
@ -156,15 +193,15 @@ export default function App() {
sessionStorage.setItem('captureDialogOpen', captureDialogOpen) sessionStorage.setItem('captureDialogOpen', captureDialogOpen)
}, [captureDialogOpen]) }, [captureDialogOpen])
const loadEntries = useCallback(async (aid, q, tag) => { const loadEntries = useCallback(async (aid, q, tag, collUid) => {
if (!aid) return if (!aid) return
setSearchBusy(true) setSearchBusy(true)
try { try {
let results let results
if (q || tag) { if (q || tag) {
results = await searchEntries(aid, q, tag) results = await searchEntries(aid, q, tag, collUid)
} else { } else {
results = await fetchEntries(aid) results = await fetchEntries(aid, collUid)
} }
setEntries(results) setEntries(results)
// Prune multi-selection to only entries still visible after load. // Prune multi-selection to only entries still visible after load.
@ -184,51 +221,57 @@ export default function App() {
} }
}, []) }, [])
// Load archives once authenticated (re-runs when authState changes so // Load archives once authenticated. Public-session auth also pre-sets archiveId in the auth
// it triggers correctly after first login or a session refresh). // useEffect; this effect handles the normal authenticated path and page refreshes.
useEffect(() => { useEffect(() => {
if (authState !== 'authenticated') return if (authState !== 'authenticated') return
if (archiveId) return // already set (public-session path set it synchronously)
fetchArchives().then(list => { fetchArchives().then(list => {
setArchives(list) setArchives(list)
if (list.length > 0) { if (list.length > 0) {
const first = list[0].id const { archive: archiveParam } = parseLocation()
setArchiveId(first) const preferred = archiveParam ? list.find(a => a.id === archiveParam) : null
setArchiveId(preferred?.id ?? list[0].id)
} }
}) })
}, [authState]) }, [authState, archiveId])
// Archive change: parallel load entries + runs + tags // Archive change: parallel load entries + runs + tags
// Archive change: load entries + runs/tags (runs/tags skipped for public guests).
useEffect(() => { useEffect(() => {
if (!archiveId) return if (!archiveId) return
const isGuest = !currentUser
if (firstArchiveLoad.current) { if (firstArchiveLoad.current) {
// First load: URL-initialized filters are already in state; the debounced
// search and tagFilter effects will call loadEntries with the right values.
firstArchiveLoad.current = false firstArchiveLoad.current = false
Promise.all([ const tasks = []
fetchRuns(archiveId).then(setRuns), if (!isGuest) {
fetchTags(archiveId).then(setTagNodes), tasks.push(fetchRuns(archiveId).then(setRuns))
]) tasks.push(fetchTags(archiveId).then(setTagNodes))
}
if (tasks.length) Promise.all(tasks)
return return
} }
setTagFilter(null) setTagFilter(null)
setSelectedEntry(null) setSelectedEntry(null)
setSelectedEntryUid(null) setSelectedEntryUid(null)
setSelectedUids(new Set()) setSelectedUids(new Set())
Promise.all([ setSelectedCollectionUid(null)
loadEntries(archiveId, '', null), const tasks = [loadEntries(archiveId, '', null, null)]
fetchRuns(archiveId).then(setRuns), if (!isGuest) {
fetchTags(archiveId).then(setTagNodes), tasks.push(fetchRuns(archiveId).then(setRuns))
]) tasks.push(fetchTags(archiveId).then(setTagNodes))
}, [archiveId]) // intentionally not including loadEntries to avoid re-running on its recreation }
Promise.all(tasks)
}, [archiveId, currentUser]) // currentUser distinguishes guest vs authenticated
// Debounced search // Debounced search — scoped to active collection
useEffect(() => { useEffect(() => {
if (archiveId === null) return if (archiveId === null) return
const timer = setTimeout(() => { const timer = setTimeout(() => {
loadEntries(archiveId, searchQuery, tagFilter) loadEntries(archiveId, searchQuery, tagFilter, selectedCollectionUid)
}, 300) }, 300)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [searchQuery, archiveId]) // tagFilter handled separately below }, [searchQuery, archiveId, selectedCollectionUid])
// Tag filter applied: switch to archive view and reload. // Tag filter applied: switch to archive view and reload.
// Only reset view when tagFilter is non-null; archive change alone (tagFilter=null) // Only reset view when tagFilter is non-null; archive change alone (tagFilter=null)
@ -236,13 +279,47 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (archiveId === null) return if (archiveId === null) return
if (tagFilter !== null) setView('archive') if (tagFilter !== null) setView('archive')
loadEntries(archiveId, searchQuery, tagFilter) loadEntries(archiveId, searchQuery, tagFilter, selectedCollectionUid)
}, [tagFilter, archiveId]) // intentional: searchQuery excluded to avoid double-fire }, [tagFilter, archiveId, selectedCollectionUid])
const handleArchiveChange = useCallback((id) => { const handleArchiveChange = useCallback((id) => {
setArchiveId(id) setArchiveId(id)
setSelectedCollectionUid(null)
// Update URL so refresh/share reopens the correct archive.
const params = new URLSearchParams(window.location.search)
params.set('archive', id)
params.delete('collection')
const qs = params.toString()
history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : ''))
}, []) }, [])
// Load collection list for the switcher — listCollections is now public so guests get it too.
useEffect(() => {
if (!archiveId) { setCollections([]); return }
listCollections(archiveId).then(setCollections).catch(() => setCollections([]))
}, [archiveId])
const handleCollectionChange = useCallback((uid) => {
// Resolve which collection record applies: named uid or the _default_ collection.
// If the guest would land on an auth-required collection, redirect to login.
if (!currentUser) {
const coll = uid
? collections.find(c => c.collection_uid === uid)
: collections.find(c => c.slug === '_default_')
if (coll?.requires_auth) { setAuthState('login'); return }
}
setSelectedCollectionUid(uid)
setSelectedEntry(null)
setSelectedEntryUid(null)
setSelectedUids(new Set())
loadEntries(archiveId, searchQuery, tagFilter, uid)
const params = new URLSearchParams(window.location.search)
if (uid) { params.set('collection', uid); params.set('archive', archiveId) }
else { params.delete('collection'); if (archiveId) params.set('archive', archiveId) }
const qs = params.toString()
history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : ''))
}, [archiveId, searchQuery, tagFilter, currentUser, collections, loadEntries])
const handleViewChange = useCallback((name) => { const handleViewChange = useCallback((name) => {
setView(name) setView(name)
if (name === 'tags' && archiveId) { if (name === 'tags' && archiveId) {
@ -380,8 +457,8 @@ export default function App() {
setSelectedEntryUid(prev => prev === entryUid ? null : prev) setSelectedEntryUid(prev => prev === entryUid ? null : prev)
setSelectedUids(prev => { const n = new Set(prev); n.delete(entryUid); return n }) setSelectedUids(prev => { const n = new Set(prev); n.delete(entryUid); return n })
// Child delete: parent row's child_count/size are stale — reload after state updates. // Child delete: parent row's child_count/size are stale — reload after state updates.
if (!isRoot) loadEntries(archiveId, searchQuery, tagFilter) if (!isRoot) loadEntries(archiveId, searchQuery, tagFilter, selectedCollectionUid)
}, [entries, archiveId, searchQuery, tagFilter, loadEntries]) }, [entries, archiveId, searchQuery, tagFilter, selectedCollectionUid, loadEntries])
const handleBulkDeleted = useCallback((uids) => { const handleBulkDeleted = useCallback((uids) => {
const rootUids = new Set(entries.map(e => e.entry_uid)) const rootUids = new Set(entries.map(e => e.entry_uid))
@ -391,8 +468,8 @@ export default function App() {
setSelectedUids(new Set()) setSelectedUids(new Set())
setSelectedEntry(null) setSelectedEntry(null)
setSelectedEntryUid(null) setSelectedEntryUid(null)
if (hasChildDelete) loadEntries(archiveId, searchQuery, tagFilter) if (hasChildDelete) loadEntries(archiveId, searchQuery, tagFilter, selectedCollectionUid)
}, [entries, archiveId, searchQuery, tagFilter, loadEntries]) }, [entries, archiveId, searchQuery, tagFilter, selectedCollectionUid, loadEntries])
// Auto-snap: drive selectedEntryUid from selectedUids so URL sync and detail // Auto-snap: drive selectedEntryUid from selectedUids so URL sync and detail
// panel stay correct. size >= 2 clears single-entry state (bulk panel takes over). // panel stay correct. size >= 2 clears single-entry state (bulk panel takes over).
@ -424,17 +501,27 @@ export default function App() {
}, [entries, selectedEntryUid, selectedEntry]) }, [entries, selectedEntryUid, selectedEntry])
// Sync search params → URL via replaceState (no new history entry). // Sync search params → URL via replaceState (no new history entry).
// ?archive=<id> is preserved whenever it was already in the URL (sticky) so that
// multi-archive public links (?archive=other) survive navigation to the default collection.
useEffect(() => { useEffect(() => {
if (PREVIEW_ROUTE) return if (PREVIEW_ROUTE) return
const existingArchive = new URLSearchParams(window.location.search).get('archive')
const params = new URLSearchParams() const params = new URLSearchParams()
if (searchQuery) params.set('q', searchQuery) if (searchQuery) params.set('q', searchQuery)
if (view === 'archive' && tagFilter) params.set('tag', tagFilter) if (view === 'archive' && tagFilter) params.set('tag', tagFilter)
if (view === 'archive' && selectedEntryUid) params.set('entry', selectedEntryUid) if (view === 'archive' && selectedEntryUid) params.set('entry', selectedEntryUid)
if (view === 'archive' && selectedCollectionUid) {
params.set('collection', selectedCollectionUid)
if (archiveId) params.set('archive', archiveId)
} else if (existingArchive && archiveId) {
// Preserve ?archive when already in the URL (e.g. default collection public link)
params.set('archive', archiveId)
}
const qs = params.toString() const qs = params.toString()
const url = window.location.pathname + (qs ? '?' + qs : '') const url = window.location.pathname + (qs ? '?' + qs : '')
const current = window.location.pathname + window.location.search const current = window.location.pathname + window.location.search
if (current !== url) history.replaceState(null, '', url) if (current !== url) history.replaceState(null, '', url)
}, [searchQuery, tagFilter, selectedEntryUid, view]) }, [searchQuery, tagFilter, selectedEntryUid, selectedCollectionUid, archiveId, view])
// ⌘K / Ctrl+K / /: focus the search input, switching to archive view first if needed. // ⌘K / Ctrl+K / /: focus the search input, switching to archive view first if needed.
useEffect(() => { useEffect(() => {
@ -533,10 +620,10 @@ export default function App() {
const handleCaptured = useCallback(() => { const handleCaptured = useCallback(() => {
if (!archiveId) return if (!archiveId) return
return Promise.allSettled([ return Promise.allSettled([
loadEntries(archiveId, searchQuery, tagFilter), loadEntries(archiveId, searchQuery, tagFilter, selectedCollectionUid),
fetchRuns(archiveId).then(setRuns), fetchRuns(archiveId).then(setRuns),
]) ])
}, [archiveId, searchQuery, tagFilter, loadEntries]) }, [archiveId, searchQuery, tagFilter, selectedCollectionUid, loadEntries])
const handleToast = useCallback((text, locator, type = 'error', headline = null) => { const handleToast = useCallback((text, locator, type = 'error', headline = null) => {
// Only suppress per-item ublock/cookie warnings (those carry a locator). // Only suppress per-item ublock/cookie warnings (those carry a locator).
@ -604,10 +691,10 @@ export default function App() {
return () => document.body.classList.remove('has-audio-bar') return () => document.body.classList.remove('has-audio-bar')
}, [currentAudio]) }, [currentAudio])
if (PREVIEW_ROUTE) return <PreviewPage archiveId={PREVIEW_ROUTE.archiveId} entryUid={PREVIEW_ROUTE.entryUid} />;
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>; if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;
if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />; if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />; if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;
if (PREVIEW_ROUTE) return <PreviewPage archiveId={PREVIEW_ROUTE.archiveId} entryUid={PREVIEW_ROUTE.entryUid} />;
return ( return (
<AuthContext.Provider value={{ currentUser, setCurrentUser }}> <AuthContext.Provider value={{ currentUser, setCurrentUser }}>
@ -619,6 +706,11 @@ export default function App() {
view={view} view={view}
onViewChange={handleViewChange} onViewChange={handleViewChange}
onCaptureClick={handleCaptureClick} onCaptureClick={handleCaptureClick}
collections={collections}
selectedCollectionUid={selectedCollectionUid}
onCollectionChange={handleCollectionChange}
isPublicSession={!currentUser}
onSignInClick={() => setAuthState('login')}
/> />
<main className="app-shell"> <main className="app-shell">
<div className="workspace"> <div className="workspace">
@ -660,6 +752,7 @@ export default function App() {
archiveId={archiveId} archiveId={archiveId}
pendingCaptures={pendingCaptures} pendingCaptures={pendingCaptures}
deletedUids={deletedUids} deletedUids={deletedUids}
isPublicSession={!currentUser}
/> />
)} )}
{view === 'runs' && <RunsView runs={runs} />} {view === 'runs' && <RunsView runs={runs} />}
@ -700,6 +793,7 @@ export default function App() {
onDetailRefresh={handleDetailRefresh} onDetailRefresh={handleDetailRefresh}
onOpenPreview={handleOpenPreview} onOpenPreview={handleOpenPreview}
onPlay={handlePlay} onPlay={handlePlay}
isPublicSession={!currentUser}
/> />
</main> </main>
{previewEntryUid && selectedEntry && selectedEntry.entry_uid === previewEntryUid && ( {previewEntryUid && selectedEntry && selectedEntry.entry_uid === previewEntryUid && (

View file

@ -10,14 +10,18 @@ export async function fetchArchives() {
return getJson("/api/archives"); return getJson("/api/archives");
} }
export async function fetchEntries(archiveId) { export async function fetchEntries(archiveId, collectionUid = null) {
return getJson(`/api/archives/${archiveId}/entries`); const url = collectionUid
? `/api/archives/${archiveId}/entries?collection=${encodeURIComponent(collectionUid)}`
: `/api/archives/${archiveId}/entries`
return getJson(url)
} }
export async function searchEntries(archiveId, q, tag) { export async function searchEntries(archiveId, q, tag, collectionUid = null) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (q) params.set("q", q); if (q) params.set("q", q);
if (tag) params.set("tag", tag); if (tag) params.set("tag", tag);
if (collectionUid) params.set("collection", collectionUid);
return getJson(`/api/archives/${archiveId}/entries/search?${params}`); return getJson(`/api/archives/${archiveId}/entries/search?${params}`);
} }
@ -358,11 +362,11 @@ export async function listCollections(archiveId) {
return getJson(`/api/archives/${archiveId}/collections`); return getJson(`/api/archives/${archiveId}/collections`);
} }
export async function createCollection(archiveId, name, slug, defaultVisibilityBits = 2) { export async function createCollection(archiveId, name, slug, defaultVisibilityBits = 2, requiresAuth = true) {
const res = await fetch(`/api/archives/${archiveId}/collections`, { const res = await fetch(`/api/archives/${archiveId}/collections`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, slug, default_visibility_bits: defaultVisibilityBits }), body: JSON.stringify({ name, slug, default_visibility_bits: defaultVisibilityBits, requires_auth: requiresAuth }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText })); const err = await res.json().catch(() => ({ error: res.statusText }));

View file

@ -26,6 +26,7 @@ export default function CollectionsView({ archiveId }) {
const [newSlug, setNewSlug] = useState('') const [newSlug, setNewSlug] = useState('')
const [newVis, setNewVis] = useState(2) const [newVis, setNewVis] = useState(2)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [newRequiresAuth, setNewRequiresAuth] = useState(true)
const [createError, setCreateError] = useState(null) const [createError, setCreateError] = useState(null)
// Add-entry form // Add-entry form
@ -75,6 +76,9 @@ export default function CollectionsView({ archiveId }) {
// Auto-focus rename input // Auto-focus rename input
useEffect(() => { if (renaming && renameRef.current) renameRef.current.focus() }, [renaming]) useEffect(() => { if (renaming && renameRef.current) renameRef.current.focus() }, [renaming])
useEffect(() => {
if (collDetail != null) setAddVis(collDetail.default_visibility_bits)
}, [collDetail])
async function handleCreate(e) { async function handleCreate(e) {
e.preventDefault() e.preventDefault()
@ -84,10 +88,11 @@ export default function CollectionsView({ archiveId }) {
setCreating(true) setCreating(true)
setCreateError(null) setCreateError(null)
try { try {
const coll = await createCollection(archiveId, name, slug, newVis) const coll = await createCollection(archiveId, name, slug, newVis, newRequiresAuth)
setNewName('') setNewName('')
setNewSlug('') setNewSlug('')
setNewVis(2) setNewVis(2)
setNewRequiresAuth(true)
await refreshList() await refreshList()
setSelectedUid(coll.collection_uid) setSelectedUid(coll.collection_uid)
} catch (err) { } catch (err) {
@ -122,6 +127,18 @@ export default function CollectionsView({ archiveId }) {
} }
} }
async function handleAuthChange(val) {
if (!selected) return
try {
await updateCollection(archiveId, selected.collection_uid, { requires_auth: val })
await refreshList()
setCollDetail(d => d ? { ...d, requires_auth: val } : d)
} catch (e) {
setError(e.message)
}
}
async function handleDelete() { async function handleDelete() {
if (!selected) return if (!selected) return
if (!window.confirm(`Delete collection "${selected.name}"? Entries will not be deleted.`)) return if (!window.confirm(`Delete collection "${selected.name}"? Entries will not be deleted.`)) return
@ -238,7 +255,7 @@ export default function CollectionsView({ archiveId }) {
{/* Visibility */} {/* Visibility */}
<div className="coll-detail-vis"> <div className="coll-detail-vis">
<span className="coll-vis-label">Default visibility</span> <span className="coll-vis-label">Entries' default visibility</span>
<select <select
className="coll-vis-select" className="coll-vis-select"
value={collDetail?.default_visibility_bits ?? selected.default_visibility_bits} value={collDetail?.default_visibility_bits ?? selected.default_visibility_bits}
@ -248,6 +265,19 @@ export default function CollectionsView({ archiveId }) {
</select> </select>
</div> </div>
{/* Auth requirement */}
<div className="coll-detail-vis">
<span className="coll-vis-label">Collection access</span>
<label className="coll-auth-label">
<input
type="checkbox"
checked={!!(collDetail?.requires_auth ?? selected.requires_auth)}
onChange={e => handleAuthChange(e.target.checked)}
/>
{' Require authentication to view'}
</label>
</div>
{/* Entries */} {/* Entries */}
<div className="coll-entries-section"> <div className="coll-entries-section">
<div className="coll-section-heading">Entries</div> <div className="coll-section-heading">Entries</div>
@ -351,12 +381,22 @@ export default function CollectionsView({ archiveId }) {
/> />
</div> </div>
<div className="form-field"> <div className="form-field">
<label className="form-label" htmlFor="coll-vis">Default visibility</label> <label className="form-label" htmlFor="coll-vis">Entries' default visibility</label>
<select className="capture-input" id="coll-vis" style={{ height: 42 }} <select className="capture-input" id="coll-vis" style={{ height: 42 }}
value={newVis} onChange={e => setNewVis(Number(e.target.value))}> value={newVis} onChange={e => setNewVis(Number(e.target.value))}>
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)} {VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select> </select>
</div> </div>
<div className="form-field">
<label className="form-label">
<input
type="checkbox"
checked={newRequiresAuth}
onChange={e => setNewRequiresAuth(e.target.checked)}
/>
{' Require authentication to view'}
</label>
</div>
{createError && <div className="collections-error">{createError}</div>} {createError && <div className="collections-error">{createError}</div>}
<button className="btn-primary" type="submit" disabled={creating}> <button className="btn-primary" type="submit" disabled={creating}>
{creating ? 'Creating\u2026' : 'Create collection'} {creating ? 'Creating\u2026' : 'Create collection'}

View file

@ -11,7 +11,7 @@ const ExternalIcon = () => (
</svg> </svg>
) )
export default function ContextRail({ archiveId, selectedEntry, selectedUids, selectedEntries, detail, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, onBulkDeleted, humanizeTags, onDetailRefresh, onOpenPreview, onPlay }) { export default function ContextRail({ archiveId, selectedEntry, selectedUids, selectedEntries, detail, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, onBulkDeleted, humanizeTags, onDetailRefresh, onOpenPreview, onPlay, isPublicSession }) {
const [tags, setTags] = useState([]) const [tags, setTags] = useState([])
const [assignInput, setAssignInput] = useState('') const [assignInput, setAssignInput] = useState('')
const [entryCollections, setEntryCollections] = useState([]) const [entryCollections, setEntryCollections] = useState([])
@ -36,6 +36,9 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
const [bulkCollState, setBulkCollState] = useState('idle') // 'idle'|'running'|'done'|'error' const [bulkCollState, setBulkCollState] = useState('idle') // 'idle'|'running'|'done'|'error'
const [bulkCollError, setBulkCollError] = useState('') const [bulkCollError, setBulkCollError] = useState('')
const [bulkDeleteState, setBulkDeleteState] = useState('idle') // 'idle'|'running' const [bulkDeleteState, setBulkDeleteState] = useState('idle') // 'idle'|'running'
const [singleCollUid, setSingleCollUid] = useState('')
const [singleCollState, setSingleCollState] = useState('idle')
const [singleCollError, setSingleCollError] = useState('')
useEffect(() => { useEffect(() => {
const seq = ++selectSeqRef.current const seq = ++selectSeqRef.current
@ -47,6 +50,12 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
setEntryCollections([]) setEntryCollections([])
return return
} }
// Skip auth-required tag/collection fetches for public guests.
if (isPublicSession) {
setTags([])
setEntryCollections([])
return
}
setEditingTitle(false) setEditingTitle(false)
setTitleDraft('') setTitleDraft('')
titleCancelRef.current = false titleCancelRef.current = false
@ -59,7 +68,7 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
setTags(tgs) setTags(tgs)
setEntryCollections(ecs) setEntryCollections(ecs)
}).catch(() => {}) }).catch(() => {})
}, [selectedEntry, archiveId]) }, [selectedEntry, archiveId, isPublicSession])
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -67,11 +76,11 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
} }
}, []) }, [])
// Fetch available collections when entering bulk mode // Fetch available collections whenever archiveId is available
useEffect(() => { useEffect(() => {
if (!isBulk || !archiveId) { setCollections([]); return } if (!archiveId) { setCollections([]); return }
listCollections(archiveId).then(setCollections).catch(() => setCollections([])) listCollections(archiveId).then(setCollections).catch(() => setCollections([]))
}, [isBulk, archiveId]) }, [archiveId])
// Reset transient bulk state when selection changes // Reset transient bulk state when selection changes
useEffect(() => { useEffect(() => {
@ -82,6 +91,9 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
setBulkCollState('idle') setBulkCollState('idle')
setBulkCollError('') setBulkCollError('')
setBulkDeleteState('idle') setBulkDeleteState('idle')
setSingleCollUid('')
setSingleCollState('idle')
setSingleCollError('')
}, [selectedUids]) }, [selectedUids])
async function handleBulkDelete() { async function handleBulkDelete() {
@ -125,9 +137,10 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
setBulkCollState('running') setBulkCollState('running')
setBulkCollError('') setBulkCollError('')
const failed = [] const failed = []
const coll = collections.find(c => c.collection_uid === bulkCollUid)
for (const uid of selectedUids) { for (const uid of selectedUids) {
try { try {
await addEntryToCollection(archiveId, bulkCollUid, uid) await addEntryToCollection(archiveId, bulkCollUid, uid, coll?.default_visibility_bits ?? 2)
} catch (err) { } catch (err) {
failed.push(uid) failed.push(uid)
} }
@ -141,6 +154,25 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
} }
} }
async function handleSingleAddToCollection() {
if (!singleCollUid || !selectedEntry) return
setSingleCollState('running')
setSingleCollError('')
const coll = collections.find(c => c.collection_uid === singleCollUid)
try {
await addEntryToCollection(archiveId, singleCollUid, selectedEntry.entry_uid, coll?.default_visibility_bits ?? 2)
setSingleCollState('done')
setSingleCollUid('')
// Refresh collection membership list
const updated = await listEntryCollections(archiveId, selectedEntry.entry_uid)
setEntryCollections(updated)
setTimeout(() => setSingleCollState('idle'), 1800)
} catch (err) {
setSingleCollError(err.message)
setSingleCollState('error')
}
}
async function handleTitleSave() { async function handleTitleSave() {
const newTitle = titleDraft.trim() || null const newTitle = titleDraft.trim() || null
try { try {
@ -262,6 +294,12 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
<div className="rail-eyebrow">Context</div> <div className="rail-eyebrow">Context</div>
{isBulk ? ( {isBulk ? (
isPublicSession ? (
<p className="bulk-count">
<span className="bulk-count-num">{selectedUids.size}</span>
{' entries selected'}
</p>
) : (
<div className="bulk-panel"> <div className="bulk-panel">
<p className="bulk-count"> <p className="bulk-count">
<span className="bulk-count-num">{selectedUids.size}</span> <span className="bulk-count-num">{selectedUids.size}</span>
@ -304,7 +342,7 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
onChange={e => setBulkCollUid(e.target.value)} onChange={e => setBulkCollUid(e.target.value)}
> >
<option value="">Pick a collection…</option> <option value="">Pick a collection…</option>
{collections.map(c => ( {collections.filter(c => c.slug !== '_default_').map(c => (
<option key={c.collection_uid} value={c.collection_uid}>{c.name}</option> <option key={c.collection_uid} value={c.collection_uid}>{c.name}</option>
))} ))}
</select> </select>
@ -334,13 +372,18 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
</button> </button>
</div> </div>
</div> </div>
)
) : !selectedEntry ? ( ) : !selectedEntry ? (
<p className="tags-empty">Select an entry.</p> <p className="tags-empty">Select an entry.</p>
) : !detail ? ( ) : !detail ? (
<p className="tags-empty">Loading\u2026</p> <p className="tags-empty">Loading\u2026</p>
) : ( ) : (
<> <>
{editingTitle ? ( {isPublicSession ? (
<h2 className="rail-title">
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
</h2>
) : editingTitle ? (
<input <input
className="rail-title-input" className="rail-title-input"
autoFocus autoFocus
@ -460,8 +503,7 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
})()} })()}
</> </>
)} )}
{selectedEntry && !isBulk && !isPublicSession && (
{selectedEntry && !isBulk && (
<> <>
<div className="rail-section"> <div className="rail-section">
<div className="rail-section-heading">Tags</div> <div className="rail-section-heading">Tags</div>
@ -499,17 +541,41 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
</div> </div>
</div> </div>
{entryCollections.length > 0 && ( {(entryCollections.length > 0 || collections.filter(c => c.slug !== '_default_').length > 0) && (
<div className="rail-section"> <div className="rail-section">
<div className="rail-section-heading">Collections</div> <div className="rail-section-heading">Collections</div>
{entryCollections.map(c => ( {entryCollections.map(c => (
<div key={c.collection_uid} className="coll-row"> <div key={c.collection_uid} className="coll-row">
<span className="coll-name">{c.collection_uid}</span> <span className="coll-name">{c.name}</span>
<span className="vis-badge"> <span className="vis-badge">
{VIS_LABEL[c.visibility_bits] ?? `bits:${c.visibility_bits}`} {VIS_LABEL[c.visibility_bits] ?? `bits:${c.visibility_bits}`}
</span> </span>
</div> </div>
))} ))}
{collections.filter(c => c.slug !== '_default_').length > 0 && (
<div className="bulk-coll-row" style={{ marginTop: 8 }}>
<select
className="bulk-coll-select"
value={singleCollUid}
onChange={e => setSingleCollUid(e.target.value)}
>
<option value="">Add to collection…</option>
{collections.filter(c => c.slug !== '_default_').map(c => (
<option key={c.collection_uid} value={c.collection_uid}>{c.name}</option>
))}
</select>
<button
className="tag-add-btn"
onClick={handleSingleAddToCollection}
disabled={!singleCollUid || singleCollState === 'running'}
>
{singleCollState === 'running' ? '…' : singleCollState === 'done' ? '✓' : singleCollState === 'error' ? '!' : 'Add'}
</button>
</div>
)}
{singleCollError && (
<p className="form-msg form-msg--err" style={{ margin: '4px 0 0' }}>{singleCollError}</p>
)}
</div> </div>
)} )}

View file

@ -2,7 +2,7 @@ import SkeletonEntryRow from './SkeletonEntryRow';
import EntryRow from './EntryRow'; import EntryRow from './EntryRow';
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [], deletedUids }) { export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [], deletedUids, isPublicSession }) {
return ( return (
<section id="archive-view" className="view is-active"> <section id="archive-view" className="view is-active">
<div className="entry-table"> <div className="entry-table">
@ -29,6 +29,7 @@ export default function EntriesView({ entries, selectedUids, onRowClick, archive
onRowClick={onRowClick} onRowClick={onRowClick}
selectedUids={selectedUids} selectedUids={selectedUids}
deletedUids={deletedUids} deletedUids={deletedUids}
isPublicSession={isPublicSession}
/> />
))} ))}
</div> </div>

View file

@ -40,7 +40,7 @@ function ChildRow({ entry, index, onRowClick, selectedUids }) {
); );
} }
export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMultiSelected, onRowClick, selectedUids, deletedUids }) { export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMultiSelected, onRowClick, selectedUids, deletedUids, isPublicSession }) {
const [favFailed, setFavFailed] = useState(false); const [favFailed, setFavFailed] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState(null); const [children, setChildren] = useState(null);
@ -67,7 +67,7 @@ export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMul
); );
const checked = isSelected || isMultiSelected; const checked = isSelected || isMultiSelected;
const hasChildren = entry.child_count > 0; const hasChildren = entry.child_count > 0 && !isPublicSession;
function handleCheckboxClick(e) { function handleCheckboxClick(e) {
e.stopPropagation(); e.stopPropagation();

View file

@ -2,7 +2,7 @@ import { useContext, useState } from 'react';
import { AuthContext } from '../App.jsx'; import { AuthContext } from '../App.jsx';
import { logout as apiLogout } from '../api.js'; import { logout as apiLogout } from '../api.js';
export default function Topbar({ archives, archiveId, onArchiveChange, view, onViewChange, onCaptureClick }) { export default function Topbar({ archives, archiveId, onArchiveChange, view, onViewChange, onCaptureClick, collections, selectedCollectionUid, onCollectionChange, isPublicSession, onSignInClick }) {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}; const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {};
const [loggingOut, setLoggingOut] = useState(false); const [loggingOut, setLoggingOut] = useState(false);
@ -13,32 +13,66 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
window.location.reload(); window.location.reload();
} }
// For guests: hide auth-required collections; "All entries" only if _default_ is public.
const defaultColl = collections.find(c => c.slug === '_default_')
const showAllEntriesOption = !isPublicSession || (!!defaultColl && !defaultColl.requires_auth)
const namedCollections = collections
.filter(c => c.slug !== '_default_')
.filter(c => !isPublicSession || !c.requires_auth)
return ( return (
<header className="topbar"> <header className="topbar">
<div className="brand">Archivr</div> <div className="brand">Archivr</div>
<div className="switcher"> <div className="switchers">
<select aria-label="Select archive" {!isPublicSession && (
value={archiveId ?? ''} onChange={e => onArchiveChange(e.target.value)}> <div className="switcher">
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)} <select aria-label="Select archive"
</select> value={archiveId ?? ''} onChange={e => onArchiveChange(e.target.value)}>
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
</select>
</div>
)}
{namedCollections.length > 0 && (
<div className="switcher">
<select
aria-label="Select collection"
value={selectedCollectionUid ?? ''}
onChange={e => onCollectionChange(e.target.value || null)}
>
{showAllEntriesOption && <option value="">All entries</option>}
{namedCollections.map(c => (
<option key={c.collection_uid} value={c.collection_uid}>{c.name}</option>
))}
</select>
</div>
)}
</div> </div>
<nav className="nav" aria-label="Primary"> {isPublicSession ? (
{['archive', 'tags', 'collections', 'runs', 'admin', 'settings'].map(name => ( <>
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`} <span style={{ flex: 1 }} />
onClick={() => onViewChange(name)}> <button className="logout-btn" onClick={onSignInClick}>Sign in</button>
{name.charAt(0).toUpperCase() + name.slice(1)} </>
</button> ) : (
))} <>
</nav> <nav className="nav" aria-label="Primary">
<button className="capture-button" onClick={onCaptureClick}>Capture</button> {['archive', 'tags', 'collections', 'runs', 'admin', 'settings'].map(name => (
{currentUser && ( <button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
<div className="user-menu"> onClick={() => onViewChange(name)}>
<span className="user-name">{currentUser.display_name || currentUser.username}</span> {name.charAt(0).toUpperCase() + name.slice(1)}
<button className="logout-btn" onClick={handleLogout} disabled={loggingOut}> </button>
{loggingOut ? 'Logging out\u2026' : 'Log out'} ))}
</button> </nav>
</div> <button className="capture-button" onClick={onCaptureClick}>Capture</button>
{currentUser && (
<div className="user-menu">
<span className="user-name">{currentUser.display_name || currentUser.username}</span>
<button className="logout-btn" onClick={handleLogout} disabled={loggingOut}>
{loggingOut ? 'Logging out\u2026' : 'Log out'}
</button>
</div>
)}
</>
)} )}
</header> </header>
); )
} }

View file

@ -128,6 +128,9 @@ select {
pointer-events: none; pointer-events: none;
} }
/* Groups archive + collection switchers as a single grid item */
.switchers { display: flex; gap: 8px; align-items: center; min-width: 0; }
.nav { display: flex; gap: 22px; justify-content: flex-end; min-width: 0; } .nav { display: flex; gap: 22px; justify-content: flex-end; min-width: 0; }
.nav-link { .nav-link {
border: 0; border: 0;
@ -579,7 +582,7 @@ select {
/* collections in rail */ /* collections in rail */
.coll-row { .coll-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; overflow: hidden;
padding: 9px 13px; padding: 9px 13px;
background: var(--field); background: var(--field);
border: 1px solid var(--line); border: 1px solid var(--line);
@ -588,7 +591,7 @@ select {
transition: border-color .15s ease; transition: border-color .15s ease;
} }
.coll-row:hover { border-color: var(--accent); } .coll-row:hover { border-color: var(--accent); }
.coll-name { font-size: 13px; color: var(--ink); font-weight: 500; } .coll-name { font-size: 13px; color: var(--ink); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.vis-badge { .vis-badge {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; color: var(--muted); font-size: 11px; color: var(--muted);
@ -1729,6 +1732,10 @@ select {
} }
.coll-add-btn:hover { opacity: 0.85; } .coll-add-btn:hover { opacity: 0.85; }
.coll-add-btn:disabled { opacity: 0.45; cursor: default; } .coll-add-btn:disabled { opacity: 0.45; cursor: default; }
/* ── Public-session context rail summary panel ──────────────────────────── */
.rail-public-summary { padding: 4px 0; }
.rail-public-summary .rail-title { margin: 0 0 10px; }
/* old override — superseded by coll-create-details section below */ /* old override — superseded by coll-create-details section below */
/* ── Auth loading state ─────────────────────────────────────────────────── */ /* ── Auth loading state ─────────────────────────────────────────────────── */