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:
parent
1af920eb63
commit
e1ee05bd41
16 changed files with 1218 additions and 185 deletions
|
|
@ -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,7 +793,21 @@ pub fn search_entries(
|
||||||
params.push(b.clone());
|
params.push(b.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visibility filter
|
// Collection scope + visibility filter.
|
||||||
|
if let Some(coll_id) = query.collection_id {
|
||||||
|
let cn = params.len() + 1;
|
||||||
|
let vn = params.len() + 2;
|
||||||
|
sql.push_str(&format!(
|
||||||
|
" AND EXISTS (\
|
||||||
|
SELECT 1 FROM collection_entries cef \
|
||||||
|
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;
|
let n = params.len() + 1;
|
||||||
sql.push_str(&format!(
|
sql.push_str(&format!(
|
||||||
" AND (CAST(?{n} AS INTEGER) & 12 != 0 \
|
" AND (CAST(?{n} AS INTEGER) & 12 != 0 \
|
||||||
|
|
@ -795,6 +815,7 @@ pub fn search_entries(
|
||||||
WHERE ce.entry_id = e.id AND ce.visibility_bits & CAST(?{n} AS INTEGER) != 0))"
|
WHERE ce.entry_id = e.id AND ce.visibility_bits & CAST(?{n} AS INTEGER) != 0))"
|
||||||
));
|
));
|
||||||
params.push(query.caller_bits.to_string());
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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,8 +557,16 @@ async fn serve_artifact(
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if !token_valid {
|
if !token_valid {
|
||||||
|
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()?;
|
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)?;
|
||||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
let conn = database::open_or_initialize(&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
47
crates/archivr-server/static/assets/index-B67momER.js
Normal file
47
crates/archivr-server/static/assets/index-B67momER.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
crates/archivr-server/static/assets/index-DicH9MNh.css
Normal file
1
crates/archivr-server/static/assets/index-DicH9MNh.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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 }));
|
||||||
|
|
|
||||||
|
|
@ -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'}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,15 +13,47 @@ 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="switchers">
|
||||||
|
{!isPublicSession && (
|
||||||
<div className="switcher">
|
<div className="switcher">
|
||||||
<select aria-label="Select archive"
|
<select aria-label="Select archive"
|
||||||
value={archiveId ?? ''} onChange={e => onArchiveChange(e.target.value)}>
|
value={archiveId ?? ''} onChange={e => onArchiveChange(e.target.value)}>
|
||||||
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
|
{isPublicSession ? (
|
||||||
|
<>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button className="logout-btn" onClick={onSignInClick}>Sign in</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<nav className="nav" aria-label="Primary">
|
<nav className="nav" aria-label="Primary">
|
||||||
{['archive', 'tags', 'collections', 'runs', 'admin', 'settings'].map(name => (
|
{['archive', 'tags', 'collections', 'runs', 'admin', 'settings'].map(name => (
|
||||||
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
|
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
|
||||||
|
|
@ -39,6 +71,8 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ─────────────────────────────────────────────────── */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue