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:
parent
8fc6754e28
commit
699eb7f62d
27 changed files with 1300 additions and 2477 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
49
crates/archivr-server/static/assets/index-CKGxin5o.js
Normal file
49
crates/archivr-server/static/assets/index-CKGxin5o.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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue