use anyhow::{Context, Result, bail}; use chrono::Utc; use rusqlite::{Connection, OptionalExtension, params}; use std::path::{Path, PathBuf}; use uuid::Uuid; pub const DATABASE_FILE_NAME: &str = "archivr.sqlite"; pub const DEFAULT_USERNAME: &str = "local-admin"; #[derive(Debug, Clone)] pub struct ArchiveRun { pub id: i64, pub run_uid: String, } #[derive(Debug, Clone)] pub struct ArchiveRunItem { pub id: i64, pub item_uid: String, } #[derive(Debug, Clone)] pub struct ArchivedEntry { pub id: i64, pub entry_uid: String, pub structured_root_relpath: String, } #[derive(Debug, Clone)] pub struct BlobRecord { pub sha256: String, pub byte_size: i64, pub mime_type: Option, pub extension: Option, pub raw_relpath: String, } #[derive(Debug, Clone)] pub struct NewEntry { pub source_identity_id: i64, pub archive_run_id: i64, pub parent_entry_id: Option, pub root_entry_id: Option, pub created_by_user_id: i64, pub owned_by_user_id: i64, pub source_kind: String, pub entity_kind: String, pub title: Option, pub visibility: String, pub representation_kind: String, pub source_metadata_json: String, pub display_metadata_json: Option, } #[derive(Debug, Clone)] pub struct NewArtifact { pub entry_id: i64, pub artifact_role: String, pub storage_area: String, pub relpath: String, pub blob_id: Option, pub logical_path: Option, pub metadata_json: Option, } #[derive(Debug, Clone)] pub struct TagRecord { pub id: i64, pub tag_uid: String, pub parent_tag_id: Option, pub name: String, pub slug: String, pub full_path: String, } #[derive(Debug, Clone)] pub struct AuthUserRecord { pub id: i64, pub user_uid: String, pub username: String, pub password_hash: String, pub status: String, } #[derive(Debug, Clone)] pub struct SessionRecord { pub user_id: i64, pub role_bits: u32, pub last_seen_at: String, pub session_uid: String, } #[derive(Debug, Clone, serde::Serialize)] pub struct ApiTokenRecord { pub token_uid: String, pub name: String, pub created_at: String, pub last_used_at: Option, } #[derive(Debug, Clone, serde::Serialize)] pub struct CaptureJobRecord { pub job_uid: String, pub archive_id: String, pub run_uid: Option, pub status: String, pub error_text: Option, pub notes_json: Option, pub created_at: String, pub updated_at: String, } #[derive(Debug, Clone, serde::Serialize)] pub struct UserSummary { pub user_uid: String, pub username: String, pub email: Option, pub status: String, pub created_at: String, pub role_slugs: Vec, pub role_bits: u32, } #[derive(Debug, Clone, serde::Serialize)] pub struct RoleRecord { pub role_uid: String, pub slug: String, pub name: String, pub level: i64, pub bit_position: i64, pub is_builtin: bool, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct InstanceSettings { pub public_index_enabled: bool, pub public_entry_content_enabled: bool, pub open_registration_enabled: bool, // maps to public_archive_submission_enabled column pub default_entry_visibility: u32, /// Global default for ad-blocking via uBlock Origin Lite during WebPage captures. /// Per-capture requests can override this. pub ublock_enabled: bool, /// Global default for cookie-consent banner dismissal via extension during WebPage captures. pub cookie_ext_enabled: bool, /// Global default for modal-closer browser-script behavior during WebPage captures. pub modal_closer_enabled: bool, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct CookieRule { pub rule_uid: String, pub url_pattern: Option, pub pattern_kind: String, pub cookies_json: String, pub ordinal: i64, pub created_at: String, } #[derive(Debug, Clone, serde::Serialize)] pub struct CollectionRecord { pub id: i64, pub collection_uid: String, pub name: String, pub slug: String, pub default_visibility_bits: u32, pub created_at: String, } pub fn database_path(archive_path: &Path) -> PathBuf { archive_path.join(DATABASE_FILE_NAME) } pub fn open_or_initialize(archive_path: &Path) -> Result { let conn = Connection::open(database_path(archive_path)).with_context(|| { format!( "failed to open archive database in {}", archive_path.display() ) })?; initialize_schema(&conn)?; Ok(conn) } pub fn initialize_schema(conn: &Connection) -> Result<()> { conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "foreign_keys", "ON")?; conn.execute_batch( r#" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, user_uid TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, email TEXT UNIQUE, password_hash TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), role TEXT NOT NULL CHECK (role IN ('admin', 'user')), created_at TEXT NOT NULL, last_login_at TEXT ); CREATE TABLE IF NOT EXISTS instance_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)), public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)), public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)), cookie_ext_enabled INTEGER NOT NULL DEFAULT 1 CHECK (cookie_ext_enabled IN (0, 1)) ); INSERT OR IGNORE INTO instance_settings ( id, public_index_enabled, public_entry_content_enabled, public_archive_submission_enabled ) VALUES (1, 0, 0, 0); CREATE TABLE IF NOT EXISTS archive_runs ( id INTEGER PRIMARY KEY, run_uid TEXT NOT NULL UNIQUE, created_by_user_id INTEGER NOT NULL REFERENCES users(id), started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL CHECK (status IN ('in_progress', 'completed', 'failed')), requested_count INTEGER NOT NULL DEFAULT 0, discovered_count INTEGER NOT NULL DEFAULT 0, completed_count INTEGER NOT NULL DEFAULT 0, failed_count INTEGER NOT NULL DEFAULT 0, error_summary TEXT ); CREATE TABLE IF NOT EXISTS archive_run_items ( id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL REFERENCES archive_runs(id) ON DELETE CASCADE, item_uid TEXT NOT NULL UNIQUE, parent_item_id INTEGER REFERENCES archive_run_items(id), ordinal INTEGER NOT NULL, requested_locator TEXT NOT NULL, canonical_locator TEXT, source_kind TEXT NOT NULL, entity_kind TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pending', 'in_progress', 'completed', 'failed')), error_text TEXT, produced_entry_id INTEGER REFERENCES archived_entries(id) ); CREATE TABLE IF NOT EXISTS source_identities ( id INTEGER PRIMARY KEY, source_kind TEXT NOT NULL, entity_kind TEXT NOT NULL, external_id TEXT, canonical_url TEXT, normalized_locator TEXT NOT NULL, identity_key TEXT NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS archived_entries ( id INTEGER PRIMARY KEY, entry_uid TEXT NOT NULL UNIQUE, source_identity_id INTEGER NOT NULL REFERENCES source_identities(id), archive_run_id INTEGER NOT NULL REFERENCES archive_runs(id), parent_entry_id INTEGER REFERENCES archived_entries(id), root_entry_id INTEGER REFERENCES archived_entries(id), created_by_user_id INTEGER NOT NULL REFERENCES users(id), owned_by_user_id INTEGER NOT NULL REFERENCES users(id), source_kind TEXT NOT NULL, entity_kind TEXT NOT NULL, title TEXT, visibility TEXT NOT NULL CHECK (visibility IN ('private', 'unlisted', 'public')), archived_at TEXT NOT NULL, original_published_at TEXT, structured_root_relpath TEXT NOT NULL, representation_kind TEXT NOT NULL, source_metadata_json TEXT NOT NULL DEFAULT '{}', display_metadata_json TEXT, cached_bytes INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS blobs ( id INTEGER PRIMARY KEY, sha256 TEXT NOT NULL UNIQUE, byte_size INTEGER NOT NULL, mime_type TEXT, extension TEXT, raw_relpath TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS entry_artifacts ( id INTEGER PRIMARY KEY, entry_id INTEGER NOT NULL REFERENCES archived_entries(id) ON DELETE CASCADE, artifact_role TEXT NOT NULL, storage_area TEXT NOT NULL CHECK (storage_area IN ('raw', 'raw_tweets', 'structured')), relpath TEXT NOT NULL, blob_id INTEGER REFERENCES blobs(id), logical_path TEXT, metadata_json TEXT ); CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY, tag_uid TEXT NOT NULL UNIQUE, parent_tag_id INTEGER REFERENCES tags(id), name TEXT NOT NULL, slug TEXT NOT NULL, full_path TEXT NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS entry_tag_assignments ( entry_id INTEGER NOT NULL REFERENCES archived_entries(id) ON DELETE CASCADE, tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (entry_id, tag_id) ); CREATE TABLE IF NOT EXISTS capture_jobs ( id INTEGER PRIMARY KEY, job_uid TEXT NOT NULL UNIQUE, archive_id TEXT NOT NULL, run_uid TEXT, status TEXT NOT NULL CHECK (status IN ('pending','running','completed','failed')) DEFAULT 'pending', error_text TEXT, notes_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_capture_jobs_status ON capture_jobs(status); CREATE INDEX IF NOT EXISTS idx_archive_run_items_run_id ON archive_run_items(run_id); CREATE INDEX IF NOT EXISTS idx_archived_entries_source_identity_id ON archived_entries(source_identity_id); CREATE INDEX IF NOT EXISTS idx_archived_entries_created_by_user_id ON archived_entries(created_by_user_id); CREATE INDEX IF NOT EXISTS idx_archived_entries_parent_entry_id ON archived_entries(parent_entry_id); CREATE INDEX IF NOT EXISTS idx_archived_entries_root_entry_id ON archived_entries(root_entry_id); CREATE INDEX IF NOT EXISTS idx_archived_entries_visibility ON archived_entries(visibility); CREATE INDEX IF NOT EXISTS idx_entry_artifacts_entry_id ON entry_artifacts(entry_id); CREATE INDEX IF NOT EXISTS idx_entry_artifacts_blob_id ON entry_artifacts(blob_id); CREATE INDEX IF NOT EXISTS idx_tags_parent_tag_id ON tags(parent_tag_id); CREATE INDEX IF NOT EXISTS idx_entry_tag_assignments_tag_id ON entry_tag_assignments(tag_id); CREATE TABLE IF NOT EXISTS collections ( id INTEGER PRIMARY KEY, collection_uid TEXT NOT NULL UNIQUE, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, default_visibility_bits INTEGER NOT NULL DEFAULT 2, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS collection_entries ( collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, entry_id INTEGER NOT NULL REFERENCES archived_entries(id) ON DELETE CASCADE, visibility_bits INTEGER NOT NULL DEFAULT 2, added_at TEXT NOT NULL, PRIMARY KEY (collection_id, entry_id) ); CREATE INDEX IF NOT EXISTS idx_collection_entries_entry_id ON collection_entries(entry_id); CREATE INDEX IF NOT EXISTS idx_collection_entries_collection_id ON collection_entries(collection_id); -- Seed default collection (idempotent) INSERT OR IGNORE INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) VALUES ('coll_default', 'All Entries', '_default_', 2, datetime('now')); -- Migrate existing entries to default collection (idempotent) INSERT OR IGNORE INTO collection_entries (collection_id, entry_id, visibility_bits, added_at) SELECT (SELECT id FROM collections WHERE slug = '_default_'), ae.id, CASE ae.visibility WHEN 'public' THEN 3 WHEN 'unlisted' THEN 2 ELSE 0 END, ae.archived_at FROM archived_entries ae; "#, )?; // Migration: add cached_bytes column to existing databases. // New databases already have it from the DDL above; the column check is // the idiomatic SQLite way to run a migration exactly once. let column_exists: bool = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('archived_entries') WHERE name = 'cached_bytes'", [], |row| row.get::<_, i64>(0), )? > 0; if !column_exists { conn.execute_batch( "ALTER TABLE archived_entries ADD COLUMN cached_bytes INTEGER NOT NULL DEFAULT 0; UPDATE archived_entries SET cached_bytes = ( SELECT COALESCE(SUM(b.byte_size), 0) FROM entry_artifacts ea JOIN blobs b ON b.id = ea.blob_id WHERE ea.entry_id = archived_entries.id AND ea.blob_id IS NOT NULL AND EXISTS ( SELECT 1 FROM entry_artifacts ea2 JOIN archived_entries e2 ON e2.id = ea2.entry_id WHERE ea2.blob_id = ea.blob_id AND (e2.archived_at < archived_entries.archived_at OR (e2.archived_at = archived_entries.archived_at AND e2.id < archived_entries.id)) ) );", )?; } // Migration: add notes_json column to existing capture_jobs tables. // Silently ignored when the column already exists (idempotent). let _ = conn.execute("ALTER TABLE capture_jobs ADD COLUMN notes_json TEXT", []); Ok(()) } pub fn initialize_auth_schema(conn: &Connection) -> Result<()> { conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "foreign_keys", "ON")?; conn.execute_batch( r#" CREATE TABLE IF NOT EXISTS roles ( id INTEGER PRIMARY KEY, role_uid TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, level INTEGER NOT NULL, bit_position INTEGER NOT NULL UNIQUE, is_builtin INTEGER NOT NULL DEFAULT 0 CHECK (is_builtin IN (0, 1)) ); INSERT OR IGNORE INTO roles (role_uid, slug, name, level, bit_position, is_builtin) VALUES ('role-guest', 'guest', 'Guest', 0, 0, 1), ('role-user', 'user', 'User', 1, 1, 1), ('role-admin', 'admin', 'Admin', 3, 2, 1), ('role-owner', 'owner', 'Owner', 4, 3, 1); CREATE TABLE IF NOT EXISTS user_roles ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id), assigned_at TEXT NOT NULL, assigned_by_user_id INTEGER REFERENCES users(id), PRIMARY KEY (user_id, role_id) ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY, session_uid TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_bits INTEGER NOT NULL, created_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, expires_at TEXT NOT NULL, user_agent TEXT ); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY, token_uid TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, name TEXT NOT NULL, created_at TEXT NOT NULL, last_used_at TEXT, expires_at TEXT ); CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); CREATE TABLE IF NOT EXISTS instance_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)), public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)), public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)), 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)) ); INSERT OR IGNORE INTO instance_settings (id, public_index_enabled, public_entry_content_enabled, public_archive_submission_enabled, default_entry_visibility) VALUES (1, 0, 0, 0, 2); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, user_uid TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, email TEXT UNIQUE, password_hash TEXT NOT NULL, display_name TEXT, status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), role TEXT NOT NULL CHECK (role IN ('admin', 'user')), created_at TEXT NOT NULL, last_login_at TEXT ); CREATE TABLE IF NOT EXISTS cookie_rules ( id INTEGER PRIMARY KEY, rule_uid TEXT NOT NULL UNIQUE, url_pattern TEXT, pattern_kind TEXT NOT NULL DEFAULT 'global', cookies_json TEXT NOT NULL DEFAULT '{}', ordinal INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); "#, )?; // Add display_name column to users if not present (idempotent migration) let _ = conn.execute("ALTER TABLE users ADD COLUMN display_name TEXT", []); // Add humanize_slugs column to users if not present (idempotent migration) let _ = conn.execute( "ALTER TABLE users ADD COLUMN humanize_slugs INTEGER NOT NULL DEFAULT 0", [], ); // Add ublock_enabled column to instance_settings if not present (idempotent migration) let _ = conn.execute( "ALTER TABLE instance_settings ADD COLUMN ublock_enabled INTEGER NOT NULL DEFAULT 1", [], ); // Add cookie_ext_enabled column to instance_settings if not present (idempotent migration) let _ = conn.execute( "ALTER TABLE instance_settings ADD COLUMN cookie_ext_enabled INTEGER NOT NULL DEFAULT 1", [], ); // Add modal_closer_enabled column to instance_settings if not present (idempotent migration) let _ = conn.execute( "ALTER TABLE instance_settings ADD COLUMN modal_closer_enabled INTEGER NOT NULL DEFAULT 1", [], ); Ok(()) } pub fn open_auth_db(auth_db_path: &Path) -> Result { if let Some(parent) = auth_db_path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create auth DB directory {}", parent.display()))?; } let conn = Connection::open(auth_db_path) .with_context(|| format!("failed to open auth database at {}", auth_db_path.display()))?; initialize_auth_schema(&conn)?; Ok(conn) } /// Returns true if an owner account exists. pub fn ensure_owner_exists(conn: &Connection) -> Result { let count: i64 = conn.query_row( "SELECT COUNT(*) FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE r.slug = 'owner'", [], |row| row.get(0), )?; Ok(count > 0) } /// Creates a user and assigns all roles from `user` up to `owner` (cumulative). /// `password_hash` must already be hashed by the caller. pub fn create_owner(conn: &Connection, username: &str, password_hash: &str) -> Result { let user_uid = public_id("usr"); conn.execute( "INSERT INTO users (user_uid, username, email, password_hash, status, role, created_at) VALUES (?1, ?2, NULL, ?3, 'active', 'admin', ?4)", params![user_uid, username, password_hash, now_timestamp()], )?; let user_id = conn.last_insert_rowid(); for slug in &["user", "admin", "owner"] { let role_id: i64 = conn.query_row("SELECT id FROM roles WHERE slug = ?1", [slug], |row| { row.get(0) })?; conn.execute( "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at) VALUES (?1, ?2, ?3)", params![user_id, role_id, now_timestamp()], )?; } Ok(user_id) } pub fn get_user_by_username(conn: &Connection, username: &str) -> Result> { conn.query_row( "SELECT id, user_uid, username, password_hash, status FROM users WHERE username = ?1", [username], |row| { Ok(AuthUserRecord { id: row.get(0)?, user_uid: row.get(1)?, username: row.get(2)?, password_hash: row.get(3)?, status: row.get(4)?, }) }, ) .optional() .map_err(Into::into) } /// Computes role_bits = ROLE_GUEST (1) | OR(assigned role bit values). pub fn compute_role_bits(conn: &Connection, user_id: i64) -> Result { let mut stmt = conn.prepare( "SELECT (1 << r.bit_position) FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?1", )?; let bits: u32 = stmt .query_map([user_id], |row| row.get::<_, i64>(0))? .try_fold(1u32, |acc, val| val.map(|v| acc | v as u32))?; Ok(bits) } /// Returns a new session_uid (UUID). pub fn create_session( conn: &Connection, user_id: i64, role_bits: u32, user_agent: Option<&str>, ) -> Result { let session_uid = public_id("sess"); let now = now_timestamp(); let expires_at = chrono::Utc::now() .checked_add_signed(chrono::Duration::days(30)) .unwrap() .to_rfc3339(); conn.execute( "INSERT INTO sessions (session_uid, user_id, role_bits, created_at, last_seen_at, expires_at, user_agent) VALUES (?1, ?2, ?3, ?4, ?4, ?5, ?6)", params![session_uid, user_id, role_bits as i64, now, expires_at, user_agent], )?; Ok(session_uid) } /// Returns session if it exists, the user is active, and it has not expired. pub fn get_session(conn: &Connection, session_uid: &str) -> Result> { let now = now_timestamp(); conn.query_row( "SELECT s.user_id, s.role_bits, s.last_seen_at, s.session_uid FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.session_uid = ?1 AND u.status = 'active' AND s.expires_at > ?2", params![session_uid, now], |row| { Ok(SessionRecord { user_id: row.get(0)?, role_bits: row.get::<_, i64>(1)? as u32, last_seen_at: row.get(2)?, session_uid: row.get(3)?, }) }, ) .optional() .map_err(Into::into) } pub fn delete_session(conn: &Connection, session_uid: &str) -> Result<()> { conn.execute("DELETE FROM sessions WHERE session_uid = ?1", [session_uid])?; Ok(()) } /// Updates last_seen_at and extends expires_at by 30 days. pub fn touch_session(conn: &Connection, session_uid: &str) -> Result<()> { let now = now_timestamp(); let new_expires = chrono::Utc::now() .checked_add_signed(chrono::Duration::days(30)) .unwrap() .to_rfc3339(); conn.execute( "UPDATE sessions SET last_seen_at = ?1, expires_at = ?2 WHERE session_uid = ?3", params![now, new_expires, session_uid], )?; Ok(()) } pub fn delete_expired_sessions(conn: &Connection) -> Result { let now = now_timestamp(); let n = conn.execute("DELETE FROM sessions WHERE expires_at <= ?1", [now])?; Ok(n) } /// Creates an API token. `token_hash` is SHA3-256 hex of the raw token. pub fn create_api_token( conn: &Connection, user_id: i64, token_hash: &str, name: &str, ) -> Result { let token_uid = public_id("tok"); conn.execute( "INSERT INTO api_tokens (token_uid, user_id, token_hash, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", params![token_uid, user_id, token_hash, name, now_timestamp()], )?; Ok(token_uid) } /// Returns the user_id for a given token hash, if the token is valid and user is active. pub fn get_user_for_token(conn: &Connection, token_hash: &str) -> Result> { let now = now_timestamp(); conn.query_row( "SELECT t.user_id FROM api_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ?1 AND u.status = 'active' AND (t.expires_at IS NULL OR t.expires_at > ?2)", params![token_hash, now], |row| row.get(0), ) .optional() .map_err(Into::into) } pub fn touch_token(conn: &Connection, token_uid: &str) -> Result<()> { conn.execute( "UPDATE api_tokens SET last_used_at = ?1 WHERE token_uid = ?2", params![now_timestamp(), token_uid], )?; Ok(()) } /// Returns true if the token was found and deleted (user_id must match). pub fn delete_api_token(conn: &Connection, token_uid: &str, user_id: i64) -> Result { let n = conn.execute( "DELETE FROM api_tokens WHERE token_uid = ?1 AND user_id = ?2", params![token_uid, user_id], )?; Ok(n > 0) } pub fn list_user_tokens(conn: &Connection, user_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT token_uid, name, created_at, last_used_at FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC", )?; let records = stmt .query_map([user_id], |row| { Ok(ApiTokenRecord { token_uid: row.get(0)?, name: row.get(1)?, created_at: row.get(2)?, last_used_at: row.get(3)?, }) })? .collect::, _>>()?; Ok(records) } pub fn get_instance_settings(conn: &Connection) -> Result { conn.query_row( "SELECT public_index_enabled, public_entry_content_enabled, public_archive_submission_enabled, default_entry_visibility, COALESCE(ublock_enabled, 1), COALESCE(cookie_ext_enabled, 1), COALESCE(modal_closer_enabled, 1) FROM instance_settings WHERE id = 1", [], |row| { Ok(InstanceSettings { public_index_enabled: row.get::<_, i64>(0)? != 0, public_entry_content_enabled: row.get::<_, i64>(1)? != 0, open_registration_enabled: row.get::<_, i64>(2)? != 0, default_entry_visibility: row.get::<_, i64>(3)? as u32, ublock_enabled: row.get::<_, i64>(4)? != 0, cookie_ext_enabled: row.get::<_, i64>(5)? != 0, modal_closer_enabled: row.get::<_, i64>(6)? != 0, }) }, ) .map_err(Into::into) } pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings) -> Result<()> { conn.execute( "UPDATE instance_settings SET public_index_enabled = ?1, public_entry_content_enabled = ?2, public_archive_submission_enabled = ?3, default_entry_visibility = ?4, ublock_enabled = ?5, cookie_ext_enabled = ?6, modal_closer_enabled = ?7 WHERE id = 1", params![ settings.public_index_enabled as i64, settings.public_entry_content_enabled as i64, settings.open_registration_enabled as i64, settings.default_entry_visibility as i64, settings.ublock_enabled as i64, settings.cookie_ext_enabled as i64, settings.modal_closer_enabled as i64, ], )?; Ok(()) } pub fn list_cookie_rules(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT rule_uid, url_pattern, pattern_kind, cookies_json, ordinal, created_at FROM cookie_rules ORDER BY ordinal ASC, created_at ASC", )?; let records = stmt .query_map([], |row| { Ok(CookieRule { rule_uid: row.get(0)?, url_pattern: row.get(1)?, pattern_kind: row.get(2)?, cookies_json: row.get(3)?, ordinal: row.get(4)?, created_at: row.get(5)?, }) })? .collect::, _>>()?; Ok(records) } pub fn create_cookie_rule( conn: &Connection, url_pattern: Option<&str>, pattern_kind: &str, cookies_json: &str, ) -> Result { let rule_uid = format!("cr_{}", Uuid::new_v4().simple()); let now = Utc::now().to_rfc3339(); let ordinal: i64 = conn.query_row( "SELECT COALESCE(MAX(ordinal), -1) + 1 FROM cookie_rules", [], |r| r.get(0), )?; conn.execute( "INSERT INTO cookie_rules (rule_uid, url_pattern, pattern_kind, cookies_json, ordinal, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![rule_uid, url_pattern, pattern_kind, cookies_json, ordinal, now], )?; Ok(CookieRule { rule_uid, url_pattern: url_pattern.map(str::to_string), pattern_kind: pattern_kind.to_string(), cookies_json: cookies_json.to_string(), ordinal, created_at: now, }) } pub fn update_cookie_rule( conn: &Connection, rule_uid: &str, url_pattern: Option<&str>, pattern_kind: &str, cookies_json: &str, ordinal: i64, ) -> Result<()> { let rows = conn.execute( "UPDATE cookie_rules SET url_pattern = ?1, pattern_kind = ?2, cookies_json = ?3, ordinal = ?4 WHERE rule_uid = ?5", params![url_pattern, pattern_kind, cookies_json, ordinal, rule_uid], )?; if rows == 0 { anyhow::bail!("cookie rule not found: {rule_uid}"); } Ok(()) } pub fn delete_cookie_rule(conn: &Connection, rule_uid: &str) -> Result<()> { let rows = conn.execute("DELETE FROM cookie_rules WHERE rule_uid = ?1", [rule_uid])?; if rows == 0 { anyhow::bail!("cookie rule not found: {rule_uid}"); } Ok(()) } pub fn get_user_password_hash(conn: &Connection, user_id: i64) -> Result> { conn.query_row( "SELECT password_hash FROM users WHERE id = ?1", [user_id], |r| r.get(0), ) .optional() .map_err(Into::into) } pub fn update_user_password(conn: &Connection, user_id: i64, new_hash: &str) -> Result<()> { conn.execute( "UPDATE users SET password_hash = ?1 WHERE id = ?2", params![new_hash, user_id], )?; Ok(()) } pub fn update_user_display_name( conn: &Connection, user_id: i64, display_name: Option<&str>, ) -> Result<()> { conn.execute( "UPDATE users SET display_name = ?1 WHERE id = ?2", params![display_name, user_id], )?; Ok(()) } pub fn update_user_humanize_slugs(conn: &Connection, user_id: i64, value: bool) -> Result<()> { conn.execute( "UPDATE users SET humanize_slugs = ?1 WHERE id = ?2", params![value as i64, user_id], )?; Ok(()) } /// Updates the user-visible title of an archived entry. /// Returns `Ok(true)` if a row was updated, `Ok(false)` if the entry_uid was not found. pub fn update_entry_title(conn: &Connection, entry_uid: &str, title: Option<&str>) -> Result { let n = conn.execute( "UPDATE archived_entries SET title = ?1 WHERE entry_uid = ?2", params![title, entry_uid], )?; Ok(n > 0) } pub fn get_user_display_name(conn: &Connection, user_id: i64) -> Result> { conn.query_row( "SELECT display_name FROM users WHERE id = ?1", [user_id], |r| r.get(0), ) .optional() .map_err(Into::into) } /// Deletes all sessions for a user. Called on ban or role change. pub fn invalidate_user_sessions(conn: &Connection, user_id: i64) -> Result { let n = conn.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?; Ok(n) } /// Returns the integer id for a user_uid, or None if not found. pub fn get_user_id_by_uid(conn: &Connection, user_uid: &str) -> Result> { conn.query_row( "SELECT id FROM users WHERE user_uid = ?1", [user_uid], |r| r.get(0), ) .optional() .map_err(Into::into) } /// Lists all users with their assigned roles and computed role_bits. pub fn list_users(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, user_uid, username, email, status, created_at FROM users ORDER BY created_at ASC", )?; let rows: Vec<(i64, String, String, Option, String, String)> = stmt .query_map([], |r| { Ok(( r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, )) })? .collect::>()?; rows.into_iter() .map(|(id, user_uid, username, email, status, created_at)| { let role_bits = compute_role_bits(conn, id)?; let mut rs = conn.prepare( "SELECT r.slug FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?1 ORDER BY r.level, r.bit_position", )?; let role_slugs: Vec = rs .query_map([id], |r| r.get(0))? .collect::>()?; Ok(UserSummary { user_uid, username, email, status, created_at, role_slugs, role_bits, }) }) .collect() } /// Gets a single user by user_uid with roles and role_bits. pub fn get_user_by_uid(conn: &Connection, user_uid: &str) -> Result> { let row = conn .query_row( "SELECT id, user_uid, username, email, status, created_at FROM users WHERE user_uid = ?1", [user_uid], |r| Ok((r.get::<_, i64>(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)), ) .optional()?; match row { None => Ok(None), Some((id, user_uid, username, email, status, created_at)) => { let role_bits = compute_role_bits(conn, id)?; let mut rs = conn.prepare( "SELECT r.slug FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?1 ORDER BY r.level, r.bit_position", )?; let role_slugs: Vec = rs .query_map([id], |r| r.get(0))? .collect::>()?; Ok(Some(UserSummary { user_uid, username, email, status, created_at, role_slugs, role_bits, })) } } } /// Creates a new user (admin-created account) and assigns the 'user' role. /// Returns the new user_uid. pub fn create_user( conn: &Connection, username: &str, email: Option<&str>, password_hash: &str, created_by_user_id: i64, ) -> Result { let user_uid = public_id("usr"); conn.execute( "INSERT INTO users (user_uid, username, email, password_hash, status, role, created_at) VALUES (?1, ?2, ?3, ?4, 'active', 'user', ?5)", params![user_uid, username, email, password_hash, now_timestamp()], )?; let user_id = conn.last_insert_rowid(); let role_id: i64 = conn.query_row("SELECT id FROM roles WHERE slug = 'user'", [], |r| r.get(0))?; conn.execute( "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at, assigned_by_user_id) VALUES (?1, ?2, ?3, ?4)", params![user_id, role_id, now_timestamp(), created_by_user_id], )?; Ok(user_uid) } /// Sets a user's status ('active' | 'disabled'). Invalidates sessions when disabling. /// Returns true if the user was found. pub fn set_user_status(conn: &Connection, user_uid: &str, status: &str) -> Result { if status == "disabled" { let id: Option = conn .query_row( "SELECT id FROM users WHERE user_uid = ?1", [user_uid], |r| r.get(0), ) .optional()?; if let Some(id) = id { invalidate_user_sessions(conn, id)?; } } let n = conn.execute( "UPDATE users SET status = ?1 WHERE user_uid = ?2", params![status, user_uid], )?; Ok(n > 0) } /// Assigns a role to a user (cumulative: also ensures 'user' for any non-guest role, /// and 'admin' for 'owner'). Invalidates the user's sessions so changes take effect on re-login. pub fn assign_role( conn: &Connection, target_user_id: i64, role_slug: &str, assigned_by_user_id: i64, ) -> Result<()> { let role_id: i64 = conn .query_row("SELECT id FROM roles WHERE slug = ?1", [role_slug], |r| { r.get(0) }) .map_err(|_| anyhow::anyhow!("role '{}' not found", role_slug))?; conn.execute( "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at, assigned_by_user_id) VALUES (?1, ?2, ?3, ?4)", params![ target_user_id, role_id, now_timestamp(), assigned_by_user_id ], )?; // Cumulative: ensure 'user' whenever any non-guest role is assigned if role_slug != "user" && role_slug != "guest" { let uid: i64 = conn.query_row("SELECT id FROM roles WHERE slug = 'user'", [], |r| r.get(0))?; conn.execute( "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at, assigned_by_user_id) VALUES (?1, ?2, ?3, ?4)", params![target_user_id, uid, now_timestamp(), assigned_by_user_id], )?; } // Also ensure 'admin' when assigning 'owner' if role_slug == "owner" { let aid: i64 = conn.query_row("SELECT id FROM roles WHERE slug = 'admin'", [], |r| { r.get(0) })?; conn.execute( "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at, assigned_by_user_id) VALUES (?1, ?2, ?3, ?4)", params![target_user_id, aid, now_timestamp(), assigned_by_user_id], )?; } invalidate_user_sessions(conn, target_user_id)?; Ok(()) } /// Removes a role from a user. Guards: can't remove the only owner's 'owner' role. /// Invalidates the user's sessions. pub fn remove_role(conn: &Connection, target_user_id: i64, role_slug: &str) -> Result<()> { if role_slug == "owner" { let count: i64 = conn.query_row( "SELECT COUNT(*) FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE r.slug = 'owner'", [], |r| r.get(0), )?; if count <= 1 { anyhow::bail!("cannot remove the last owner"); } } let role_id: i64 = conn .query_row("SELECT id FROM roles WHERE slug = ?1", [role_slug], |r| { r.get(0) }) .map_err(|_| anyhow::anyhow!("role '{}' not found", role_slug))?; conn.execute( "DELETE FROM user_roles WHERE user_id = ?1 AND role_id = ?2", params![target_user_id, role_id], )?; invalidate_user_sessions(conn, target_user_id)?; Ok(()) } /// Lists all roles ordered by level then bit_position. pub fn list_roles(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT role_uid, slug, name, level, bit_position, is_builtin FROM roles ORDER BY level, bit_position", )?; stmt.query_map([], |r| { Ok(RoleRecord { role_uid: r.get(0)?, slug: r.get(1)?, name: r.get(2)?, level: r.get(3)?, bit_position: r.get(4)?, is_builtin: r.get::<_, i64>(5)? != 0, }) })? .collect::>() .map_err(Into::into) } /// 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 { if slug.is_empty() || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { anyhow::bail!( "role slug must be non-empty and contain only ASCII letters, digits, or hyphens" ); } let next_bit: i64 = conn.query_row( "SELECT COALESCE(MAX(bit_position) + 1, 4) FROM roles WHERE bit_position >= 4", [], |r| r.get(0), )?; if next_bit >= 32 { anyhow::bail!("maximum number of custom roles reached"); } let role_uid = public_id("role"); conn.execute( "INSERT INTO roles (role_uid, slug, name, level, bit_position, is_builtin) VALUES (?1, ?2, ?3, 2, ?4, 0)", params![role_uid, slug, name, next_bit], )?; Ok(RoleRecord { role_uid, slug: slug.to_string(), name: name.to_string(), level: 2, bit_position: next_bit, is_builtin: false, }) } pub fn ensure_default_user(conn: &Connection) -> Result { if let Some(id) = conn .query_row( "SELECT id FROM users WHERE username = ?1", [DEFAULT_USERNAME], |row| row.get(0), ) .optional()? { return Ok(id); } conn.execute( "INSERT INTO users ( user_uid, username, email, password_hash, status, role, created_at, last_login_at ) VALUES (?1, ?2, NULL, ?3, 'active', 'admin', ?4, NULL)", params![ public_id("usr"), DEFAULT_USERNAME, "disabled-local-password", now_timestamp() ], )?; Ok(conn.last_insert_rowid()) } /// Creates a pending capture job. Returns the new `job_uid`. pub fn create_capture_job(conn: &Connection, archive_id: &str) -> Result { let job_uid = public_id("job"); let now = now_timestamp(); conn.execute( "INSERT INTO capture_jobs (job_uid, archive_id, run_uid, status, error_text, created_at, updated_at) VALUES (?1, ?2, NULL, 'pending', NULL, ?3, ?3)", rusqlite::params![job_uid, archive_id, now], )?; Ok(job_uid) } /// Updates the status (and optionally run_uid / error_text / notes_json) of a capture job. pub fn update_capture_job_status( conn: &Connection, job_uid: &str, status: &str, run_uid: Option<&str>, error_text: Option<&str>, notes_json: Option<&str>, ) -> Result<()> { let now = now_timestamp(); conn.execute( "UPDATE capture_jobs SET status = ?1, run_uid = COALESCE(?2, run_uid), error_text = ?3, notes_json = COALESCE(?4, notes_json), updated_at = ?5 WHERE job_uid = ?6", rusqlite::params![status, run_uid, error_text, notes_json, now, job_uid], )?; Ok(()) } /// Returns a capture job by uid. pub fn get_capture_job(conn: &Connection, job_uid: &str) -> Result> { conn.query_row( "SELECT job_uid, archive_id, run_uid, status, error_text, notes_json, created_at, updated_at FROM capture_jobs WHERE job_uid = ?1", [job_uid], |row| { Ok(CaptureJobRecord { job_uid: row.get(0)?, archive_id: row.get(1)?, run_uid: row.get(2)?, status: row.get(3)?, error_text: row.get(4)?, notes_json: row.get(5)?, created_at: row.get(6)?, updated_at: row.get(7)?, }) }, ) .optional() .map_err(Into::into) } /// Marks all interrupted capture jobs, runs, and run items as failed. /// Called at server startup to recover from a hard shutdown mid-capture. /// /// `capture_jobs.run_uid` is NULL when the server crashes before `perform_capture` /// returns, so we cannot join; instead we fail every `archive_runs` row still /// `in_progress` directly — any run that survived shutdown unfinished was interrupted. /// /// Returns the number of `capture_jobs` rows updated (used for the startup log). pub fn fail_stalled_capture_jobs(conn: &Connection) -> Result { let now = now_timestamp(); // 1. Fail in-progress run items. conn.execute( "UPDATE archive_run_items SET status = 'failed', error_text = 'interrupted by server restart' WHERE status = 'in_progress'", [], )?; // 2. Fail in-progress archive runs; recount failed items from the updated rows. conn.execute( "UPDATE archive_runs SET status = 'failed', finished_at = ?1, failed_count = ( SELECT COUNT(*) FROM archive_run_items WHERE run_id = archive_runs.id AND status = 'failed' ), error_summary = 'interrupted by server restart' WHERE status = 'in_progress'", [now.clone()], )?; // 3. Fail running capture jobs (the polling layer). let n = conn.execute( "UPDATE capture_jobs SET status = 'failed', error_text = 'interrupted by server restart', updated_at = ?1 WHERE status = 'running'", [now], )?; Ok(n) } pub fn create_archive_run( conn: &Connection, created_by_user_id: i64, requested_count: i64, ) -> Result { let run_uid = public_id("run"); conn.execute( "INSERT INTO archive_runs ( run_uid, created_by_user_id, started_at, status, requested_count ) VALUES (?1, ?2, ?3, 'in_progress', ?4)", params![ run_uid, created_by_user_id, now_timestamp(), requested_count ], )?; Ok(ArchiveRun { id: conn.last_insert_rowid(), run_uid, }) } pub fn create_archive_run_item( conn: &Connection, run_id: i64, parent_item_id: Option, ordinal: i64, requested_locator: &str, canonical_locator: Option<&str>, source_kind: &str, entity_kind: &str, ) -> Result { let item_uid = public_id("item"); conn.execute( "INSERT INTO archive_run_items ( run_id, item_uid, parent_item_id, ordinal, requested_locator, canonical_locator, source_kind, entity_kind, status ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'in_progress')", params![ run_id, item_uid, parent_item_id, ordinal, requested_locator, canonical_locator, source_kind, entity_kind ], )?; Ok(ArchiveRunItem { id: conn.last_insert_rowid(), item_uid, }) } pub fn complete_archive_run_item( conn: &Connection, item_id: i64, produced_entry_id: i64, ) -> Result<()> { conn.execute( "UPDATE archive_run_items SET status = 'completed', produced_entry_id = ?1, error_text = NULL WHERE id = ?2", params![produced_entry_id, item_id], )?; refresh_run_counters(conn, run_id_for_item(conn, item_id)?)?; Ok(()) } pub fn fail_archive_run_item(conn: &Connection, item_id: i64, error_text: &str) -> Result<()> { conn.execute( "UPDATE archive_run_items SET status = 'failed', error_text = ?1 WHERE id = ?2", params![error_text, item_id], )?; refresh_run_counters(conn, run_id_for_item(conn, item_id)?)?; Ok(()) } pub fn finish_archive_run(conn: &Connection, run_id: i64) -> Result<()> { refresh_run_counters(conn, run_id)?; let failed_count: i64 = conn.query_row( "SELECT failed_count FROM archive_runs WHERE id = ?1", [run_id], |row| row.get(0), )?; let status = if failed_count > 0 { "failed" } else { "completed" }; conn.execute( "UPDATE archive_runs SET status = ?1, finished_at = ?2 WHERE id = ?3", params![status, now_timestamp(), run_id], )?; Ok(()) } pub fn fail_archive_run(conn: &Connection, run_id: i64, error_summary: &str) -> Result<()> { refresh_run_counters(conn, run_id)?; conn.execute( "UPDATE archive_runs SET status = 'failed', finished_at = ?1, error_summary = ?2 WHERE id = ?3", params![now_timestamp(), error_summary, run_id], )?; Ok(()) } pub fn upsert_source_identity( conn: &Connection, source_kind: &str, entity_kind: &str, external_id: Option<&str>, canonical_url: Option<&str>, normalized_locator: &str, ) -> Result { let identity_key = identity_key( source_kind, entity_kind, external_id, canonical_url, normalized_locator, ); conn.execute( "INSERT OR IGNORE INTO source_identities ( source_kind, entity_kind, external_id, canonical_url, normalized_locator, identity_key ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ source_kind, entity_kind, external_id, canonical_url, normalized_locator, identity_key ], )?; let id = conn.query_row( "SELECT id FROM source_identities WHERE identity_key = ?1", [identity_key], |row| row.get(0), )?; Ok(id) } /// Computes and stores `cached_bytes` for a single entry. /// /// Must be called after all artifacts for the entry have been inserted so the /// correlated subquery sees the complete artifact set. Ordering by `archived_at` /// (tiebreak: `id`) matches the display ordering used in listings. pub fn refresh_entry_cached_bytes(conn: &Connection, entry_id: i64) -> Result<()> { let cached: i64 = conn.query_row( "SELECT COALESCE(SUM(b.byte_size), 0) FROM entry_artifacts ea JOIN blobs b ON b.id = ea.blob_id JOIN archived_entries e ON e.id = ea.entry_id WHERE ea.entry_id = ?1 AND ea.blob_id IS NOT NULL AND EXISTS ( SELECT 1 FROM entry_artifacts ea2 JOIN archived_entries e2 ON e2.id = ea2.entry_id WHERE ea2.blob_id = ea.blob_id AND (e2.archived_at < e.archived_at OR (e2.archived_at = e.archived_at AND e2.id < ?1)) )", [entry_id], |row| row.get(0), )?; conn.execute( "UPDATE archived_entries SET cached_bytes = ?1 WHERE id = ?2", params![cached, entry_id], )?; Ok(()) } /// Recomputes `cached_bytes` for entries that shared blobs with `entry_id` and /// were archived after it. /// /// Must be called **before** the entry row is deleted so that the shared-blob /// lookup still works. The inner EXISTS deliberately excludes `entry_id` so each /// affected entry is recomputed as if that entry no longer exists. /// /// Intended to be dispatched asynchronously: acknowledge the delete to the user /// first, then call this on a background thread. pub fn cascade_cached_bytes_after_delete(conn: &Connection, entry_id: i64) -> Result<()> { conn.execute( "UPDATE archived_entries SET cached_bytes = ( SELECT COALESCE(SUM(b.byte_size), 0) FROM entry_artifacts ea JOIN blobs b ON b.id = ea.blob_id WHERE ea.entry_id = archived_entries.id AND ea.blob_id IS NOT NULL AND EXISTS ( SELECT 1 FROM entry_artifacts ea3 JOIN archived_entries e3 ON e3.id = ea3.entry_id WHERE ea3.blob_id = ea.blob_id AND e3.id != ?1 AND (e3.archived_at < archived_entries.archived_at OR (e3.archived_at = archived_entries.archived_at AND e3.id < archived_entries.id)) ) ) WHERE id IN ( SELECT DISTINCT ea2.entry_id FROM entry_artifacts ea_del JOIN entry_artifacts ea2 ON ea2.blob_id = ea_del.blob_id JOIN archived_entries e_del ON e_del.id = ea_del.entry_id JOIN archived_entries e2 ON e2.id = ea2.entry_id WHERE ea_del.entry_id = ?1 AND ea2.entry_id != ?1 AND (e2.archived_at > e_del.archived_at OR (e2.archived_at = e_del.archived_at AND e2.id > ?1)) )", [entry_id], )?; Ok(()) } /// Recalculates `cached_bytes` for every entry that shares a blob with any member of /// `subtree_ids` and was archived after that member, treating the whole subtree as absent. /// /// Unlike `cascade_cached_bytes_after_delete` (single-entry), this excludes **all** subtree IDs /// from the EXISTS check in one SQL pass, so sibling entries don't falsely count each other as /// "still there" during the recalculation. /// /// Must be called before any subtree rows are deleted so the `entry_artifacts` JOIN resolves. fn cascade_cached_bytes_after_subtree_delete(conn: &Connection, subtree_ids: &[i64]) -> Result<()> { if subtree_ids.is_empty() { return Ok(()); } // Build ?1,?2,…,?N — positional params can be re-referenced multiple times in one statement. let ph: String = (1..=subtree_ids.len()) .map(|i| format!("?{i}")) .collect::>() .join(", "); let sql = format!( "UPDATE archived_entries SET cached_bytes = ( SELECT COALESCE(SUM(b.byte_size), 0) FROM entry_artifacts ea JOIN blobs b ON b.id = ea.blob_id WHERE ea.entry_id = archived_entries.id AND ea.blob_id IS NOT NULL AND EXISTS ( SELECT 1 FROM entry_artifacts ea3 JOIN archived_entries e3 ON e3.id = ea3.entry_id WHERE ea3.blob_id = ea.blob_id AND e3.id NOT IN ({ph}) AND (e3.archived_at < archived_entries.archived_at OR (e3.archived_at = archived_entries.archived_at AND e3.id < archived_entries.id)) ) ) WHERE id NOT IN ({ph}) AND id IN ( SELECT DISTINCT ea2.entry_id FROM entry_artifacts ea_sub JOIN entry_artifacts ea2 ON ea2.blob_id = ea_sub.blob_id JOIN archived_entries e_sub ON e_sub.id = ea_sub.entry_id JOIN archived_entries e2 ON e2.id = ea2.entry_id WHERE ea_sub.entry_id IN ({ph}) AND ea2.entry_id NOT IN ({ph}) AND (e2.archived_at > e_sub.archived_at OR (e2.archived_at = e_sub.archived_at AND e2.id > e_sub.id)) )" ); conn.execute(&sql, rusqlite::params_from_iter(subtree_ids.iter()))?; Ok(()) } /// Deletes an entry and every descendant in its tree (identified by `root_entry_id = entry_id`). /// /// Call order matters: /// 1. Collect all subtree IDs while the rows still exist. /// 2. Run `cascade_cached_bytes_after_subtree_delete` in one SQL pass that excludes the entire /// subtree — necessary so sibling blobs don't falsely satisfy the EXISTS check for each other. /// 3. NULL `archive_run_items.produced_entry_id` for every subtree entry (FK has no ON DELETE /// action; would otherwise block with `PRAGMA foreign_keys = ON`). /// 4. Delete children before root (self-referential `parent_entry_id` FK has no cascade). /// 5. Delete the root entry; CASCADE handles `entry_artifacts`, `entry_tag_assignments`, /// and `collection_entries` automatically. /// /// Returns `Ok(false)` if no entry with `entry_uid` was found; `Ok(true)` otherwise. /// Wrap in a transaction at the call site for atomicity. pub fn delete_entry(conn: &Connection, entry_uid: &str) -> Result { let entry_id: Option = conn .query_row( "SELECT id FROM archived_entries WHERE entry_uid = ?1", [entry_uid], |row| row.get(0), ) .optional()?; let entry_id = match entry_id { Some(id) => id, None => return Ok(false), }; // Collect the full subtree while rows still exist. let subtree_ids: Vec = { let mut stmt = conn.prepare("SELECT id FROM archived_entries WHERE root_entry_id = ?1")?; stmt.query_map([entry_id], |row| row.get(0))? .collect::>()? }; // One-pass set-aware cascade: recalculate cached_bytes for all external entries that // shared blobs with any subtree member, excluding every subtree ID simultaneously. cascade_cached_bytes_after_subtree_delete(conn, &subtree_ids)?; // Null the FK that has no ON DELETE action (covers root and all descendants). conn.execute( "UPDATE archive_run_items SET produced_entry_id = NULL WHERE produced_entry_id IN ( SELECT id FROM archived_entries WHERE root_entry_id = ?1 )", [entry_id], )?; // Children first — self-referential parent_entry_id FK has no cascade. conn.execute( "DELETE FROM archived_entries WHERE root_entry_id = ?1 AND id != ?1", [entry_id], )?; // Root entry: CASCADE handles entry_artifacts, entry_tag_assignments, collection_entries. conn.execute("DELETE FROM archived_entries WHERE id = ?1", [entry_id])?; Ok(true) } pub fn upsert_blob(conn: &Connection, blob: &BlobRecord) -> Result { conn.execute( "INSERT OR IGNORE INTO blobs ( sha256, byte_size, mime_type, extension, raw_relpath, created_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ blob.sha256, blob.byte_size, blob.mime_type, blob.extension, blob.raw_relpath, now_timestamp() ], )?; let id = conn.query_row( "SELECT id FROM blobs WHERE sha256 = ?1", [blob.sha256.as_str()], |row| row.get(0), )?; Ok(id) } /// Returns the `BlobRecord` for the given SHA-256 hex digest, or `None` if not found. pub fn get_blob_by_sha256(conn: &Connection, sha256: &str) -> Result> { conn.query_row( "SELECT sha256, byte_size, mime_type, extension, raw_relpath FROM blobs WHERE sha256 = ?1", [sha256], |row| { Ok(BlobRecord { sha256: row.get(0)?, byte_size: row.get(1)?, mime_type: row.get(2)?, extension: row.get(3)?, raw_relpath: row.get(4)?, }) }, ) .optional() .map_err(anyhow::Error::from) } /// Returns `true` if any capture job in this archive is `pending` or `running`. /// Call before scanning or deleting orphans: the capture pipeline moves files into /// `raw/` before writing the DB rows, so a mid-capture scan can falsely flag live files. pub fn has_active_capture_jobs(conn: &Connection) -> Result { let n: i64 = conn.query_row( "SELECT COUNT(*) FROM capture_jobs WHERE status IN ('pending', 'running')", [], |row| row.get(0), )?; Ok(n > 0) } /// Returns `(id, raw_relpath, byte_size)` for every blob row not referenced by any /// `entry_artifacts.blob_id`. These DB rows are safe to delete regardless of whether /// a disk file still exists at their `raw_relpath`. pub fn list_orphaned_blob_rows(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, raw_relpath, byte_size FROM blobs \ WHERE id NOT IN \ (SELECT DISTINCT blob_id FROM entry_artifacts WHERE blob_id IS NOT NULL)", )?; let rows = stmt .query_map([], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, )) })? .collect::>>()?; Ok(rows) } /// Returns the set of all file relpaths (relative to `store_path`) that are currently /// referenced by at least one live entry_artifact, either directly via /// `entry_artifacts.relpath` or indirectly via a live blob's `raw_relpath`. /// Any disk file whose relpath is in this set must NOT be deleted. pub fn all_referenced_file_relpaths( conn: &Connection, ) -> Result> { let mut set = std::collections::HashSet::new(); { let mut stmt = conn.prepare("SELECT DISTINCT relpath FROM entry_artifacts")?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { set.insert(row.get::<_, String>(0)?); } } { let mut stmt = conn.prepare( "SELECT DISTINCT raw_relpath FROM blobs \ WHERE id IN \ (SELECT DISTINCT blob_id FROM entry_artifacts WHERE blob_id IS NOT NULL)", )?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { set.insert(row.get::<_, String>(0)?); } } Ok(set) } /// Delete every blob row not referenced by any `entry_artifacts.blob_id`. /// Returns the number of rows deleted. pub fn delete_orphaned_blob_rows(conn: &Connection) -> Result { Ok(conn.execute( "DELETE FROM blobs WHERE id NOT IN \ (SELECT DISTINCT blob_id FROM entry_artifacts WHERE blob_id IS NOT NULL)", [], )?) } pub fn create_archived_entry(conn: &Connection, entry: &NewEntry) -> Result { validate_visibility(&entry.visibility)?; let entry_uid = public_id("entry"); let structured_root_relpath = format!("structured/{entry_uid}"); conn.execute( "INSERT INTO archived_entries ( 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 ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, NULL, ?13, ?14, ?15, ?16 )", params![ entry_uid, entry.source_identity_id, entry.archive_run_id, entry.parent_entry_id, entry.root_entry_id, entry.created_by_user_id, entry.owned_by_user_id, entry.source_kind, entry.entity_kind, entry.title, entry.visibility, now_timestamp(), structured_root_relpath, entry.representation_kind, entry.source_metadata_json, entry.display_metadata_json ], )?; let id = conn.last_insert_rowid(); if entry.root_entry_id.is_none() { conn.execute( "UPDATE archived_entries SET root_entry_id = ?1 WHERE id = ?1", [id], )?; } // Auto-enroll in the default collection with appropriate visibility_bits. let default_coll_id = ensure_default_collection(conn)?; let vbits = visibility_to_bits(&entry.visibility); add_entry_to_collection(conn, default_coll_id, id, vbits)?; Ok(ArchivedEntry { id, entry_uid, structured_root_relpath, }) } pub fn add_entry_artifact(conn: &Connection, artifact: &NewArtifact) -> Result { conn.execute( "INSERT INTO entry_artifacts ( entry_id, artifact_role, storage_area, relpath, blob_id, logical_path, metadata_json ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ artifact.entry_id, artifact.artifact_role, artifact.storage_area, artifact.relpath, artifact.blob_id, artifact.logical_path, artifact.metadata_json ], )?; Ok(conn.last_insert_rowid()) } pub fn remove_entry_tag_assignment(conn: &Connection, entry_id: i64, tag_id: i64) -> Result<()> { conn.execute( "DELETE FROM entry_tag_assignments WHERE entry_id = ?1 AND tag_id = ?2", params![entry_id, tag_id], )?; Ok(()) } pub fn list_all_tags(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, tag_uid, parent_tag_id, name, slug, full_path FROM tags ORDER BY full_path", )?; let records = stmt .query_map([], |row| { Ok(TagRecord { id: row.get(0)?, tag_uid: row.get(1)?, parent_tag_id: row.get(2)?, name: row.get(3)?, slug: row.get(4)?, full_path: row.get(5)?, }) })? .collect::, _>>() .context("failed to list tags")?; Ok(records) } pub fn list_tags_for_entry(conn: &Connection, entry_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT t.id, t.tag_uid, t.parent_tag_id, t.name, t.slug, t.full_path FROM tags t JOIN entry_tag_assignments eta ON eta.tag_id = t.id WHERE eta.entry_id = ?1 ORDER BY t.full_path", )?; let records = stmt .query_map([entry_id], |row| { Ok(TagRecord { id: row.get(0)?, tag_uid: row.get(1)?, parent_tag_id: row.get(2)?, name: row.get(3)?, slug: row.get(4)?, full_path: row.get(5)?, }) })? .collect::, _>>() .context("failed to list tags for entry")?; Ok(records) } pub fn get_tag_by_uid(conn: &Connection, tag_uid: &str) -> Result> { conn.query_row( "SELECT id, tag_uid, parent_tag_id, name, slug, full_path FROM tags WHERE tag_uid = ?1", [tag_uid], |row| { Ok(TagRecord { id: row.get(0)?, tag_uid: row.get(1)?, parent_tag_id: row.get(2)?, name: row.get(3)?, slug: row.get(4)?, full_path: row.get(5)?, }) }, ) .optional() .context("failed to get tag by uid") } pub fn get_tag_by_path(conn: &Connection, full_path: &str) -> Result> { conn.query_row( "SELECT id, tag_uid, parent_tag_id, name, slug, full_path FROM tags WHERE full_path = ?1", [full_path], |row| { Ok(TagRecord { id: row.get(0)?, tag_uid: row.get(1)?, parent_tag_id: row.get(2)?, name: row.get(3)?, slug: row.get(4)?, full_path: row.get(5)?, }) }, ) .optional() .context("failed to get tag by path") } #[cfg(test)] pub fn set_public_settings( conn: &Connection, public_index_enabled: bool, public_entry_content_enabled: bool, public_archive_submission_enabled: bool, ) -> Result<()> { conn.execute( "UPDATE instance_settings SET public_index_enabled = ?1, public_entry_content_enabled = ?2, public_archive_submission_enabled = ?3 WHERE id = 1", params![ public_index_enabled as i64, public_entry_content_enabled as i64, public_archive_submission_enabled as i64 ], )?; Ok(()) } #[cfg(test)] pub fn public_index_entry_count(conn: &Connection) -> Result { let count = conn.query_row( "SELECT COUNT(*) FROM archived_entries WHERE parent_entry_id IS NULL AND visibility = 'public' AND (SELECT public_index_enabled FROM instance_settings WHERE id = 1) = 1 AND (SELECT public_entry_content_enabled FROM instance_settings WHERE id = 1) = 1", [], |row| row.get(0), )?; Ok(count) } #[cfg(test)] pub fn main_archive_entry_count(conn: &Connection) -> Result { let count = conn.query_row( "SELECT COUNT(*) FROM archived_entries WHERE parent_entry_id IS NULL", [], |row| row.get(0), )?; Ok(count) } pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result { let raw_segments = normalized_tag_segments(full_path)?; // Slugify each segment consistently with rename_tag. let segments: Vec = raw_segments .into_iter() .map(slugify_segment) .collect::>>()?; let mut parent_tag_id: Option = None; let mut current_path = String::new(); let mut current_id: i64 = 0; for segment in &segments { current_path.push('/'); current_path.push_str(segment); if let Some(id) = conn .query_row( "SELECT id FROM tags WHERE full_path = ?1", [current_path.as_str()], |row| row.get(0), ) .optional()? { current_id = id; parent_tag_id = Some(id); continue; } conn.execute( "INSERT INTO tags (tag_uid, parent_tag_id, name, slug, full_path) VALUES (?1, ?2, ?3, ?4, ?5)", params![ public_id("tag"), parent_tag_id, humanize_slug(segment), segment.as_str(), current_path ], )?; current_id = conn.last_insert_rowid(); parent_tag_id = Some(current_id); } Ok(current_id) } pub fn assign_entry_to_tag(conn: &Connection, entry_id: i64, tag_id: i64) -> Result<()> { conn.execute( "INSERT OR IGNORE INTO entry_tag_assignments (entry_id, tag_id) VALUES (?1, ?2)", params![entry_id, tag_id], )?; Ok(()) } pub fn entry_count_for_tag_path(conn: &Connection, full_path: &str) -> Result { let count = conn.query_row( "WITH RECURSIVE descendants(id) AS ( SELECT id FROM tags WHERE full_path = ?1 UNION ALL SELECT child.id FROM tags child JOIN descendants parent ON child.parent_tag_id = parent.id ) SELECT COUNT(DISTINCT eta.entry_id) FROM entry_tag_assignments eta JOIN descendants d ON eta.tag_id = d.id", [full_path], |row| row.get(0), )?; Ok(count) } pub fn rename_tag( conn: &Connection, tag_uid: &str, new_segment: &str, ) -> Result> { let new_slug = slugify_segment(new_segment)?; // Fetch existing tag. let tag = match get_tag_by_uid(conn, tag_uid)? { Some(t) => t, None => return Ok(None), }; // Build new full_path by replacing the last segment. let old_prefix = tag.full_path.clone(); let parent_prefix = match old_prefix.rfind('/') { Some(idx) => &old_prefix[..idx], None => "", }; let new_full_path = format!("{}/{}", parent_prefix, new_slug); // Collision check: bail if another tag already owns this path. if let Some(existing) = get_tag_by_path(conn, &new_full_path)? { if existing.tag_uid != tag_uid { bail!("tag path already exists: {new_full_path}"); } } let new_name = humanize_slug(&new_slug); // Transaction: update the tag row, then cascade path change to descendants. let result = (|| -> Result<()> { conn.execute_batch("BEGIN")?; conn.execute( "UPDATE tags SET name=?1, slug=?2, full_path=?3 WHERE tag_uid=?4", params![new_name, new_slug, new_full_path, tag_uid], )?; let old_prefix_slash = format!("{}/", old_prefix); let new_prefix_slash = format!("{}/", new_full_path); // Replace the leading old_prefix_slash with new_prefix_slash across all // descendants. REPLACE(full_path, old, new) would corrupt paths where the // old prefix string appears again deeper in the tree (e.g. /foo/other/foo/bar). // Use prefix-anchored substitution instead: keep everything after the prefix // and prepend the new prefix. conn.execute( "WITH RECURSIVE descendants(id) AS (\ SELECT id FROM tags WHERE parent_tag_id = ?1 \ UNION ALL \ SELECT t.id FROM tags t JOIN descendants d ON t.parent_tag_id = d.id \ ) \ UPDATE tags SET full_path = ?3 || substr(full_path, length(?2) + 1) \ WHERE id IN (SELECT id FROM descendants)", params![tag.id, old_prefix_slash, new_prefix_slash], )?; conn.execute_batch("COMMIT")?; Ok(()) })(); if let Err(e) = result { let _ = conn.execute_batch("ROLLBACK"); return Err(e); } // Re-fetch the updated record. get_tag_by_uid(conn, tag_uid) } /// Deletes a tag and its entire descendant subtree. /// /// `entry_tag_assignments` rows are removed automatically via `ON DELETE CASCADE`. /// `parent_tag_id` has no cascade so a recursive CTE is used to collect the subtree /// before issuing a single DELETE. /// /// Returns `Ok(true)` if anything was deleted, `Ok(false)` if `tag_uid` was not found. pub fn delete_tag(conn: &Connection, tag_uid: &str) -> Result { let deleted = conn.execute( "WITH RECURSIVE subtree(id) AS ( SELECT id FROM tags WHERE tag_uid = ?1 UNION ALL SELECT t.id FROM tags t JOIN subtree s ON t.parent_tag_id = s.id ) DELETE FROM tags WHERE id IN (SELECT id FROM subtree)", [tag_uid], )?; Ok(deleted > 0) } /// Moves a tag and its entire subtree to a new parent. /// /// `new_parent_uid = None` promotes the tag to root level. /// Returns `Ok(None)` if `tag_uid` is not found. /// Returns an error if: /// - `new_parent_uid` refers to the tag itself or a descendant of it /// - the resulting path would collide with an existing tag /// - `new_parent_uid` is provided but not found pub fn move_tag( conn: &Connection, tag_uid: &str, new_parent_uid: Option<&str>, ) -> Result> { let tag = match get_tag_by_uid(conn, tag_uid)? { Some(t) => t, None => return Ok(None), }; let new_parent: Option = match new_parent_uid { Some(uid) => { let parent = match get_tag_by_uid(conn, uid)? { Some(p) => p, None => bail!("parent tag not found"), }; if parent.tag_uid == tag.tag_uid { bail!("cannot move a tag under itself"); } // Reject if the proposed parent is a descendant of the tag being moved. if parent.full_path.starts_with(&format!("{}/", tag.full_path)) { bail!("cannot move a tag under one of its own descendants"); } Some(parent) } None => None, }; let new_full_path = match &new_parent { Some(p) => format!("{}/{}", p.full_path, tag.slug), None => format!("/{}", tag.slug), }; // No-op when the path would not change. if new_full_path == tag.full_path { return Ok(Some(tag)); } // Collision check. if let Some(existing) = get_tag_by_path(conn, &new_full_path)? { if existing.tag_uid != tag_uid { bail!("a tag already exists at path: {new_full_path}"); } } let new_parent_id: Option = new_parent.as_ref().map(|p| p.id); let tag_id = tag.id; let old_prefix_slash = format!("{}/", tag.full_path); let new_prefix_slash = format!("{}/", new_full_path); let result = (|| -> Result<()> { conn.execute_batch("BEGIN")?; // Update the moved tag itself: new parent and new full_path. conn.execute( "UPDATE tags SET parent_tag_id = ?1, full_path = ?2 WHERE tag_uid = ?3", params![new_parent_id, new_full_path, tag_uid], )?; // Cascade the full_path prefix change to every descendant. // Use prefix-anchored substitution: ?3 || substr(full_path, length(?2) + 1) // rather than REPLACE, which would corrupt paths where the old prefix slug // appears again at a non-overlapping position deeper in the tree. conn.execute( "WITH RECURSIVE descendants(id) AS (\ SELECT id FROM tags WHERE parent_tag_id = ?1 \ UNION ALL \ SELECT t.id FROM tags t JOIN descendants d ON t.parent_tag_id = d.id \ ) \ UPDATE tags SET full_path = ?3 || substr(full_path, length(?2) + 1) \ WHERE id IN (SELECT id FROM descendants)", params![tag_id, old_prefix_slash, new_prefix_slash], )?; conn.execute_batch("COMMIT")?; Ok(()) })(); if let Err(e) = result { let _ = conn.execute_batch("ROLLBACK"); return Err(e); } get_tag_by_uid(conn, tag_uid) } fn refresh_run_counters(conn: &Connection, run_id: i64) -> Result<()> { conn.execute( "UPDATE archive_runs SET discovered_count = (SELECT COUNT(*) FROM archive_run_items WHERE run_id = ?1), completed_count = (SELECT COUNT(*) FROM archive_run_items WHERE run_id = ?1 AND status = 'completed'), failed_count = (SELECT COUNT(*) FROM archive_run_items WHERE run_id = ?1 AND status = 'failed') WHERE id = ?1", [run_id], )?; Ok(()) } /// Maps legacy visibility strings to collection_entries.visibility_bits. /// 'public'→3 (guest|user), 'unlisted'→2 (user only), 'private'→0 (nobody). pub fn visibility_to_bits(visibility: &str) -> u32 { match visibility { "public" => 3, "unlisted" => 2, _ => 0, } } /// Returns the id of the '_default_' collection, creating it if absent. pub fn ensure_default_collection(conn: &Connection) -> Result { let now = now_timestamp(); conn.execute( "INSERT OR IGNORE INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) \ VALUES ('coll_default', 'All Entries', '_default_', 2, ?1)", [&now], )?; let id: i64 = conn.query_row( "SELECT id FROM collections WHERE slug = '_default_'", [], |row| row.get(0), )?; Ok(id) } /// Creates a new collection. Returns the created record. pub fn create_collection( conn: &Connection, name: &str, slug: &str, default_visibility_bits: u32, ) -> Result { if slug.is_empty() || slug.starts_with('_') { anyhow::bail!("collection slug must be non-empty and not start with underscore"); } let collection_uid = public_id("coll"); let now = now_timestamp(); conn.execute( "INSERT INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5)", params![ collection_uid, name, slug, default_visibility_bits as i64, now ], )?; let id = conn.last_insert_rowid(); Ok(CollectionRecord { id, collection_uid, name: name.to_string(), slug: slug.to_string(), default_visibility_bits, created_at: now, }) } /// Lists all collections ordered by creation date. pub fn list_collections(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \ FROM collections ORDER BY created_at ASC", )?; stmt.query_map([], |row| { Ok(CollectionRecord { id: row.get(0)?, collection_uid: row.get(1)?, name: row.get(2)?, slug: row.get(3)?, default_visibility_bits: row.get::<_, i64>(4)? as u32, created_at: row.get(5)?, }) })? .collect::>() .map_err(Into::into) } /// Returns a collection by its uid, or None if not found. pub fn get_collection_by_uid(conn: &Connection, uid: &str) -> Result> { conn.query_row( "SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \ FROM collections WHERE collection_uid = ?1", [uid], |row| { Ok(CollectionRecord { id: row.get(0)?, collection_uid: row.get(1)?, name: row.get(2)?, slug: row.get(3)?, default_visibility_bits: row.get::<_, i64>(4)? as u32, created_at: row.get(5)?, }) }, ) .optional() .map_err(Into::into) } /// Adds an entry to a collection with given visibility_bits. Idempotent (INSERT OR IGNORE). pub fn add_entry_to_collection( conn: &Connection, collection_id: i64, entry_id: i64, visibility_bits: u32, ) -> Result<()> { let now = now_timestamp(); conn.execute( "INSERT OR IGNORE INTO collection_entries (collection_id, entry_id, visibility_bits, added_at) \ VALUES (?1, ?2, ?3, ?4)", params![collection_id, entry_id, visibility_bits as i64, now], )?; Ok(()) } /// Updates the visibility_bits of an entry in a collection. Returns true if updated. pub fn update_collection_entry_visibility( conn: &Connection, collection_id: i64, entry_id: i64, visibility_bits: u32, ) -> Result { let n = conn.execute( "UPDATE collection_entries SET visibility_bits = ?1 \ WHERE collection_id = ?2 AND entry_id = ?3", params![visibility_bits as i64, collection_id, entry_id], )?; Ok(n > 0) } /// Removes an entry from a collection. Returns true if removed. pub fn remove_entry_from_collection( conn: &Connection, collection_id: i64, entry_id: i64, ) -> Result { let n = conn.execute( "DELETE FROM collection_entries WHERE collection_id = ?1 AND entry_id = ?2", params![collection_id, entry_id], )?; Ok(n > 0) } /// Returns (collection_id, collection_uid, visibility_bits) for all collections containing an entry. pub fn get_entry_collection_memberships( conn: &Connection, entry_id: i64, ) -> Result> { let mut stmt = conn.prepare( "SELECT ce.collection_id, c.collection_uid, ce.visibility_bits \ FROM collection_entries ce \ JOIN collections c ON c.id = ce.collection_id \ WHERE ce.entry_id = ?1", )?; stmt.query_map([entry_id], |row| { Ok((row.get(0)?, row.get(1)?, row.get::<_, i64>(2)? as u32)) })? .collect::>() .map_err(Into::into) } /// Renames a collection and/or updates its default_visibility_bits. /// Returns true if updated, false if not found. /// Refuses to rename the '_default_' collection. pub fn update_collection( conn: &Connection, collection_uid: &str, new_name: Option<&str>, new_visibility_bits: Option, ) -> Result { let coll = get_collection_by_uid(conn, collection_uid)?; let Some(coll) = coll else { return Ok(false) }; if coll.slug == "_default_" { anyhow::bail!("cannot modify the default collection"); } let name = new_name.unwrap_or(&coll.name); let vbits = new_visibility_bits.unwrap_or(coll.default_visibility_bits); conn.execute( "UPDATE collections SET name = ?1, default_visibility_bits = ?2 WHERE id = ?3", params![name, vbits as i64, coll.id], )?; Ok(true) } /// Deletes a collection and cascades to collection_entries. /// Returns true if deleted, false if not found. /// Refuses to delete the '_default_' collection. pub fn delete_collection(conn: &Connection, collection_uid: &str) -> Result { let coll = get_collection_by_uid(conn, collection_uid)?; let Some(coll) = coll else { return Ok(false) }; if coll.slug == "_default_" { anyhow::bail!("cannot delete the default collection"); } conn.execute("DELETE FROM collections WHERE id = ?1", [coll.id])?; Ok(true) } fn run_id_for_item(conn: &Connection, item_id: i64) -> Result { let run_id = conn.query_row( "SELECT run_id FROM archive_run_items WHERE id = ?1", [item_id], |row| row.get(0), )?; Ok(run_id) } fn public_id(prefix: &str) -> String { format!("{prefix}_{}", Uuid::new_v4().simple()) } fn now_timestamp() -> String { Utc::now().to_rfc3339() } fn identity_key( source_kind: &str, entity_kind: &str, external_id: Option<&str>, canonical_url: Option<&str>, normalized_locator: &str, ) -> String { let stable_locator = external_id.or(canonical_url).unwrap_or(normalized_locator); format!("{source_kind}:{entity_kind}:{stable_locator}") } fn validate_visibility(visibility: &str) -> Result<()> { match visibility { "private" | "unlisted" | "public" => Ok(()), _ => bail!("invalid archived entry visibility: {visibility}"), } } fn normalized_tag_segments(full_path: &str) -> Result> { let segments = full_path .trim() .trim_matches('/') .split('/') .filter(|segment| !segment.is_empty()) .collect::>(); if segments.is_empty() { bail!("tag path must contain at least one segment"); } Ok(segments) } fn humanize_slug(slug: &str) -> String { slug.split('-') .map(|part| { let mut chars = part.chars(); match chars.next() { Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), None => String::new(), } }) .collect::>() .join(" ") } /// Converts a raw input string into a valid tag slug: /// spaces → hyphens, strip non-(alphanumeric|hyphen), collapse runs, trim edge hyphens. /// Returns an error if the result is empty. fn slugify_segment(input: &str) -> Result { let hyphenated: String = input .trim() .chars() .map(|c| if c == ' ' { '-' } else { c }) .collect(); let filtered: String = hyphenated .chars() .filter(|c| c.is_alphanumeric() || *c == '-') .collect(); let mut slug = String::new(); let mut prev_hyphen = false; for c in filtered.chars() { if c == '-' { if !prev_hyphen { slug.push(c); } prev_hyphen = true; } else { slug.push(c); prev_hyphen = false; } } let slug = slug.trim_matches('-').to_string(); if slug.is_empty() { bail!("segment slugifies to empty string"); } Ok(slug) } /// A minimal view of an archived entry needed for re-archiving. pub struct EntryForRearchive { pub id: i64, pub entity_kind: String, pub source_metadata_json: String, } /// Looks up an entry by its public UID for re-archive purposes. pub fn get_entry_for_rearchive( conn: &Connection, entry_uid: &str, ) -> Result> { conn.query_row( "SELECT id, entity_kind, source_metadata_json \ FROM archived_entries WHERE entry_uid = ?1", [entry_uid], |row| { Ok(EntryForRearchive { id: row.get(0)?, entity_kind: row.get(1)?, source_metadata_json: row.get(2)?, }) }, ) .optional() .map_err(anyhow::Error::from) } /// Deletes all `entry_artifacts` rows for the given entry. /// Call within a transaction for atomicity with re-insertion. pub fn delete_entry_artifacts(conn: &Connection, entry_id: i64) -> Result { Ok(conn.execute( "DELETE FROM entry_artifacts WHERE entry_id = ?1", [entry_id], )?) } #[cfg(test)] mod tests { use super::*; use std::{ env, fs, time::{SystemTime, UNIX_EPOCH}, }; fn conn() -> Connection { let conn = Connection::open_in_memory().unwrap(); initialize_schema(&conn).unwrap(); conn } fn unique_db_path(prefix: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); env::temp_dir().join(format!("{prefix}-{nanos}-{}.sqlite", std::process::id())) } fn create_entry_fixture( conn: &Connection, visibility: &str, parent_entry_id: Option, root_entry_id: Option, ) -> ArchivedEntry { let user_id = ensure_default_user(conn).unwrap(); let run = create_archive_run(conn, user_id, 1).unwrap(); let source_id = upsert_source_identity( conn, "youtube", "video", Some("video-1"), Some("https://youtube.com/watch?v=video-1"), "https://youtube.com/watch?v=video-1", ) .unwrap(); create_archived_entry( conn, &NewEntry { source_identity_id: source_id, archive_run_id: run.id, parent_entry_id, root_entry_id, created_by_user_id: user_id, owned_by_user_id: user_id, source_kind: "youtube".to_string(), entity_kind: "video".to_string(), title: None, visibility: visibility.to_string(), representation_kind: "video".to_string(), source_metadata_json: "{}".to_string(), display_metadata_json: None, }, ) .unwrap() } #[test] fn schema_defaults_public_settings_to_private() { let conn = conn(); let defaults: (i64, i64, i64) = conn .query_row( "SELECT public_index_enabled, public_entry_content_enabled, public_archive_submission_enabled FROM instance_settings WHERE id = 1", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .unwrap(); assert_eq!(defaults, (0, 0, 0)); } #[test] fn file_database_uses_wal_journal_mode() { let path = unique_db_path("archivr-wal-test"); let conn = Connection::open(&path).unwrap(); initialize_schema(&conn).unwrap(); let journal_mode: String = conn .query_row("PRAGMA journal_mode", [], |row| row.get(0)) .unwrap(); assert_eq!(journal_mode, "wal"); drop(conn); let _ = fs::remove_file(&path); let _ = fs::remove_file(path.with_extension("sqlite-wal")); let _ = fs::remove_file(path.with_extension("sqlite-shm")); } #[test] fn root_entry_sets_root_id_after_insert() { let conn = conn(); let entry = create_entry_fixture(&conn, "private", None, None); let root_entry_id: i64 = conn .query_row( "SELECT root_entry_id FROM archived_entries WHERE id = ?1", [entry.id], |row| row.get(0), ) .unwrap(); assert_eq!(root_entry_id, entry.id); } #[test] fn rearchiving_reuses_source_identity_and_blob_but_creates_entries() { let conn = conn(); let user_id = ensure_default_user(&conn).unwrap(); let blob = BlobRecord { sha256: "abc123".to_string(), byte_size: 123, mime_type: Some("video/mp4".to_string()), extension: Some("mp4".to_string()), raw_relpath: "raw/a/b/abc123.mp4".to_string(), }; let blob_id = upsert_blob(&conn, &blob).unwrap(); let duplicate_blob_id = upsert_blob(&conn, &blob).unwrap(); assert_eq!(blob_id, duplicate_blob_id); let first_source_id = upsert_source_identity( &conn, "youtube", "video", Some("video-1"), Some("https://youtube.com/watch?v=video-1"), "https://youtube.com/watch?v=video-1", ) .unwrap(); let second_source_id = upsert_source_identity( &conn, "youtube", "video", Some("video-1"), Some("https://youtube.com/watch?v=video-1"), "https://youtube.com/watch?v=video-1", ) .unwrap(); assert_eq!(first_source_id, second_source_id); for _ in 0..2 { let run = create_archive_run(&conn, user_id, 1).unwrap(); let entry = create_archived_entry( &conn, &NewEntry { source_identity_id: first_source_id, archive_run_id: run.id, parent_entry_id: None, root_entry_id: None, created_by_user_id: user_id, owned_by_user_id: user_id, source_kind: "youtube".to_string(), entity_kind: "video".to_string(), title: None, visibility: "private".to_string(), representation_kind: "video".to_string(), source_metadata_json: "{}".to_string(), display_metadata_json: None, }, ) .unwrap(); add_entry_artifact( &conn, &NewArtifact { entry_id: entry.id, artifact_role: "primary_media".to_string(), storage_area: "raw".to_string(), relpath: blob.raw_relpath.clone(), blob_id: Some(blob_id), logical_path: None, metadata_json: None, }, ) .unwrap(); } let entry_count: i64 = conn .query_row("SELECT COUNT(*) FROM archived_entries", [], |row| { row.get(0) }) .unwrap(); let source_count: i64 = conn .query_row("SELECT COUNT(*) FROM source_identities", [], |row| { row.get(0) }) .unwrap(); let blob_count: i64 = conn .query_row("SELECT COUNT(*) FROM blobs", [], |row| row.get(0)) .unwrap(); assert_eq!(entry_count, 2); assert_eq!(source_count, 1); assert_eq!(blob_count, 1); } #[test] fn source_identity_key_prefers_external_id_over_shared_canonical_url() { let conn = conn(); let first_source_id = upsert_source_identity( &conn, "x", "tweet", Some("tweet-1"), Some("https://x.com/some-profile"), "https://x.com/some-profile/status/tweet-1", ) .unwrap(); let second_source_id = upsert_source_identity( &conn, "x", "tweet", Some("tweet-2"), Some("https://x.com/some-profile"), "https://x.com/some-profile/status/tweet-2", ) .unwrap(); assert_ne!(first_source_id, second_source_id); } #[test] fn run_items_refresh_progress_counters() { let conn = conn(); let user_id = ensure_default_user(&conn).unwrap(); let run = create_archive_run(&conn, user_id, 2).unwrap(); let source_id = upsert_source_identity(&conn, "local", "file", None, None, "file:///a").unwrap(); let entry = create_archived_entry( &conn, &NewEntry { source_identity_id: source_id, archive_run_id: run.id, parent_entry_id: None, root_entry_id: None, created_by_user_id: user_id, owned_by_user_id: user_id, source_kind: "local".to_string(), entity_kind: "file".to_string(), title: None, visibility: "private".to_string(), representation_kind: "file".to_string(), source_metadata_json: "{}".to_string(), display_metadata_json: None, }, ) .unwrap(); let first = create_archive_run_item(&conn, run.id, None, 0, "file:///a", None, "local", "file") .unwrap(); let second = create_archive_run_item(&conn, run.id, None, 1, "file:///b", None, "local", "file") .unwrap(); complete_archive_run_item(&conn, first.id, entry.id).unwrap(); fail_archive_run_item(&conn, second.id, "copy failed").unwrap(); finish_archive_run(&conn, run.id).unwrap(); let counters: (i64, i64, i64, String) = conn .query_row( "SELECT discovered_count, completed_count, failed_count, status FROM archive_runs WHERE id = ?1", [run.id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .unwrap(); assert_eq!(counters, (2, 1, 1, "failed".to_string())); } #[test] fn main_archive_query_only_counts_roots() { let conn = conn(); let parent = create_entry_fixture(&conn, "private", None, None); let _child = create_entry_fixture(&conn, "private", Some(parent.id), Some(parent.id)); assert_eq!(main_archive_entry_count(&conn).unwrap(), 1); } #[test] fn public_entries_require_instance_flags_and_public_visibility() { let conn = conn(); let _public = create_entry_fixture(&conn, "public", None, None); let _private = create_entry_fixture(&conn, "private", None, None); assert_eq!(public_index_entry_count(&conn).unwrap(), 0); set_public_settings(&conn, true, false, false).unwrap(); assert_eq!(public_index_entry_count(&conn).unwrap(), 0); set_public_settings(&conn, true, true, false).unwrap(); assert_eq!(public_index_entry_count(&conn).unwrap(), 1); } #[test] fn hierarchical_tag_assignments_are_discoverable_through_ancestors() { let conn = conn(); let entry = create_entry_fixture(&conn, "private", None, None); let tag_id = create_tag_path(&conn, "/sciences/computer-science/compilers").unwrap(); assign_entry_to_tag(&conn, entry.id, tag_id).unwrap(); assert_eq!( entry_count_for_tag_path(&conn, "/sciences/computer-science/compilers").unwrap(), 1 ); assert_eq!( entry_count_for_tag_path(&conn, "/sciences/computer-science").unwrap(), 1 ); assert_eq!(entry_count_for_tag_path(&conn, "/sciences").unwrap(), 1); } #[test] fn get_blob_by_sha256_round_trips() { let conn = conn(); let blob = BlobRecord { sha256: "deadbeef01234567".repeat(4), // 64-char hex string byte_size: 1234, mime_type: Some("font/woff2".to_string()), extension: Some("woff2".to_string()), raw_relpath: "raw/d/e/deadbeef.woff2".to_string(), }; upsert_blob(&conn, &blob).unwrap(); let found = get_blob_by_sha256(&conn, &blob.sha256).unwrap(); assert!(found.is_some(), "should find the blob we just upserted"); let found = found.unwrap(); assert_eq!(found.sha256, blob.sha256); assert_eq!(found.byte_size, 1234); assert_eq!(found.mime_type, Some("font/woff2".to_string())); assert_eq!(found.raw_relpath, blob.raw_relpath); } #[test] fn get_blob_by_sha256_returns_none_for_unknown() { let conn = conn(); let result = get_blob_by_sha256( &conn, "0000000000000000000000000000000000000000000000000000000000000000", ) .unwrap(); assert!(result.is_none()); } #[test] fn auth_schema_seeds_builtin_roles() { let conn = Connection::open_in_memory().unwrap(); initialize_auth_schema(&conn).unwrap(); let count: i64 = conn .query_row("SELECT COUNT(*) FROM roles WHERE is_builtin = 1", [], |r| { r.get(0) }) .unwrap(); assert_eq!(count, 4); let owner_bits: i64 = conn .query_row( "SELECT bit_position FROM roles WHERE slug = 'owner'", [], |r| r.get(0), ) .unwrap(); assert_eq!(owner_bits, 3); } #[test] fn auth_schema_is_idempotent() { let conn = Connection::open_in_memory().unwrap(); initialize_auth_schema(&conn).unwrap(); initialize_auth_schema(&conn).unwrap(); } fn make_auth_conn() -> Connection { let conn = Connection::open_in_memory().unwrap(); initialize_auth_schema(&conn).unwrap(); conn } #[test] fn ensure_owner_exists_returns_false_when_no_owner() { let conn = make_auth_conn(); assert!(!ensure_owner_exists(&conn).unwrap()); } #[test] fn create_owner_then_ensure_returns_true() { let conn = make_auth_conn(); create_owner(&conn, "alice", "hashed_pw").unwrap(); assert!(ensure_owner_exists(&conn).unwrap()); } #[test] fn create_owner_assigns_cumulative_roles() { let conn = make_auth_conn(); let user_id = create_owner(&conn, "alice", "hashed_pw").unwrap(); let bits = compute_role_bits(&conn, user_id).unwrap(); assert_eq!(bits, 15u32); } #[test] fn get_user_by_username_returns_none_for_unknown() { let conn = make_auth_conn(); assert!(get_user_by_username(&conn, "nobody").unwrap().is_none()); } #[test] fn create_and_get_session() { let conn = make_auth_conn(); let user_id = create_owner(&conn, "alice", "pw").unwrap(); let uid = create_session(&conn, user_id, 15, None).unwrap(); let sess = get_session(&conn, &uid).unwrap().unwrap(); assert_eq!(sess.user_id, user_id); assert_eq!(sess.role_bits, 15); } #[test] fn get_session_returns_none_for_unknown() { let conn = make_auth_conn(); assert!(get_session(&conn, "nonexistent").unwrap().is_none()); } #[test] fn delete_session_removes_it() { let conn = make_auth_conn(); let user_id = create_owner(&conn, "alice", "pw").unwrap(); let uid = create_session(&conn, user_id, 15, None).unwrap(); delete_session(&conn, &uid).unwrap(); assert!(get_session(&conn, &uid).unwrap().is_none()); } #[test] fn token_hash_round_trips() { let conn = make_auth_conn(); let user_id = create_owner(&conn, "alice", "pw").unwrap(); create_api_token(&conn, user_id, "hash_abc", "My Token").unwrap(); let found_id = get_user_for_token(&conn, "hash_abc").unwrap(); assert_eq!(found_id, Some(user_id)); } #[test] fn get_user_for_token_returns_none_for_unknown() { let conn = make_auth_conn(); assert!(get_user_for_token(&conn, "unknown").unwrap().is_none()); } #[test] fn capture_job_create_and_get() { let conn = conn(); let job_uid = create_capture_job(&conn, "personal").unwrap(); let job = get_capture_job(&conn, &job_uid).unwrap().unwrap(); assert_eq!(job.status, "pending"); assert_eq!(job.archive_id, "personal"); assert!(job.run_uid.is_none()); } #[test] fn capture_job_status_transitions() { let conn = conn(); let job_uid = create_capture_job(&conn, "test").unwrap(); update_capture_job_status(&conn, &job_uid, "running", None, None, None).unwrap(); update_capture_job_status(&conn, &job_uid, "completed", Some("run_abc"), None, None) .unwrap(); let job = get_capture_job(&conn, &job_uid).unwrap().unwrap(); assert_eq!(job.status, "completed"); assert_eq!(job.run_uid.as_deref(), Some("run_abc")); } #[test] fn fail_stalled_jobs_on_restart() { let conn = conn(); // Simulate an in-progress capture_job (run_uid still NULL — common crash case). let uid = create_capture_job(&conn, "test").unwrap(); update_capture_job_status(&conn, &uid, "running", None, None, None).unwrap(); // Simulate an in-progress archive_run and item with no associated capture_job // (covers the case where run_uid was never written back before the crash). let user_id = ensure_default_user(&conn).unwrap(); let run = create_archive_run(&conn, user_id, 1).unwrap(); create_archive_run_item( &conn, run.id, None, 0, "https://example.com", None, "web", "file", ) .unwrap(); let n = fail_stalled_capture_jobs(&conn).unwrap(); assert_eq!(n, 1); // one capture_job updated // capture_job is failed let job = get_capture_job(&conn, &uid).unwrap().unwrap(); assert_eq!(job.status, "failed"); assert!(job.error_text.as_deref().unwrap().contains("interrupted")); // archive_run is failed let updated_run: String = conn .query_row( "SELECT status FROM archive_runs WHERE id = ?1", [run.id], |r| r.get(0), ) .unwrap(); assert_eq!(updated_run, "failed"); // archive_run_item is failed let item_status: String = conn .query_row( "SELECT status FROM archive_run_items WHERE run_id = ?1", [run.id], |r| r.get(0), ) .unwrap(); assert_eq!(item_status, "failed"); } fn make_auth_conn_for_mgmt() -> Connection { let conn = Connection::open_in_memory().unwrap(); initialize_auth_schema(&conn).unwrap(); conn } #[test] fn user_create_and_list() { let conn = make_auth_conn_for_mgmt(); let owner_id = create_owner(&conn, "owner", "hash").unwrap(); let uid = create_user(&conn, "alice", Some("alice@example.com"), "hash2", owner_id).unwrap(); let users = list_users(&conn).unwrap(); assert_eq!(users.len(), 2); let alice = users.iter().find(|u| u.username == "alice").unwrap(); assert_eq!(alice.user_uid, uid); assert_eq!(alice.status, "active"); assert!(alice.role_slugs.contains(&"user".to_string())); } #[test] fn set_status_disables_user_and_kills_sessions() { let conn = make_auth_conn_for_mgmt(); let owner_id = create_owner(&conn, "owner", "hash").unwrap(); let uid = create_user(&conn, "bob", None, "hash", owner_id).unwrap(); let bob_id: i64 = conn .query_row("SELECT id FROM users WHERE user_uid = ?1", [&uid], |r| { r.get(0) }) .unwrap(); create_session(&conn, bob_id, 3, None).unwrap(); set_user_status(&conn, &uid, "disabled").unwrap(); let sess_count: i64 = conn .query_row( "SELECT COUNT(*) FROM sessions WHERE user_id = ?1", [bob_id], |r| r.get(0), ) .unwrap(); assert_eq!(sess_count, 0, "sessions should be cleared on disable"); let u = get_user_by_uid(&conn, &uid).unwrap().unwrap(); assert_eq!(u.status, "disabled"); } #[test] fn assign_and_remove_role() { let conn = make_auth_conn_for_mgmt(); let owner_id = create_owner(&conn, "owner", "hash").unwrap(); let uid = create_user(&conn, "carol", None, "hash", owner_id).unwrap(); let carol_id = get_user_id_by_uid(&conn, &uid).unwrap().unwrap(); let bits_before = compute_role_bits(&conn, carol_id).unwrap(); assign_role(&conn, carol_id, "admin", owner_id).unwrap(); let bits_after = compute_role_bits(&conn, carol_id).unwrap(); assert!(bits_after & 4 != 0, "admin bit should be set"); assert!(bits_after > bits_before); remove_role(&conn, carol_id, "admin").unwrap(); let bits_final = compute_role_bits(&conn, carol_id).unwrap(); assert!(bits_final & 4 == 0, "admin bit should be cleared"); } #[test] fn custom_role_gets_next_bit_position() { let conn = make_auth_conn_for_mgmt(); let r1 = create_custom_role(&conn, "moderator", "Moderator").unwrap(); assert_eq!(r1.bit_position, 4); let r2 = create_custom_role(&conn, "helper", "Helper").unwrap(); assert_eq!(r2.bit_position, 5); assert_eq!(r2.level, 2); } // ── rename_tag / delete_tag ──────────────────────────────────────────── #[test] fn rename_tag_unknown_uid_returns_none() { let conn = conn(); let result = rename_tag(&conn, "tag_doesnotexist", "anything").unwrap(); assert!(result.is_none()); } #[test] fn rename_tag_updates_own_path_and_cascades_to_children() { let conn = conn(); // Create /science → /science/cs → /science/cs/algorithms let _ = create_tag_path(&conn, "science/cs/algorithms").unwrap(); let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); let cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap(); let algo = get_tag_by_path(&conn, "/science/cs/algorithms") .unwrap() .unwrap(); // Rename "science" → "natural-science" let updated = rename_tag(&conn, &science.tag_uid, "natural-science") .unwrap() .expect("should return updated tag"); assert_eq!(updated.slug, "natural-science"); assert_eq!(updated.name, "Natural Science"); assert_eq!(updated.full_path, "/natural-science"); // /science must no longer exist assert!(get_tag_by_path(&conn, "/science").unwrap().is_none()); // /science/cs must have moved assert!(get_tag_by_path(&conn, "/science/cs").unwrap().is_none()); let cs_new = get_tag_by_uid(&conn, &cs.tag_uid).unwrap().unwrap(); assert_eq!(cs_new.full_path, "/natural-science/cs"); // /science/cs/algorithms must have moved assert!( get_tag_by_path(&conn, "/science/cs/algorithms") .unwrap() .is_none() ); let algo_new = get_tag_by_uid(&conn, &algo.tag_uid).unwrap().unwrap(); assert_eq!(algo_new.full_path, "/natural-science/cs/algorithms"); } #[test] fn rename_tag_sibling_collision_returns_err() { let conn = conn(); // Create /science and /natural-science as siblings let _ = create_tag_path(&conn, "science").unwrap(); let _ = create_tag_path(&conn, "natural-science").unwrap(); let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); // Renaming /science → natural-science should collide let result = rename_tag(&conn, &science.tag_uid, "natural-science"); assert!( result.is_err(), "expected collision error, got {:?}", result ); } #[test] fn rename_tag_to_same_name_is_noop() { let conn = conn(); let _ = create_tag_path(&conn, "science").unwrap(); let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); // "Science" humanizes to the same slug; rename should succeed (no collision since same uid) let updated = rename_tag(&conn, &science.tag_uid, "science") .unwrap() .expect("should return tag"); assert_eq!(updated.full_path, "/science"); } #[test] fn delete_tag_unknown_uid_returns_false() { let conn = conn(); assert!(!delete_tag(&conn, "tag_doesnotexist").unwrap()); } #[test] fn delete_tag_removes_subtree_and_cascades_assignments() { let conn = conn(); // Build /science/cs and /science/math let cs_id = create_tag_path(&conn, "science/cs").unwrap(); let math_id = create_tag_path(&conn, "science/math").unwrap(); let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); // Create an entry and assign it to /science/cs let entry = create_entry_fixture(&conn, "private", None, None); assign_entry_to_tag(&conn, entry.id, cs_id).unwrap(); // Verify assignment exists let assigned_before: i64 = conn .query_row( "SELECT COUNT(*) FROM entry_tag_assignments WHERE entry_id = ?1", [entry.id], |r| r.get(0), ) .unwrap(); assert_eq!(assigned_before, 1); // Delete the /science subtree assert!(delete_tag(&conn, &science.tag_uid).unwrap()); // All three tag rows must be gone let tag_count: i64 = conn .query_row("SELECT COUNT(*) FROM tags", [], |r| r.get(0)) .unwrap(); assert_eq!(tag_count, 0, "all tags in subtree should be deleted"); // Assignment must have been cascade-deleted let assigned_after: i64 = conn .query_row( "SELECT COUNT(*) FROM entry_tag_assignments WHERE entry_id = ?1", [entry.id], |r| r.get(0), ) .unwrap(); assert_eq!(assigned_after, 0, "assignment should be removed by cascade"); // Verify by uid too (subtree ids: science, cs, math) assert!(get_tag_by_uid(&conn, &science.tag_uid).unwrap().is_none()); let cs_tag = conn .query_row("SELECT tag_uid FROM tags WHERE id = ?1", [cs_id], |r| { r.get::<_, String>(0) }) .optional() .unwrap(); assert!(cs_tag.is_none(), "/science/cs should be deleted"); let math_tag = conn .query_row("SELECT tag_uid FROM tags WHERE id = ?1", [math_id], |r| { r.get::<_, String>(0) }) .optional() .unwrap(); assert!(math_tag.is_none(), "/science/math should be deleted"); } #[test] fn rename_tag_slug_with_special_chars_is_stripped() { let conn = conn(); let _ = create_tag_path(&conn, "science").unwrap(); let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); // Input with spaces and underscores — underscores stripped, spaces become hyphens, case preserved let updated = rename_tag(&conn, &science.tag_uid, "Natural Science") .unwrap() .expect("should rename"); assert_eq!(updated.slug, "Natural-Science"); assert_eq!(updated.full_path, "/Natural-Science"); } // ── move_tag tests ──────────────────────────────────────────────────────── #[test] fn move_tag_unknown_uid_returns_none() { let conn = conn(); let result = move_tag(&conn, "tag_doesnotexist", None).unwrap(); assert!(result.is_none()); } #[test] fn move_tag_to_root_clears_parent_and_updates_path() { let conn = conn(); // Build /science/cs/algorithms let _ = create_tag_path(&conn, "science/cs/algorithms").unwrap(); let cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap(); let algo = get_tag_by_path(&conn, "/science/cs/algorithms") .unwrap() .unwrap(); // Move /science/cs to root — new path should be /cs let updated = move_tag(&conn, &cs.tag_uid, None) .unwrap() .expect("should return updated tag"); assert_eq!(updated.full_path, "/cs"); assert!(updated.parent_tag_id.is_none()); // /science/cs must no longer exist at old path assert!(get_tag_by_path(&conn, "/science/cs").unwrap().is_none()); // Descendant /science/cs/algorithms must have its path updated let algo_updated = get_tag_by_uid(&conn, &algo.tag_uid).unwrap().unwrap(); assert_eq!(algo_updated.full_path, "/cs/algorithms"); } #[test] fn move_tag_to_new_parent_updates_subtree_paths() { let conn = conn(); // Build /science/cs/algorithms and /archive let _ = create_tag_path(&conn, "science/cs/algorithms").unwrap(); let _ = create_tag_path(&conn, "archive").unwrap(); let cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap(); let algo = get_tag_by_path(&conn, "/science/cs/algorithms") .unwrap() .unwrap(); let archive = get_tag_by_path(&conn, "/archive").unwrap().unwrap(); // Move /science/cs under /archive let updated = move_tag(&conn, &cs.tag_uid, Some(&archive.tag_uid)) .unwrap() .expect("should return updated tag"); assert_eq!(updated.full_path, "/archive/cs"); assert_eq!(updated.parent_tag_id, Some(archive.id)); // Old path must be gone assert!(get_tag_by_path(&conn, "/science/cs").unwrap().is_none()); // Descendant must have cascaded path let algo_updated = get_tag_by_uid(&conn, &algo.tag_uid).unwrap().unwrap(); assert_eq!(algo_updated.full_path, "/archive/cs/algorithms"); } #[test] fn move_tag_noop_when_parent_unchanged() { let conn = conn(); let _ = create_tag_path(&conn, "science/cs").unwrap(); let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); let cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap(); // Moving /science/cs under /science again is a no-op let updated = move_tag(&conn, &cs.tag_uid, Some(&science.tag_uid)) .unwrap() .expect("should return tag"); assert_eq!(updated.full_path, "/science/cs"); } #[test] fn move_tag_rejects_self_move() { let conn = conn(); let _ = create_tag_path(&conn, "science").unwrap(); let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); let result = move_tag(&conn, &science.tag_uid, Some(&science.tag_uid)); assert!( result.is_err(), "expected error when moving tag under itself" ); } #[test] fn move_tag_rejects_descendant_as_parent() { let conn = conn(); let _ = create_tag_path(&conn, "science/cs/algorithms").unwrap(); let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); let algo = get_tag_by_path(&conn, "/science/cs/algorithms") .unwrap() .unwrap(); // Moving /science under one of its own descendants must be rejected let result = move_tag(&conn, &science.tag_uid, Some(&algo.tag_uid)); assert!( result.is_err(), "expected error when moving tag under a descendant" ); } #[test] fn move_tag_rejects_path_collision() { let conn = conn(); // /archive/cs and /science/cs both exist; moving /science/cs under /archive collides let _ = create_tag_path(&conn, "science/cs").unwrap(); let _ = create_tag_path(&conn, "archive/cs").unwrap(); let science_cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap(); let archive = get_tag_by_path(&conn, "/archive").unwrap().unwrap(); let result = move_tag(&conn, &science_cs.tag_uid, Some(&archive.tag_uid)); assert!( result.is_err(), "expected collision error; /archive/cs already exists" ); } #[test] fn move_tag_rejects_unknown_parent_uid() { let conn = conn(); let _ = create_tag_path(&conn, "science").unwrap(); let science = get_tag_by_path(&conn, "/science").unwrap().unwrap(); let result = move_tag(&conn, &science.tag_uid, Some("uid_does_not_exist")); assert!(result.is_err(), "expected error for unknown parent uid"); } #[test] fn move_tag_cascade_only_replaces_leading_prefix() { // Regression: SQLite REPLACE(full_path, old_prefix, new_prefix) rewrites // every non-overlapping occurrence of the pattern in the string, not just // the leading one. /foo/foo/bar is NOT a triggering case (the overlapping // '/' hides the second match), but /foo/other/foo/bar has a second /foo/ // at a non-overlapping position and does expose the bug. let conn = conn(); let _ = create_tag_path(&conn, "foo/other/foo/bar").unwrap(); let foo = get_tag_by_path(&conn, "/foo").unwrap().unwrap(); let other = get_tag_by_path(&conn, "/foo/other").unwrap().unwrap(); let other_foo = get_tag_by_path(&conn, "/foo/other/foo").unwrap().unwrap(); let bar = get_tag_by_path(&conn, "/foo/other/foo/bar") .unwrap() .unwrap(); let _ = create_tag_path(&conn, "dest").unwrap(); let dest = get_tag_by_path(&conn, "/dest").unwrap().unwrap(); let updated = move_tag(&conn, &foo.tag_uid, Some(&dest.tag_uid)) .unwrap() .expect("should return updated tag"); assert_eq!(updated.full_path, "/dest/foo"); let other_new = get_tag_by_uid(&conn, &other.tag_uid).unwrap().unwrap(); assert_eq!(other_new.full_path, "/dest/foo/other"); // /foo/other/foo must become /dest/foo/other/foo — REPLACE gives /dest/foo/other/dest/foo. let other_foo_new = get_tag_by_uid(&conn, &other_foo.tag_uid).unwrap().unwrap(); assert_eq!( other_foo_new.full_path, "/dest/foo/other/foo", "cascade must only replace the leading /foo/ prefix, not every occurrence" ); let bar_new = get_tag_by_uid(&conn, &bar.tag_uid).unwrap().unwrap(); assert_eq!(bar_new.full_path, "/dest/foo/other/foo/bar"); } #[test] fn rename_tag_cascade_only_replaces_leading_prefix() { // Same REPLACE bug applies to rename_tag's cascade. let conn = conn(); let _ = create_tag_path(&conn, "foo/other/foo/bar").unwrap(); let foo = get_tag_by_path(&conn, "/foo").unwrap().unwrap(); let other = get_tag_by_path(&conn, "/foo/other").unwrap().unwrap(); let other_foo = get_tag_by_path(&conn, "/foo/other/foo").unwrap().unwrap(); let bar = get_tag_by_path(&conn, "/foo/other/foo/bar") .unwrap() .unwrap(); let updated = rename_tag(&conn, &foo.tag_uid, "renamed") .unwrap() .expect("should return updated tag"); assert_eq!(updated.full_path, "/renamed"); let other_new = get_tag_by_uid(&conn, &other.tag_uid).unwrap().unwrap(); assert_eq!(other_new.full_path, "/renamed/other"); // /foo/other/foo must become /renamed/other/foo — REPLACE gives /renamed/other/renamed. let other_foo_new = get_tag_by_uid(&conn, &other_foo.tag_uid).unwrap().unwrap(); assert_eq!( other_foo_new.full_path, "/renamed/other/foo", "cascade must only replace the leading /foo/ prefix, not every occurrence" ); let bar_new = get_tag_by_uid(&conn, &bar.tag_uid).unwrap().unwrap(); assert_eq!(bar_new.full_path, "/renamed/other/foo/bar"); } // ── delete_entry tests ──────────────────────────────────────────────────── /// Helper: attach a shared blob to an entry and return the blob id. fn attach_blob(conn: &Connection, entry_id: i64, sha256: &str, byte_size: i64) -> i64 { let blob = BlobRecord { sha256: sha256.to_string(), byte_size, mime_type: None, extension: None, raw_relpath: format!("raw/{sha256}"), }; let blob_id = upsert_blob(conn, &blob).unwrap(); add_entry_artifact( conn, &NewArtifact { entry_id, artifact_role: "main".to_string(), storage_area: "raw".to_string(), relpath: format!("raw/{sha256}"), blob_id: Some(blob_id), logical_path: None, metadata_json: None, }, ) .unwrap(); blob_id } #[test] fn delete_entry_returns_false_for_unknown_uid() { let conn = conn(); assert!(!delete_entry(&conn, "entry_doesnotexist").unwrap()); } #[test] fn delete_entry_removes_root_and_child_rows() { let conn = conn(); let root = create_entry_fixture(&conn, "private", None, None); let child = create_entry_fixture(&conn, "private", Some(root.id), Some(root.id)); delete_entry(&conn, &root.entry_uid).unwrap(); let root_gone: Option = conn .query_row( "SELECT id FROM archived_entries WHERE id = ?1", [root.id], |r| r.get(0), ) .optional() .unwrap(); let child_gone: Option = conn .query_row( "SELECT id FROM archived_entries WHERE id = ?1", [child.id], |r| r.get(0), ) .optional() .unwrap(); assert!(root_gone.is_none(), "root should be gone"); assert!(child_gone.is_none(), "child should be gone"); } #[test] fn delete_entry_nulls_run_item_produced_entry_id() { let conn = conn(); let user_id = ensure_default_user(&conn).unwrap(); let root = create_entry_fixture(&conn, "private", None, None); let run = create_archive_run(&conn, user_id, 1).unwrap(); let item = create_archive_run_item( &conn, run.id, None, 0, "https://example.com", None, "web", "page", ) .unwrap(); complete_archive_run_item(&conn, item.id, root.id).unwrap(); delete_entry(&conn, &root.entry_uid).unwrap(); let produced: Option = conn .query_row( "SELECT produced_entry_id FROM archive_run_items WHERE id = ?1", [item.id], |r| r.get(0), ) .unwrap(); assert!( produced.is_none(), "produced_entry_id should be NULL after delete" ); } #[test] fn delete_entry_recalculates_cached_bytes_for_external_entries() { // Scenario: root (id=N) and child (id=N+1) both own blob X (100 bytes). // External (id=N+2, higher → newer by tiebreaker) also uses blob X. // Before delete: external.cached_bytes = 100 (blob owned by root). // After delete_entry(root): external.cached_bytes = 0 (no older entry remains). let conn = conn(); let root = create_entry_fixture(&conn, "private", None, None); let child = create_entry_fixture(&conn, "private", Some(root.id), Some(root.id)); let external = create_entry_fixture(&conn, "private", None, None); // Attach the same blob to all three. attach_blob(&conn, root.id, "blobx", 100); attach_blob(&conn, child.id, "blobx", 100); attach_blob(&conn, external.id, "blobx", 100); // Compute external.cached_bytes before delete — root and child are older by id. refresh_entry_cached_bytes(&conn, external.id).unwrap(); let before: i64 = conn .query_row( "SELECT cached_bytes FROM archived_entries WHERE id = ?1", [external.id], |r| r.get(0), ) .unwrap(); assert_eq!( before, 100, "external should see blob as cached before delete" ); delete_entry(&conn, &root.entry_uid).unwrap(); // external must still exist but with cached_bytes = 0. let after: i64 = conn .query_row( "SELECT cached_bytes FROM archived_entries WHERE id = ?1", [external.id], |r| r.get(0), ) .unwrap(); assert_eq!( after, 0, "cached_bytes must be 0 after whole subtree is deleted" ); } // ── Orphan blob cleanup ─────────────────────────────────────────────────────── #[test] fn has_active_capture_jobs_false_when_none() { let conn = conn(); assert!(!has_active_capture_jobs(&conn).unwrap()); } #[test] fn has_active_capture_jobs_true_for_pending() { let conn = conn(); create_capture_job(&conn, "test").unwrap(); assert!(has_active_capture_jobs(&conn).unwrap()); } #[test] fn has_active_capture_jobs_true_for_running() { let conn = conn(); let uid = create_capture_job(&conn, "test").unwrap(); update_capture_job_status(&conn, &uid, "running", None, None, None).unwrap(); assert!(has_active_capture_jobs(&conn).unwrap()); } #[test] fn has_active_capture_jobs_false_for_completed() { let conn = conn(); let uid = create_capture_job(&conn, "test").unwrap(); update_capture_job_status(&conn, &uid, "completed", Some("run_x"), None, None).unwrap(); assert!(!has_active_capture_jobs(&conn).unwrap()); } #[test] fn list_orphaned_blob_rows_empty_when_blob_is_referenced() { let conn = conn(); let entry = create_entry_fixture(&conn, "private", None, None); let blob = BlobRecord { sha256: "aaa111".to_string(), byte_size: 100, mime_type: None, extension: Some("mp4".to_string()), raw_relpath: "raw/a/a/aaa111.mp4".to_string(), }; let blob_id = upsert_blob(&conn, &blob).unwrap(); add_entry_artifact( &conn, &NewArtifact { entry_id: entry.id, artifact_role: "main".to_string(), storage_area: "raw".to_string(), relpath: blob.raw_relpath.clone(), blob_id: Some(blob_id), logical_path: None, metadata_json: None, }, ) .unwrap(); assert!( list_orphaned_blob_rows(&conn).unwrap().is_empty(), "referenced blob must not appear as orphan" ); } #[test] fn list_orphaned_blob_rows_finds_unreferenced_blob() { let conn = conn(); upsert_blob( &conn, &BlobRecord { sha256: "bbb222".to_string(), byte_size: 200, mime_type: None, extension: Some("jpg".to_string()), raw_relpath: "raw/b/b/bbb222.jpg".to_string(), }, ) .unwrap(); let orphans = list_orphaned_blob_rows(&conn).unwrap(); assert_eq!(orphans.len(), 1, "unreferenced blob must appear as orphan"); assert_eq!(orphans[0].1, "raw/b/b/bbb222.jpg"); } #[test] fn all_referenced_file_relpaths_covers_blob_and_direct_artifact_relpaths() { let conn = conn(); let entry = create_entry_fixture(&conn, "private", None, None); // Live blob: linked via blob_id let blob = BlobRecord { sha256: "live1".to_string(), byte_size: 50, mime_type: None, extension: None, raw_relpath: "raw/l/i/live1".to_string(), }; let blob_id = upsert_blob(&conn, &blob).unwrap(); add_entry_artifact( &conn, &NewArtifact { entry_id: entry.id, artifact_role: "main".to_string(), storage_area: "raw".to_string(), relpath: blob.raw_relpath.clone(), blob_id: Some(blob_id), logical_path: None, metadata_json: None, }, ) .unwrap(); // Artifact referencing a file directly (no blob_id) add_entry_artifact( &conn, &NewArtifact { entry_id: entry.id, artifact_role: "sidecar".to_string(), storage_area: "raw".to_string(), relpath: "raw/s/i/sidecar.vtt".to_string(), blob_id: None, logical_path: None, metadata_json: None, }, ) .unwrap(); let refs = all_referenced_file_relpaths(&conn).unwrap(); assert!( refs.contains("raw/l/i/live1"), "live blob relpath must be protected" ); assert!( refs.contains("raw/s/i/sidecar.vtt"), "direct artifact relpath must be protected" ); } #[test] fn delete_orphaned_blob_rows_removes_only_unreferenced() { let conn = conn(); let entry = create_entry_fixture(&conn, "private", None, None); // Referenced blob let live = BlobRecord { sha256: "live9999".to_string(), byte_size: 10, mime_type: None, extension: None, raw_relpath: "raw/l/v/live9999".to_string(), }; let live_id = upsert_blob(&conn, &live).unwrap(); add_entry_artifact( &conn, &NewArtifact { entry_id: entry.id, artifact_role: "main".to_string(), storage_area: "raw".to_string(), relpath: live.raw_relpath.clone(), blob_id: Some(live_id), logical_path: None, metadata_json: None, }, ) .unwrap(); // Orphaned blob upsert_blob( &conn, &BlobRecord { sha256: "dead0000".to_string(), byte_size: 20, mime_type: None, extension: None, raw_relpath: "raw/d/e/dead0000".to_string(), }, ) .unwrap(); let deleted = delete_orphaned_blob_rows(&conn).unwrap(); assert_eq!( deleted, 1, "only the unreferenced blob row should be deleted" ); assert!( get_blob_by_sha256(&conn, "live9999").unwrap().is_some(), "referenced blob row must be preserved" ); } #[test] fn orphan_blob_row_whose_relpath_is_artifact_relpath_stays_in_referenced_set() { // Blob row has no blob_id reference (would be deleted from DB), // but an artifact points to the same file via relpath — the file must // appear in all_referenced_file_relpaths so it won't be deleted from disk. let conn = conn(); let entry = create_entry_fixture(&conn, "private", None, None); let blob = BlobRecord { sha256: "edgecase".to_string(), byte_size: 30, mime_type: None, extension: None, raw_relpath: "raw/e/d/edgecase".to_string(), }; upsert_blob(&conn, &blob).unwrap(); // artifact uses same relpath but no blob_id add_entry_artifact( &conn, &NewArtifact { entry_id: entry.id, artifact_role: "sidecar".to_string(), storage_area: "raw".to_string(), relpath: blob.raw_relpath.clone(), blob_id: None, logical_path: None, metadata_json: None, }, ) .unwrap(); // blob row is orphaned (no blob_id reference) assert_eq!(list_orphaned_blob_rows(&conn).unwrap().len(), 1); // but the file relpath is still protected let refs = all_referenced_file_relpaths(&conn).unwrap(); assert!( refs.contains(&blob.raw_relpath), "file must be protected because artifact.relpath references it directly" ); } }