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

feat: reorder child entries with a configurable role permission

- Persist sibling order for child entries (archived_entries.position):
  backfilled to the previous archived_at order, new children appended
  (initial playlist order kept; sync appends after user order).
- PUT /api/archives/:id/entries/:uid/children/order takes the full child
  UID list in one IMMEDIATE transaction (401 guest, 403 role not allowed,
  404 unknown or invisible parent, 400 unless exactly the current children).
- Reorder permission is a role mask in the auth instance settings
  (reorder_children_role_bits, default Admin|Owner). Only the Owner can
  change it, built-in or custom roles, never Guest. /api/auth/me and login
  expose can_reorder_children. Settings PATCH is now one IMMEDIATE txn.
- Main page: drag handle for mouse/trackpad on viewports >640px, up/down
  arrows otherwise (pure CSS, arrows are the fallback), Alt+Up/Down
  everywhere; optimistic with revert. Settings > Instance > Permissions
  for the Owner (read-only for admins).
- Fix: renaming a child entry now updates its row immediately.
- Remove Storybook (deps, config, stories, docs).
This commit is contained in:
TheGeneralist 2026-10-04 18:54:04 +02:00
parent 8fc6754e28
commit 699eb7f62d
27 changed files with 1300 additions and 2477 deletions

View file

@ -510,9 +510,11 @@ pub fn list_entries_for_collection(
Ok(entries)
}
/// Returns the direct children of the entry identified by `parent_entry_uid`,
/// ordered ascending by `archived_at, id` (preserves playlist ordinal feel).
/// Returns an empty vec if the parent has no children or does not exist.
/// Returns the direct children of the entry identified by `parent_entry_uid`
/// in persisted sibling order (`position`, set at insert time as append and
/// rewritten by `database::reorder_child_entries`), tie-broken by
/// `archived_at, id`. Returns an empty vec if the parent has no children or
/// does not exist.
pub fn list_child_entries(
conn: &rusqlite::Connection,
parent_entry_uid: &str,
@ -535,7 +537,7 @@ pub fn list_child_entries(
)\
) \
GROUP BY e.id \
ORDER BY e.archived_at ASC, e.id ASC",
ORDER BY e.position ASC, e.archived_at ASC, e.id ASC",
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
);
let mut stmt = conn.prepare(&sql)?;
@ -2268,4 +2270,37 @@ mod tests {
assert!(guest_children.is_empty(), "guest must not see children of a USER-only collection");
}
#[test]
fn list_child_entries_orders_by_position_not_archived_at() {
let (conn, user_id, run_id) = make_tag_test_db();
let container = make_entry_in_db(&conn, user_id, run_id, None, None,
"Playlist", "https://example.com/pl");
let mk = |title: &str, url: &str| make_entry_in_db(&conn, user_id, run_id,
Some(container.id), Some(container.id), title, url);
let v1 = mk("V1", "https://example.com/pl/v1");
let v2 = mk("V2", "https://example.com/pl/v2");
let v3 = mk("V3", "https://example.com/pl/v3");
conn.execute(
"UPDATE archived_entries SET archived_at = '2000-01-01T00:00:00Z' WHERE id = ?1",
[v3.id],
).unwrap();
let uids = || -> Vec<String> {
list_child_entries(&conn, &container.entry_uid, 12).unwrap()
.into_iter().map(|e| e.entry_uid).collect()
};
assert_eq!(uids(), vec![v1.entry_uid.clone(), v2.entry_uid.clone(), v3.entry_uid.clone()]);
let order = vec![v3.entry_uid.clone(), v1.entry_uid.clone(), v2.entry_uid.clone()];
assert_eq!(
database::reorder_child_entries(&conn, &container.entry_uid, &order).unwrap(),
database::ReorderChildrenOutcome::Reordered
);
assert_eq!(uids(), order);
let v4 = mk("V4", "https://example.com/pl/v4");
let mut expected = order.clone();
expected.push(v4.entry_uid.clone());
assert_eq!(uids(), expected);
}
}

View file

@ -1,6 +1,7 @@
use anyhow::{Context, Result, bail};
use chrono::Utc;
use rusqlite::{Connection, OptionalExtension, params};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use uuid::Uuid;
@ -155,6 +156,9 @@ pub struct RoleRecord {
pub is_builtin: bool,
}
/// Default reorder permission: ADMIN (bit 2) | OWNER (bit 3). Keep in sync with the SQL DEFAULT 12 in `initialize_auth_schema`.
pub const DEFAULT_REORDER_CHILDREN_ROLE_BITS: u32 = 0b1100;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InstanceSettings {
pub public_index_enabled: bool,
@ -168,6 +172,16 @@ pub struct InstanceSettings {
pub cookie_ext_enabled: bool,
/// Global default for modal-closer browser-script behavior during WebPage captures.
pub modal_closer_enabled: bool,
/// Role bits allowed to reorder child entries (`PUT …/children/order`).
/// A caller may reorder iff `role_bits & reorder_children_role_bits != 0`.
/// Only the Owner may change it. Never contains the Guest bit.
pub reorder_children_role_bits: u32,
}
impl InstanceSettings {
pub fn can_reorder_children(&self, role_bits: u32) -> bool {
role_bits & self.reorder_children_role_bits != 0
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -296,7 +310,8 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
representation_kind TEXT NOT NULL,
source_metadata_json TEXT NOT NULL DEFAULT '{}',
display_metadata_json TEXT,
cached_bytes INTEGER NOT NULL DEFAULT 0
cached_bytes INTEGER NOT NULL DEFAULT 0,
position INTEGER
);
CREATE TABLE IF NOT EXISTS blobs (
@ -493,6 +508,51 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
[],
);
// Migration: persisted sibling order for child entries (`position`).
// NULL for roots; 0-based per parent for children. The backfill reproduces
// the previous implicit child order (archived_at ASC, id ASC) so existing
// archives render unchanged. New DBs get the column from the DDL above and
// skip this (they have no rows to backfill).
let has_position = |conn: &Connection| -> Result<bool> {
Ok(conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('archived_entries') WHERE name = 'position'",
[],
|row| row.get::<_, i64>(0),
)? > 0)
};
if !has_position(conn)? {
// IMMEDIATE + re-check under the write lock: two connections opening
// the same pre-migration DB at once must not both run the ALTER.
conn.execute_batch("BEGIN IMMEDIATE")?;
let migrated = (|| -> Result<()> {
if !has_position(conn)? {
conn.execute_batch(
"ALTER TABLE archived_entries ADD COLUMN position INTEGER;
UPDATE archived_entries
SET position = (
SELECT COUNT(*) FROM archived_entries s
WHERE s.parent_entry_id = archived_entries.parent_entry_id
AND (s.archived_at < archived_entries.archived_at
OR (s.archived_at = archived_entries.archived_at
AND s.id < archived_entries.id))
)
WHERE parent_entry_id IS NOT NULL;",
)?;
}
Ok(())
})();
conn.execute_batch(if migrated.is_ok() { "COMMIT" } else { "ROLLBACK" })?;
migrated?;
}
// Sibling-order lookups: list_child_entries ORDER BY and the MAX(position)
// append in create_archived_entry. Created after the migration because the
// column may not exist yet when the main DDL batch runs.
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_archived_entries_parent_position
ON archived_entries(parent_entry_id, position)",
[],
)?;
// Summary attempts used to be unique by cache key, which meant forced
// regeneration erased the last completed result. Rebuild that small table
// without the cache-key constraint while retaining all existing rows.
@ -605,7 +665,8 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
default_entry_visibility INTEGER NOT NULL DEFAULT 2,
ublock_enabled INTEGER NOT NULL DEFAULT 1 CHECK (ublock_enabled IN (0, 1)),
cookie_ext_enabled INTEGER NOT NULL DEFAULT 1 CHECK (cookie_ext_enabled IN (0, 1)),
modal_closer_enabled INTEGER NOT NULL DEFAULT 1 CHECK (modal_closer_enabled IN (0, 1))
modal_closer_enabled INTEGER NOT NULL DEFAULT 1 CHECK (modal_closer_enabled IN (0, 1)),
reorder_children_role_bits INTEGER NOT NULL DEFAULT 12
);
INSERT OR IGNORE INTO instance_settings
@ -660,6 +721,11 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
"ALTER TABLE instance_settings ADD COLUMN modal_closer_enabled INTEGER NOT NULL DEFAULT 1",
[],
);
// Add reorder_children_role_bits (ADMIN|OWNER = 12 by default) if not present (idempotent migration)
let _ = conn.execute(
"ALTER TABLE instance_settings ADD COLUMN reorder_children_role_bits INTEGER NOT NULL DEFAULT 12",
[],
);
Ok(())
}
@ -885,7 +951,8 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
public_archive_submission_enabled, default_entry_visibility,
COALESCE(ublock_enabled, 1),
COALESCE(cookie_ext_enabled, 1),
COALESCE(modal_closer_enabled, 1)
COALESCE(modal_closer_enabled, 1),
COALESCE(reorder_children_role_bits, 12)
FROM instance_settings WHERE id = 1",
[],
|row| {
@ -897,6 +964,7 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
ublock_enabled: row.get::<_, i64>(4)? != 0,
cookie_ext_enabled: row.get::<_, i64>(5)? != 0,
modal_closer_enabled: row.get::<_, i64>(6)? != 0,
reorder_children_role_bits: row.get::<_, i64>(7)? as u32,
})
},
)
@ -912,7 +980,8 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
default_entry_visibility = ?4,
ublock_enabled = ?5,
cookie_ext_enabled = ?6,
modal_closer_enabled = ?7
modal_closer_enabled = ?7,
reorder_children_role_bits = ?8
WHERE id = 1",
params![
settings.public_index_enabled as i64,
@ -922,6 +991,7 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
settings.ublock_enabled as i64,
settings.cookie_ext_enabled as i64,
settings.modal_closer_enabled as i64,
settings.reorder_children_role_bits as i64,
],
)?;
Ok(())
@ -1051,6 +1121,78 @@ pub fn update_entry_title(conn: &Connection, entry_uid: &str, title: Option<&str
Ok(n > 0)
}
/// Outcome of [`reorder_child_entries`]; the server maps it to 204/404/400.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReorderChildrenOutcome {
Reordered,
ParentNotFound,
/// `ordered_child_uids` was not exactly the parent's current direct
/// children (missing, extra, foreign or duplicate UID).
ChildSetMismatch,
}
/// Replaces the sibling order of the direct children of `parent_entry_uid`.
/// `ordered_child_uids` must be a permutation of the current children; child
/// at index `i` gets `position = i`. Nothing is written on mismatch.
/// Wrap in a transaction at the call site so the set check and the writes
/// are atomic against concurrent child inserts (sync capture).
pub fn reorder_child_entries(
conn: &Connection,
parent_entry_uid: &str,
ordered_child_uids: &[String],
) -> Result<ReorderChildrenOutcome> {
let Some(parent_id) = entry_id_for_uid(conn, parent_entry_uid)? else {
return Ok(ReorderChildrenOutcome::ParentNotFound);
};
let current: HashSet<String> = {
let mut stmt =
conn.prepare("SELECT entry_uid FROM archived_entries WHERE parent_entry_id = ?1")?;
stmt.query_map([parent_id], |row| row.get(0))?
.collect::<rusqlite::Result<_>>()?
};
let requested: HashSet<&str> = ordered_child_uids.iter().map(String::as_str).collect();
if requested.len() != ordered_child_uids.len()
|| requested.len() != current.len()
|| !requested.iter().all(|uid| current.contains(*uid))
{
return Ok(ReorderChildrenOutcome::ChildSetMismatch);
}
let mut update = conn.prepare(
"UPDATE archived_entries SET position = ?1
WHERE parent_entry_id = ?2 AND entry_uid = ?3",
)?;
for (index, uid) in ordered_child_uids.iter().enumerate() {
update.execute(params![index as i64, parent_id, uid])?;
}
Ok(ReorderChildrenOutcome::Reordered)
}
/// True when `caller_bits` can see every direct child of `parent_entry_uid`
/// under the rule `archive::list_child_entries` applies: ADMIN/OWNER (bits 12)
/// see everything, otherwise the parent must be in a collection whose
/// `visibility_bits` overlap the caller's bits (children inherit it). A caller
/// who sees only individually-shared children cannot submit a full order, so
/// that case is false too. False for an unknown parent.
pub fn caller_sees_all_children(
conn: &Connection,
parent_entry_uid: &str,
caller_bits: u32,
) -> Result<bool> {
if caller_bits & 12 != 0 {
return Ok(entry_id_for_uid(conn, parent_entry_uid)?.is_some());
}
let visible: bool = conn.query_row(
"SELECT EXISTS (
SELECT 1 FROM collection_entries ce
JOIN archived_entries p ON p.id = ce.entry_id
WHERE p.entry_uid = ?1 AND ce.visibility_bits & ?2 != 0
)",
params![parent_entry_uid, caller_bits as i64],
|row| row.get(0),
)?;
Ok(visible)
}
pub fn get_user_display_name(conn: &Connection, user_id: i64) -> Result<Option<String>> {
conn.query_row(
"SELECT display_name FROM users WHERE id = ?1",
@ -1294,6 +1436,17 @@ pub fn list_roles(conn: &Connection) -> Result<Vec<RoleRecord>> {
.map_err(Into::into)
}
/// OR of `1 << bit_position` over every role except Guest (bit 0): the bits a
/// role-permission mask may contain. Guest is excluded because every signed-in
/// account carries it, so granting it would mean "everyone".
pub fn grantable_role_bits(conn: &Connection) -> Result<u32> {
let mut stmt = conn.prepare("SELECT bit_position FROM roles WHERE bit_position > 0")?;
let bits = stmt
.query_map([], |row| row.get::<_, i64>(0))?
.try_fold(0u32, |acc, b| b.map(|b| acc | (1u32 << b)))?;
Ok(bits)
}
/// Creates a new custom role (level=2, bit_position = max existing + 1, min 4).
/// Returns the created RoleRecord.
pub fn create_custom_role(conn: &Connection, slug: &str, name: &str) -> Result<RoleRecord> {
@ -2201,10 +2354,14 @@ pub fn create_archived_entry(conn: &Connection, entry: &NewEntry) -> Result<Arch
entry_uid, source_identity_id, archive_run_id, parent_entry_id, root_entry_id,
created_by_user_id, owned_by_user_id, source_kind, entity_kind, title, visibility,
archived_at, original_published_at, structured_root_relpath, representation_kind,
source_metadata_json, display_metadata_json
source_metadata_json, display_metadata_json, position
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11,
?12, NULL, ?13, ?14, ?15, ?16
?12, NULL, ?13, ?14, ?15, ?16,
CASE WHEN ?4 IS NULL THEN NULL ELSE (
SELECT COALESCE(MAX(position), -1) + 1
FROM archived_entries WHERE parent_entry_id = ?4
) END
)",
params![
entry_uid,
@ -3798,6 +3955,60 @@ mod tests {
assert_eq!(r2.bit_position, 5);
assert_eq!(r2.level, 2);
}
#[test]
fn instance_settings_reorder_mask_defaults_to_admin_owner() {
let conn = make_auth_conn_for_mgmt();
let s = get_instance_settings(&conn).unwrap();
assert_eq!(s.reorder_children_role_bits, 12);
assert_eq!(s.reorder_children_role_bits, DEFAULT_REORDER_CHILDREN_ROLE_BITS);
}
#[test]
fn instance_settings_reorder_mask_round_trips() {
let conn = make_auth_conn_for_mgmt();
let mut s = get_instance_settings(&conn).unwrap();
s.reorder_children_role_bits = 2 | 16;
update_instance_settings(&conn, &s).unwrap();
let s = get_instance_settings(&conn).unwrap();
assert_eq!(s.reorder_children_role_bits, 18);
assert!(s.ublock_enabled);
}
#[test]
fn instance_settings_reorder_mask_migrates_legacy_table() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE instance_settings (id INTEGER PRIMARY KEY CHECK (id = 1), public_index_enabled INTEGER NOT NULL DEFAULT 0, public_entry_content_enabled INTEGER NOT NULL DEFAULT 0, public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0, default_entry_visibility INTEGER NOT NULL DEFAULT 2);
INSERT INTO instance_settings (id) VALUES (1);",
)
.unwrap();
initialize_auth_schema(&conn).unwrap();
initialize_auth_schema(&conn).unwrap();
let s = get_instance_settings(&conn).unwrap();
assert_eq!(s.reorder_children_role_bits, 12);
assert!(s.modal_closer_enabled);
}
#[test]
fn can_reorder_children_intersects_mask() {
let conn = make_auth_conn_for_mgmt();
let mut s = get_instance_settings(&conn).unwrap();
assert!(s.can_reorder_children(15));
assert!(s.can_reorder_children(7));
assert!(!s.can_reorder_children(3));
assert!(!s.can_reorder_children(1));
s.reorder_children_role_bits = 0;
assert!(!s.can_reorder_children(15));
}
#[test]
fn grantable_role_bits_excludes_guest_and_tracks_custom_roles() {
let conn = make_auth_conn_for_mgmt();
assert_eq!(grantable_role_bits(&conn).unwrap(), 0b1110);
create_custom_role(&conn, "editor", "Editor").unwrap();
assert_eq!(grantable_role_bits(&conn).unwrap(), 30);
}
// ── rename_tag / delete_tag ────────────────────────────────────────────
#[test]
@ -4940,4 +5151,128 @@ mod tests {
);
assert_eq!(entry_id_for_uid(&c, "ent_nope").unwrap(), None);
}
fn position_of(c: &Connection, id: i64) -> Option<i64> {
c.query_row(
"SELECT position FROM archived_entries WHERE id = ?1",
[id],
|r| r.get(0),
)
.unwrap()
}
fn child(c: &Connection, parent: &ArchivedEntry) -> ArchivedEntry {
create_entry_fixture(c, "private", Some(parent.id), Some(parent.id))
}
#[test]
fn create_archived_entry_assigns_child_positions_append_only() {
let c = conn();
let r = create_entry_fixture(&c, "private", None, None);
assert_eq!(position_of(&c, r.id), None);
let a = child(&c, &r);
let b = child(&c, &r);
let c3 = child(&c, &r);
assert_eq!(position_of(&c, a.id), Some(0));
assert_eq!(position_of(&c, b.id), Some(1));
assert_eq!(position_of(&c, c3.id), Some(2));
let r2 = create_entry_fixture(&c, "private", None, None);
let x = child(&c, &r2);
assert_eq!(position_of(&c, x.id), Some(0));
assert!(delete_entry(&c, &b.entry_uid).unwrap());
let d = child(&c, &r);
assert_eq!(position_of(&c, d.id), Some(3));
}
#[test]
fn reorder_child_entries_rewrites_positions() {
let c = conn();
let r = create_entry_fixture(&c, "private", None, None);
let a = child(&c, &r);
let b = child(&c, &r);
let c3 = child(&c, &r);
let order = vec![c3.entry_uid.clone(), a.entry_uid.clone(), b.entry_uid.clone()];
assert_eq!(
reorder_child_entries(&c, &r.entry_uid, &order).unwrap(),
ReorderChildrenOutcome::Reordered
);
assert_eq!(position_of(&c, c3.id), Some(0));
assert_eq!(position_of(&c, a.id), Some(1));
assert_eq!(position_of(&c, b.id), Some(2));
let d = child(&c, &r);
assert_eq!(position_of(&c, d.id), Some(3));
}
#[test]
fn reorder_child_entries_rejects_mismatched_sets() {
let c = conn();
let r = create_entry_fixture(&c, "private", None, None);
let a = child(&c, &r);
let b = child(&c, &r);
let r2 = create_entry_fixture(&c, "private", None, None);
let x = child(&c, &r2);
let (a_u, b_u) = (a.entry_uid.clone(), b.entry_uid.clone());
let cases: Vec<Vec<String>> = vec![
vec![a_u.clone()],
vec![a_u.clone(), b_u.clone(), x.entry_uid.clone()],
vec![a_u.clone(), b_u.clone(), r2.entry_uid.clone()],
vec![a_u.clone(), a_u.clone()],
vec![a_u.clone(), b_u.clone(), a_u.clone()],
vec![],
];
for case in cases {
assert_eq!(
reorder_child_entries(&c, &r.entry_uid, &case).unwrap(),
ReorderChildrenOutcome::ChildSetMismatch,
"{case:?}"
);
assert_eq!(position_of(&c, a.id), Some(0));
assert_eq!(position_of(&c, b.id), Some(1));
}
}
#[test]
fn reorder_child_entries_unknown_parent() {
let c = conn();
assert_eq!(
reorder_child_entries(&c, "entry_nope", &[]).unwrap(),
ReorderChildrenOutcome::ParentNotFound
);
}
#[test]
fn initialize_schema_backfills_child_positions_once() {
let c = conn();
let r = create_entry_fixture(&c, "private", None, None);
let a = child(&c, &r);
let b = child(&c, &r);
let c3 = child(&c, &r);
c.execute(
"UPDATE archived_entries SET archived_at = '2026-01-01T00:00:00Z' WHERE id = ?1",
[b.id],
)
.unwrap();
c.execute(
"UPDATE archived_entries SET archived_at = '2026-01-02T00:00:00Z' WHERE id IN (?1, ?2)",
[a.id, c3.id],
)
.unwrap();
c.execute_batch(
"DROP INDEX idx_archived_entries_parent_position;
ALTER TABLE archived_entries DROP COLUMN position;",
)
.unwrap();
initialize_schema(&c).unwrap();
assert_eq!(position_of(&c, b.id), Some(0));
assert_eq!(position_of(&c, a.id), Some(1));
assert_eq!(position_of(&c, c3.id), Some(2));
assert_eq!(position_of(&c, r.id), None);
let order = vec![a.entry_uid.clone(), b.entry_uid.clone(), c3.entry_uid.clone()];
reorder_child_entries(&c, &r.entry_uid, &order).unwrap();
initialize_schema(&c).unwrap();
assert_eq!(position_of(&c, a.id), Some(0));
assert_eq!(position_of(&c, b.id), Some(1));
assert_eq!(position_of(&c, c3.id), Some(2));
}
}

View file

@ -11,13 +11,17 @@
// POST /api/archives/:id/tags
// POST/DELETE /api/archives/:id/entries/:uid/tags
// PATCH /api/archives/:id/entries/:uid
// ADMIN — requires ROLE_ADMIN: (future)
// OWNER — requires ROLE_OWNER: (future)
// PERMISSION — authenticated + caller role_bits ∩ instance-settings mask (else 403):
// PUT /api/archives/:id/entries/:uid/children/order
// (mask = reorder_children_role_bits, default ADMIN|OWNER)
// ADMIN — requires ROLE_ADMIN: /api/admin/* (users, roles, cookie-rules)
// OWNER — requires ROLE_OWNER:
// changing reorder_children_role_bits via PATCH /api/admin/instance-settings
// AUTH_SELF — own resources, require_auth() only:
// GET/POST/DELETE /api/auth/tokens
// POST /api/auth/logout, GET/PATCH /api/auth/me
// SETTINGS — instance settings, require ROLE_ADMIN:
// GET/PATCH /api/admin/instance-settings
// SETTINGS — instance settings:
// GET/PATCH /api/admin/instance-settings (ROLE_ADMIN; the reorder mask field is OWNER-only)
// ────────────────────────────────────────────────────────────────────────────
use parking_lot::Mutex;
@ -36,7 +40,7 @@ use axum::{
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
routing::{delete, get, patch, post},
routing::{delete, get, patch, post, put},
};
use tower::ServiceExt;
use tower_http::services::{ServeDir, ServeFile};
@ -260,6 +264,10 @@ pub fn app_with_state(state: AppState) -> Router {
"/api/archives/:archive_id/entries/:entry_uid/children",
get(list_entry_children),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/children/order",
put(reorder_entry_children_handler),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
get(serve_artifact),
@ -1114,6 +1122,44 @@ async fn delete_entry_handler(
}
}
async fn reorder_entry_children_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path((archive_id, entry_uid)): Path<(String, String)>,
Json(body): Json<ReorderChildrenBody>,
) -> Result<StatusCode, ApiError> {
let (_, role_bits) = auth_user.require_auth()?; // guests → 401
let settings = database::get_instance_settings(&database::open_auth_db(&state.auth_db_path)?)?;
if !settings.can_reorder_children(role_bits) {
return Err(ApiError::forbidden(
"your role is not allowed to reorder child entries",
));
}
let mounted = mounted_archive(&state, &archive_id)?;
let mut conn = database::open_or_initialize(&mounted.archive_path)?;
// IMMEDIATE: take the write lock before the set check so a concurrent
// sync capture cannot add a sibling between validation and the writes.
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
// A role granted reorder must still be able to see the parent's children
// (same rule as the children GET); hidden parents are indistinguishable
// from missing ones.
if !database::caller_sees_all_children(&tx, &entry_uid, role_bits)? {
return Err(ApiError::not_found("entry not found"));
}
match database::reorder_child_entries(&tx, &entry_uid, &body.child_uids)? {
database::ReorderChildrenOutcome::Reordered => {
tx.commit()?;
Ok(StatusCode::NO_CONTENT)
}
database::ReorderChildrenOutcome::ParentNotFound => {
Err(ApiError::not_found("entry not found"))
}
database::ReorderChildrenOutcome::ChildSetMismatch => Err(ApiError::bad_request(
"child_uids must list every current child of the entry exactly once",
)),
}
}
#[derive(Debug, serde::Deserialize)]
struct CaptureBody {
locator: String,
@ -1174,6 +1220,12 @@ struct PatchEntryBody {
title: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct ReorderChildrenBody {
/// Every current direct child UID of the parent, in the desired order.
child_uids: Vec<String>,
}
#[derive(Debug, serde::Deserialize)]
struct PatchTagBody {
name: String,
@ -1922,6 +1974,8 @@ async fn auth_login(
return Err(ApiError::unauthorized("invalid_credentials"));
}
let role_bits = database::compute_role_bits(&conn, user.id)?;
let can_reorder_children =
database::get_instance_settings(&conn)?.can_reorder_children(role_bits);
let user_agent = headers.get("user-agent").and_then(|v| v.to_str().ok());
let session_uid = database::create_session(&conn, user.id, role_bits, user_agent)?;
@ -1950,6 +2004,7 @@ async fn auth_login(
"user_uid": user.user_uid,
"username": user.username,
"role_bits": role_bits,
"can_reorder_children": can_reorder_children,
})),
))
}
@ -1986,11 +2041,13 @@ async fn auth_me(
)
.map_err(|e| ApiError::from(anyhow::anyhow!("db error: {e}")))?;
let humanize_slugs = humanize_slugs_int != 0;
let settings = database::get_instance_settings(&conn)?;
Ok(Json(serde_json::json!({
"role_bits": role_bits,
"username": username,
"display_name": display_name,
"humanize_slugs": humanize_slugs,
"can_reorder_children": settings.can_reorder_children(role_bits),
})))
}
@ -2069,8 +2126,12 @@ async fn update_instance_settings_handler(
Json(body): Json<UpdateInstanceSettingsBody>,
) -> Result<StatusCode, ApiError> {
auth_user.require_role(ROLE_ADMIN)?;
let conn = database::open_auth_db(&state.auth_db_path)?;
let mut settings = database::get_instance_settings(&conn)?;
let mut conn = database::open_auth_db(&state.auth_db_path)?;
// IMMEDIATE: the read-merge-write below rewrites every column, so it must
// not interleave with another PATCH (an admin save could otherwise write a
// stale reorder mask over the owner's change).
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let mut settings = database::get_instance_settings(&tx)?;
if let Some(v) = body.public_index_enabled {
settings.public_index_enabled = v;
}
@ -2092,7 +2153,24 @@ async fn update_instance_settings_handler(
if let Some(v) = body.modal_closer_enabled {
settings.modal_closer_enabled = v;
}
database::update_instance_settings(&conn, &settings)?;
if let Some(mask) = body.reorder_children_role_bits {
if mask != settings.reorder_children_role_bits {
if !auth_user.has_role(ROLE_OWNER) {
return Err(ApiError::forbidden(
"only the owner can change who may reorder child entries",
));
}
let grantable = database::grantable_role_bits(&tx)?;
if mask & !grantable != 0 {
return Err(ApiError::bad_request(
"reorder_children_role_bits may only contain bits of existing non-guest roles",
));
}
settings.reorder_children_role_bits = mask;
}
}
database::update_instance_settings(&tx, &settings)?;
tx.commit()?;
Ok(StatusCode::NO_CONTENT)
}
@ -2440,6 +2518,7 @@ struct UpdateInstanceSettingsBody {
ublock_enabled: Option<bool>,
cookie_ext_enabled: Option<bool>,
modal_closer_enabled: Option<bool>,
reorder_children_role_bits: Option<u32>,
}
async fn admin_list_users(
@ -3031,6 +3110,78 @@ mod tests {
format!("session={}", sess_uid)
}
/// Creates an active user holding `roles` (assign_role adds the cumulative ones:
/// user for any non-guest, admin for owner) and returns a session cookie.
fn make_role_session(auth_path: &std::path::Path, username: &str, roles: &[&str]) -> String {
let conn = archivr_core::database::open_auth_db(auth_path).unwrap();
let owner_id: i64 = conn
.query_row(
"SELECT id FROM users WHERE username = 'testowner'",
[],
|r| r.get(0),
)
.unwrap();
let uid = database::create_user(&conn, username, None, "dummy", owner_id).unwrap();
let user_id = database::get_user_id_by_uid(&conn, &uid).unwrap().unwrap();
for role in roles {
database::assign_role(&conn, user_id, role, owner_id).unwrap();
}
// assign_role deletes sessions, so create the session afterwards.
let bits = database::compute_role_bits(&conn, user_id).unwrap();
format!(
"session={}",
database::create_session(&conn, user_id, bits, None).unwrap()
)
}
fn patch_settings_request(body: serde_json::Value, cookie: &str) -> Request<Body> {
Request::builder()
.method("PATCH")
.uri("/api/admin/instance-settings")
.header("content-type", "application/json")
.header("cookie", cookie)
.body(Body::from(body.to_string()))
.unwrap()
}
async fn get_settings_json(
registry: ServerRegistry,
auth_path: std::path::PathBuf,
cookie: &str,
) -> serde_json::Value {
let resp = app(registry, auth_path)
.oneshot(
Request::builder()
.uri("/api/admin/instance-settings")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
body_json(resp).await
}
async fn me_json(
registry: ServerRegistry,
auth_path: std::path::PathBuf,
cookie: &str,
) -> serde_json::Value {
let resp = app(registry, auth_path)
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
body_json(resp).await
}
fn make_test_entry(archive_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();
@ -3065,6 +3216,38 @@ mod tests {
.unwrap()
}
fn make_test_child(
archive_path: &std::path::Path,
parent_id: i64,
title: &str,
url: &str,
) -> 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(url), url)
.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(title.to_string()),
visibility: "private".to_string(),
representation_kind: "html".to_string(),
source_metadata_json: "{}".to_string(),
display_metadata_json: None,
},
)
.unwrap()
}
fn add_summary_test_artifact(
archive_path: &std::path::Path,
entry_id: i64,
@ -4220,6 +4403,35 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn login_response_includes_can_reorder_children() {
let dir = tempfile::tempdir().unwrap();
let auth_path = dir.path().join("auth.sqlite");
{
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
let hash = crate::auth::hash_password("pw").unwrap();
archivr_core::database::create_owner(&conn, "owner", &hash).unwrap();
}
let registry = ServerRegistry {
archives: vec![],
bind: None,
auth_db_path: None,
};
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/login")
.header("content-type", "application/json")
.body(Body::from(r#"{"username":"owner","password":"pw"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(body_json(response).await["can_reorder_children"], true);
}
#[tokio::test]
async fn create_token_requires_auth() {
let (test_app, _dir) = make_test_app();
@ -4841,6 +5053,7 @@ mod tests {
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["public_index_enabled"], false);
assert_eq!(json["open_registration_enabled"], false);
assert_eq!(json["reorder_children_role_bits"], 12);
}
#[tokio::test]
@ -6166,6 +6379,328 @@ mod tests {
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
fn reorder_request(parent_uid: &str, uids: &[&str], cookie: Option<&str>) -> Request<Body> {
let mut builder = Request::builder()
.method("PUT")
.uri(format!("/api/archives/test/entries/{parent_uid}/children/order"))
.header("content-type", "application/json");
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder
.body(Body::from(serde_json::json!({ "child_uids": uids }).to_string()))
.unwrap()
}
async fn child_uids_via_api(
registry: ServerRegistry,
auth_path: std::path::PathBuf,
cookie: &str,
parent_uid: &str,
) -> Vec<String> {
let resp = app(registry, auth_path)
.oneshot(
Request::builder()
.uri(format!("/api/archives/test/entries/{parent_uid}/children"))
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
body_json(resp)
.await
.as_array()
.unwrap()
.iter()
.map(|e| e["entry_uid"].as_str().unwrap().to_string())
.collect()
}
#[tokio::test]
async fn reorder_entry_children_requires_auth() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let resp = app(registry, auth_path)
.oneshot(reorder_request(&parent.entry_uid, &[&a.entry_uid], None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// owner is allowed by the default mask
#[tokio::test]
async fn reorder_entry_children_persists_order() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let cookie = make_test_session(&auth_path);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b");
let c = make_test_child(&archive_path, parent.id, "C", "https://example.com/c");
let order = [c.entry_uid.as_str(), a.entry_uid.as_str(), b.entry_uid.as_str()];
let resp = app(registry.clone(), auth_path.clone())
.oneshot(reorder_request(&parent.entry_uid, &order, Some(&cookie)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(
child_uids_via_api(registry, auth_path, &cookie, &parent.entry_uid).await,
order
);
}
#[tokio::test]
async fn reorder_entry_children_rejects_mismatched_set() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let cookie = make_test_session(&auth_path);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b");
let other = make_test_entry(&archive_path);
let (a_u, b_u) = (a.entry_uid.as_str(), b.entry_uid.as_str());
let bad: [&[&str]; 3] = [&[a_u], &[a_u, b_u, other.entry_uid.as_str()], &[a_u, a_u]];
for uids in bad {
let resp = app(registry.clone(), auth_path.clone())
.oneshot(reorder_request(&parent.entry_uid, uids, Some(&cookie)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{uids:?}");
}
assert_eq!(
child_uids_via_api(registry, auth_path, &cookie, &parent.entry_uid).await,
[a_u, b_u]
);
}
#[tokio::test]
async fn reorder_entry_children_returns_404_for_unknown_parent() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let cookie = make_test_session(&auth_path);
let resp = app(registry, auth_path)
.oneshot(reorder_request("no-such-uid", &[], Some(&cookie)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn reorder_entry_children_denies_plain_user_by_default() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
let plain = make_role_session(&auth_path, "plain", &[]);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b");
let (a_u, b_u) = (a.entry_uid.as_str(), b.entry_uid.as_str());
let resp = app(registry.clone(), auth_path.clone())
.oneshot(reorder_request(&parent.entry_uid, &[b_u, a_u], Some(&plain)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
assert_eq!(
child_uids_via_api(registry, auth_path, &owner, &parent.entry_uid).await,
[a_u, b_u]
);
}
#[tokio::test]
async fn reorder_entry_children_allows_admin_by_default() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let admin = make_role_session(&auth_path, "adm", &["admin"]);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b");
let (a_u, b_u) = (a.entry_uid.as_str(), b.entry_uid.as_str());
let resp = app(registry.clone(), auth_path.clone())
.oneshot(reorder_request(&parent.entry_uid, &[b_u, a_u], Some(&admin)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(
child_uids_via_api(registry, auth_path, &admin, &parent.entry_uid).await,
[b_u, a_u]
);
}
#[tokio::test]
async fn reorder_entry_children_allows_custom_role_after_owner_grants_it() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
{
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
let role = database::create_custom_role(&conn, "editor", "Editor").unwrap();
assert_eq!(role.bit_position, 4);
}
let editor = make_role_session(&auth_path, "ed", &["editor"]);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b");
let order = [b.entry_uid.as_str(), a.entry_uid.as_str()];
let resp = app(registry.clone(), auth_path.clone())
.oneshot(reorder_request(&parent.entry_uid, &order, Some(&editor)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 4 | 8 | 16 }),
&owner,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app(registry, auth_path)
.oneshot(reorder_request(&parent.entry_uid, &order, Some(&editor)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn reorder_entry_children_hides_parent_invisible_to_granted_role() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
let user = make_role_session(&auth_path, "plain", &["user"]);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let b = make_test_child(&archive_path, parent.id, "B", "https://example.com/b");
// Admin/owner-only membership: the USER role cannot see the parent or children.
database::open_or_initialize(&archive_path)
.unwrap()
.execute("UPDATE collection_entries SET visibility_bits = 12", [])
.unwrap();
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 2 | 4 | 8 }),
&owner,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let order = [b.entry_uid.as_str(), a.entry_uid.as_str()];
let resp = app(registry.clone(), auth_path.clone())
.oneshot(reorder_request(&parent.entry_uid, &order, Some(&user)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND, "granted but cannot see the parent");
let resp = app(registry, auth_path)
.oneshot(reorder_request(&parent.entry_uid, &order, Some(&owner)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT, "owner sees every entry");
}
#[tokio::test]
async fn reorder_entry_children_empty_mask_denies_everyone() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
let parent = make_test_entry(&archive_path);
let a = make_test_child(&archive_path, parent.id, "A", "https://example.com/a");
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 0 }),
&owner,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app(registry, auth_path)
.oneshot(reorder_request(&parent.entry_uid, &[&a.entry_uid], Some(&owner)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn instance_settings_reorder_mask_requires_owner_to_change() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
let admin = make_role_session(&auth_path, "adm", &["admin"]);
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 2 }),
&admin,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let json = get_settings_json(registry.clone(), auth_path.clone(), &admin).await;
assert_eq!(json["reorder_children_role_bits"], 12);
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 12, "open_registration_enabled": true }),
&admin,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let json = get_settings_json(registry.clone(), auth_path.clone(), &admin).await;
assert_eq!(json["open_registration_enabled"], true);
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 14 }),
&owner,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let json = get_settings_json(registry, auth_path, &owner).await;
assert_eq!(json["reorder_children_role_bits"], 14);
}
#[tokio::test]
async fn instance_settings_reorder_mask_rejects_invalid_bits() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
for mask in [16u32, 13, 1u32 << 31] {
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": mask }),
&owner,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{mask}");
}
let json = get_settings_json(registry, auth_path, &owner).await;
assert_eq!(json["reorder_children_role_bits"], 12);
}
#[tokio::test]
async fn auth_me_reports_can_reorder_children() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let owner = make_test_session(&auth_path);
let plain = make_role_session(&auth_path, "plain", &[]);
let me = me_json(registry.clone(), auth_path.clone(), &owner).await;
assert_eq!(me["can_reorder_children"], true);
let me = me_json(registry.clone(), auth_path.clone(), &plain).await;
assert_eq!(me["can_reorder_children"], false);
let resp = app(registry.clone(), auth_path.clone())
.oneshot(patch_settings_request(
serde_json::json!({ "reorder_children_role_bits": 14 }),
&owner,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let me = me_json(registry, auth_path, &plain).await;
assert_eq!(me["can_reorder_children"], true);
}
#[tokio::test]
async fn auth_me_returns_humanize_slugs_false_by_default() {
let dir = tempfile::tempdir().unwrap();
@ -6950,24 +7485,7 @@ mod tests {
// 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()
};
let child = make_test_child(&archive_path, parent.id, "Child Entry", "https://example.com/child");
// 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,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,8 +6,8 @@
<title>Archivr</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<script type="module" crossorigin src="/assets/index-BxBKvOHp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1h0SqIvL.css">
<script type="module" crossorigin src="/assets/index-CKGxin5o.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CQKWq7vB.css">
</head>
<body>
<div id="root"></div>