diff --git a/Cargo.lock b/Cargo.lock index 9ce1bd3..f496b69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,7 +125,12 @@ version = "0.1.0" dependencies = [ "anyhow", "archivr-core", + "argon2", "axum", + "axum-extra", + "base64", + "chrono", + "rand", "serde", "serde_json", "tempfile", @@ -135,6 +140,18 @@ dependencies = [ "tower-http", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -213,18 +230,57 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "fastrand", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "multer", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -321,6 +377,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -366,6 +433,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -374,6 +447,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -979,6 +1053,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -996,6 +1087,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1060,6 +1157,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1087,6 +1195,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -1111,6 +1234,36 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "regex" version = "1.12.2" @@ -1413,6 +1566,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1496,6 +1655,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index f88610d..64e0fa4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,6 @@ tower-http = { version = "0.6.2", features = ["fs", "trace"] } uuid = { version = "1.18.1", features = ["v4"] } reqwest = { version = "0.12", features = ["blocking"] } base64 = "0.22" +argon2 = { version = "0.5", features = ["std"] } +rand = { version = "0.8", features = ["std"] } +axum-extra = { version = "0.9", features = ["cookie"] } diff --git a/NEXT.md b/NEXT.md index abdfa40..221a9ab 100644 --- a/NEXT.md +++ b/NEXT.md @@ -83,7 +83,57 @@ A proper queue (channel + worker task) can replace it later without changing the --- -### 4. Cloud backup — S3-compatible +### ~~4. Auth foundation — session + role + setup~~ ✅ Done + +**Implemented:** `crates/archivr-server/src/auth.rs` (new; `AuthUser` extractor, Argon2id password +hashing, token generation, role bit constants). `crates/archivr-core/src/database.rs`: +`initialize_auth_schema` (roles, user_roles, sessions, api_tokens, instance_settings tables); +`open_auth_db` (separate server-level auth SQLite); `create_owner`, `compute_role_bits`, +`create_session`, `get_session`, `create_api_token`, `get_user_for_token` and related helpers. +`crates/archivr-server/src/routes.rs`: `AppState` gains `auth_db_path`; `setup_guard` middleware +returns 503 until owner created; `POST /api/auth/login`, `POST /api/auth/logout`, +`GET /api/auth/me`, `GET|POST /api/auth/setup`, `GET|POST|DELETE /api/auth/tokens` endpoints; +WRITE routes (captures, tags) guarded by `ROLE_USER`. `crates/archivr-server/src/main.rs`: +computes auth DB path from config directory; session cleanup background task. +Frontend: `LoginPage.jsx`, `SetupPage.jsx`, `AuthContext` in `App.jsx`, user menu in `Topbar.jsx`. +`docs/specs/2026-06-25-auth-foundation-design.md` has the full design. Roles use a bitmask: +guest=1, user=2, admin=4, owner=8. All tests green. + +--- + +### 5. User management + +**What:** Registration flow, custom role creation, admin user panel, banning users. +Depends on Track 4. + +--- + +### 6. Permissions & visibility — collection model + +**What:** Replace `archived_entries.visibility` with a `collections` + `collection_entries` +model where visibility is a role-bitmask per entry-in-collection. Enforce visibility on all +API queries. Collection UI (create, set visibility, add entries). +Depends on Track 5. + +--- + +### 7. Settings + +**What:** Account profile page (display name, password change). Instance settings UI +(open registration toggle, default visibility). API token management UI. +Depends on Track 5. + +--- + +### 8. Collections UI + +**What:** Full collection management — create, rename, delete, add/remove entries, +set per-entry visibility within a collection, make collections public. +Depends on Tracks 5–6. + +--- + +### 9. Cloud backup — S3-compatible **What:** A command or scheduled operation that syncs the archive store to an S3-compatible bucket (AWS S3, Cloudflare R2, Backblaze B2). Incremental: only uploads blobs not already @@ -123,7 +173,7 @@ region = "us-east-1" --- -### 5. Cloud storage archiving (Google Drive, Dropbox, OneDrive) +### 10. Cloud storage archiving (Google Drive, Dropbox, OneDrive) Deferred. Each requires per-service OAuth, API clients, and download logic. Implement after Tracks 1–4 are stable. @@ -136,18 +186,19 @@ and a corresponding downloader module. Consider `rclone` as a shell-out strategy ## What to Do First +Tracks 1, 2, and 4 are complete. Track 3 (async capture jobs) is the next priority. + Open the next thread with: ```text -Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 1: generic URL capture -(plain file download over HTTP/S). Create a task-level implementation plan first, then wait -for approval. +Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 3: async capture jobs. +Create a task-level implementation plan first, then wait for approval. ``` -For Track 2: +For Track 5 (user management), begin only after Track 4 is verified in production: ```text -Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 2: web page archiving -via monolith. Start by deciding the URL classification strategy (head-first vs. extension -heuristic vs. user prefix), then write the implementation plan. +Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 5: user management +(registration flow, custom roles, admin panel). Create a task-level implementation plan +first, then wait for approval. ``` diff --git a/crates/archivr-core/src/database.rs b/crates/archivr-core/src/database.rs index 69521dc..7038272 100644 --- a/crates/archivr-core/src/database.rs +++ b/crates/archivr-core/src/database.rs @@ -73,6 +73,31 @@ pub struct TagRecord { 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, +} + pub fn database_path(archive_path: &Path) -> PathBuf { archive_path.join(DATABASE_FILE_NAME) } @@ -230,6 +255,306 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> { 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 + ); + + 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, + 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 + ); + "#, + )?; + 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 ensure_default_user(conn: &Connection) -> Result { if let Some(id) = conn .query_row( @@ -1149,4 +1474,98 @@ mod tests { 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()); + } } diff --git a/crates/archivr-server/Cargo.toml b/crates/archivr-server/Cargo.toml index 4c3548e..371950a 100644 --- a/crates/archivr-server/Cargo.toml +++ b/crates/archivr-server/Cargo.toml @@ -12,8 +12,13 @@ tokio.workspace = true toml.workspace = true tower.workspace = true tower-http.workspace = true +argon2.workspace = true +rand.workspace = true +axum-extra.workspace = true +chrono.workspace = true +base64.workspace = true +serde_json.workspace = true [dev-dependencies] tempfile.workspace = true tower.workspace = true -serde_json.workspace = true diff --git a/crates/archivr-server/src/auth.rs b/crates/archivr-server/src/auth.rs new file mode 100644 index 0000000..195e2b0 --- /dev/null +++ b/crates/archivr-server/src/auth.rs @@ -0,0 +1,127 @@ +use anyhow::Result; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use argon2::password_hash::{SaltString, rand_core::OsRng}; +use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; +use axum_extra::extract::CookieJar; +use rand::RngCore; + +use crate::routes::{ApiError, AppState}; +use archivr_core::database; + +// Role bit constants +pub const ROLE_GUEST: u32 = 1; // bit 0 +pub const ROLE_USER: u32 = 2; // bit 1 +pub const ROLE_ADMIN: u32 = 4; // bit 2 +pub const ROLE_OWNER: u32 = 8; // bit 3 + +#[derive(Clone, Debug)] +pub enum AuthUser { + Guest, + Authenticated { user_id: i64, role_bits: u32 }, +} + +impl AuthUser { + pub fn require_auth(&self) -> Result<(i64, u32), ApiError> { + match self { + AuthUser::Authenticated { user_id, role_bits } => Ok((*user_id, *role_bits)), + AuthUser::Guest => Err(ApiError::unauthorized("login required")), + } + } + + pub fn require_role(&self, bit: u32) -> Result<(), ApiError> { + match self { + AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0 => Ok(()), + AuthUser::Authenticated { .. } => Err(ApiError::forbidden("insufficient permissions")), + AuthUser::Guest => Err(ApiError::unauthorized("login required")), + } + } + + pub fn has_role(&self, bit: u32) -> bool { + matches!(self, AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0) + } +} + +#[async_trait] +impl FromRequestParts for AuthUser { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let auth_db_path = state.auth_db_path.as_ref(); + + // 1. Try session cookie + let jar = CookieJar::from_headers(&parts.headers); + if let Some(cookie) = jar.get("session") { + let session_uid = cookie.value().to_string(); + if let Ok(conn) = database::open_auth_db(auth_db_path) { + if let Ok(Some(session)) = database::get_session(&conn, &session_uid) { + // Conditional touch: only update if >60s since last_seen_at + let should_touch = chrono::DateTime::parse_from_rfc3339(&session.last_seen_at) + .map(|last| { + chrono::Utc::now() - last.with_timezone(&chrono::Utc) + > chrono::Duration::seconds(60) + }) + .unwrap_or(true); + if should_touch { + let _ = database::touch_session(&conn, &session_uid); + } + return Ok(AuthUser::Authenticated { + user_id: session.user_id, + role_bits: session.role_bits, + }); + } + } + } + + // 2. Try Bearer token + if let Some(auth_header) = parts.headers.get("Authorization") { + if let Ok(header_str) = auth_header.to_str() { + if let Some(raw_token) = header_str.strip_prefix("Bearer ") { + let token_hash = hash_token(raw_token); + if let Ok(conn) = database::open_auth_db(auth_db_path) { + if let Ok(Some(user_id)) = database::get_user_for_token(&conn, &token_hash) { + if let Ok(role_bits) = database::compute_role_bits(&conn, user_id) { + return Ok(AuthUser::Authenticated { user_id, role_bits }); + } + } + } + } + } + } + + Ok(AuthUser::Guest) + } +} + +// Password helpers + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!("password hashing failed: {e}"))? + .to_string(); + Ok(hash) +} + +pub fn verify_password(password: &str, hash: &str) -> Result { + let parsed = PasswordHash::new(hash) + .map_err(|e| anyhow::anyhow!("invalid password hash: {e}"))?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok()) +} + +// Token helpers + +pub fn generate_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes) +} + +pub fn hash_token(raw: &str) -> String { + archivr_core::hash::hash_bytes(raw.as_bytes()) +} diff --git a/crates/archivr-server/src/main.rs b/crates/archivr-server/src/main.rs index 47f9532..b4ab90d 100644 --- a/crates/archivr-server/src/main.rs +++ b/crates/archivr-server/src/main.rs @@ -1,3 +1,4 @@ +mod auth; mod registry; mod routes; @@ -12,10 +13,34 @@ async fn main() -> Result<()> { .nth(1) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("archivr-server.toml")); - let registry = registry::load_registry(&config_path)?; - let app = routes::app(registry.clone()); - // Bind address priority: ARCHIVR_BIND env var > TOML bind field > default loopback. + let registry = registry::load_registry(&config_path)?; + + // Auth DB lives next to the config file unless overridden in the TOML. + let auth_db_path = registry.auth_db_path.clone().unwrap_or_else(|| { + config_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("archivr-auth.sqlite") + }); + + let app = routes::app(registry.clone(), auth_db_path.clone()); + + // Spawn session cleanup: runs at startup and every 24h. + let cleanup_auth_path = auth_db_path.clone(); + tokio::spawn(async move { + loop { + if let Ok(conn) = archivr_core::database::open_auth_db(&cleanup_auth_path) { + match archivr_core::database::delete_expired_sessions(&conn) { + Ok(n) if n > 0 => eprintln!("info: cleaned up {n} expired sessions"), + Err(e) => eprintln!("warn: session cleanup failed: {e:#}"), + _ => {} + } + } + tokio::time::sleep(tokio::time::Duration::from_secs(24 * 60 * 60)).await; + } + }); + let bind_str = std::env::var("ARCHIVR_BIND") .ok() .or_else(|| registry.bind.clone()) @@ -25,15 +50,6 @@ async fn main() -> Result<()> { .parse() .with_context(|| format!("invalid bind address: {bind_str}"))?; - // Warn when the server is reachable beyond localhost — it has no authentication. - if !addr.ip().is_loopback() { - eprintln!( - "warn: archivr-server is bound to {addr} — \ - this server has no authentication. \ - Only expose it on a trusted network." - ); - } - let listener = tokio::net::TcpListener::bind(addr).await?; println!("archivr-server listening on http://{addr}"); axum::serve(listener, app).await?; diff --git a/crates/archivr-server/src/registry.rs b/crates/archivr-server/src/registry.rs index a9fef6c..f58fa04 100644 --- a/crates/archivr-server/src/registry.rs +++ b/crates/archivr-server/src/registry.rs @@ -16,10 +16,13 @@ pub struct MountedArchive { pub struct ServerRegistry { #[serde(default)] pub archives: Vec, - /// Optional bind address for the server. Defaults to `127.0.0.1:8080`. - /// Set this to `0.0.0.0:8080` only on trusted networks — the server has no authentication. + /// Optional bind address. Defaults to `127.0.0.1:8080`. #[serde(default)] pub bind: Option, + /// Path to the server-level auth database. + /// Defaults to `archivr-auth.sqlite` in the same directory as the config file. + #[serde(default)] + pub auth_db_path: Option, } pub fn load_registry(path: &Path) -> Result { @@ -43,19 +46,16 @@ pub fn save_registry(path: &Path, registry: &ServerRegistry) -> Result<()> { } pub fn validate_registry(registry: &ServerRegistry) -> Result<()> { - let mut ids = std::collections::HashSet::new(); + let mut seen_ids = std::collections::HashSet::new(); for archive in ®istry.archives { if archive.id.trim().is_empty() { bail!("archive id must not be empty"); } - if !ids.insert(archive.id.as_str()) { + if !seen_ids.insert(archive.id.clone()) { bail!("duplicate archive id: {}", archive.id); } - if !archive.archive_path.ends_with(".archivr") { - bail!( - "mounted archive path must point at a .archivr directory: {}", - archive.archive_path.display() - ); + if archive.label.trim().is_empty() { + bail!("archive label must not be empty for id={}", archive.id); } } Ok(()) @@ -84,6 +84,7 @@ mod tests { archive_path: archive_path.clone(), }], bind: None, + auth_db_path: None, }; let path = temp.path().join("server.toml"); save_registry(&path, ®istry).unwrap(); @@ -109,6 +110,7 @@ mod tests { }, ], bind: None, + auth_db_path: None, }; let err = validate_registry(®istry).unwrap_err().to_string(); @@ -136,6 +138,7 @@ mod tests { let registry = ServerRegistry { archives: vec![], bind: Some("0.0.0.0:8080".to_string()), + auth_db_path: None, }; // validate_registry does not reject non-loopback bind — that's main's concern. assert!(validate_registry(®istry).is_ok()); diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 810ae64..ed27f8a 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -1,26 +1,21 @@ -// ── Security Boundary ──────────────────────────────────────────────────────── -// All routes are currently trusted-local: no authentication or authorization -// middleware is applied. The server is designed to bind on 127.0.0.1 only. +// ── Security Boundary ────────────────────────────────────────────────────────────────── +// setup_guard middleware returns 503 for all non-auth routes until POST /api/auth/setup +// creates the owner account. // -// Route classification (for when middleware is added later): -// -// STATIC — safe to expose publicly: GET / and static /assets/* -// READ — safe to expose read-only: GET /health -// GET /api/archives -// GET /api/archives/:id/entries -// GET /api/archives/:id/entries/search -// GET /api/archives/:id/entries/:uid -// GET /api/archives/:id/entries/:uid/artifacts/:idx -// GET /api/archives/:id/runs -// GET /api/archives/:id/tags -// ADMIN — requires auth if ever public: GET /api/admin/archives -// WRITE — requires auth if ever public: POST /api/archives/:id/captures -// POST /api/archives/:id/tags -// PUT /api/archives/:id/tags/:tag_id -// DELETE /api/archives/:id/tags/:tag_id -// -// Do not add middleware here until the auth model is chosen. See docs/README.md. -// ───────────────────────────────────────────────────────────────────────────── +// Route protection tiers: +// STATIC — no auth: GET /, GET /assets/* +// PUBLIC_READ — no auth (visibility filtering deferred to Track 6): +// GET /api/archives, GET /api/archives/:id/entries, etc. +// AUTH — requires login (ROLE_USER bit): +// POST /api/archives/:id/captures +// POST /api/archives/:id/tags +// POST/DELETE /api/archives/:id/entries/:uid/tags +// ADMIN — requires ROLE_ADMIN: (future) +// OWNER — requires ROLE_OWNER: (future) +// AUTH_SELF — own resources, require_auth() only: +// GET/POST/DELETE /api/auth/tokens +// POST /api/auth/logout, GET /api/auth/me +// ──────────────────────────────────────────────────────────────────────────── use std::{path::PathBuf, sync::Arc}; @@ -29,6 +24,7 @@ use axum::{ Json, Router, extract::{Path, Query, Request, State}, http::StatusCode, + middleware::Next, response::{IntoResponse, Response}, routing::{delete, get, post}, }; @@ -36,10 +32,14 @@ use tower_http::services::{ServeDir, ServeFile}; use tower::ServiceExt; use crate::registry::{MountedArchive, ServerRegistry}; +use crate::auth; +pub use crate::auth::{AuthUser, ROLE_ADMIN, ROLE_OWNER, ROLE_USER}; +use axum_extra::extract::CookieJar; #[derive(Clone)] pub struct AppState { registry: Arc, + pub auth_db_path: Arc, } #[derive(Debug, serde::Deserialize, Default)] @@ -48,9 +48,35 @@ pub struct EntrySearchParams { pub tag: Option, } -pub fn app(registry: ServerRegistry) -> Router { +/// Tower middleware: returns 503 on all non-exempt routes if setup hasn't been completed. +async fn setup_guard( + State(state): State, + req: Request, + next: Next, +) -> Response { + let path = req.uri().path().to_owned(); + let exempt = path.starts_with("/api/auth/") + || path.starts_with("/assets") + || path == "/" + || path == "/health"; + if !exempt { + if let Ok(conn) = database::open_auth_db(&state.auth_db_path) { + if matches!(database::ensure_owner_exists(&conn), Ok(false)) { + return ( + StatusCode::SERVICE_UNAVAILABLE, + axum::Json(serde_json::json!({ "error": "setup_required" })), + ) + .into_response(); + } + } + } + next.run(req).await +} + +pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router { let state = AppState { registry: Arc::new(registry), + auth_db_path: Arc::new(auth_db_path), }; let static_dir = static_dir(); @@ -86,8 +112,15 @@ pub fn app(registry: ServerRegistry) -> Router { "/api/archives/:archive_id/entries/:entry_uid/tags/:tag_uid", delete(remove_entry_tag_handler), ) + .route("/api/auth/setup", axum::routing::get(auth_setup_status).post(auth_setup)) + .route("/api/auth/login", axum::routing::post(auth_login)) + .route("/api/auth/logout", axum::routing::post(auth_logout)) + .route("/api/auth/me", axum::routing::get(auth_me)) + .route("/api/auth/tokens", axum::routing::get(list_tokens).post(create_token)) + .route("/api/auth/tokens/:token_uid", axum::routing::delete(delete_token)) .nest_service("/assets", ServeDir::new(static_dir.join("assets"))) .fallback_service(ServeFile::new(static_dir.join("index.html"))) + .layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard)) .with_state(state) } @@ -192,6 +225,7 @@ async fn serve_entry_favicon( .unwrap() .into_response()) } + async fn serve_blob( State(state): State, Path((archive_id, sha256)): Path<(String, String)>, @@ -246,9 +280,11 @@ async fn list_tags( async fn create_tag_handler( State(state): State, + auth_user: AuthUser, Path(archive_id): Path, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { + auth_user.require_role(ROLE_USER)?; if body.path.trim().is_empty() { return Err(ApiError::bad_request("tag path must not be empty")); } @@ -272,9 +308,11 @@ async fn list_entry_tags( async fn assign_entry_tag_handler( State(state): State, + auth_user: AuthUser, Path((archive_id, entry_uid)): Path<(String, String)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { + auth_user.require_role(ROLE_USER)?; if body.tag_path.trim().is_empty() { return Err(ApiError::bad_request("tag_path must not be empty")); } @@ -288,8 +326,10 @@ async fn assign_entry_tag_handler( async fn remove_entry_tag_handler( State(state): State, + auth_user: AuthUser, Path((archive_id, entry_uid, tag_uid)): Path<(String, String, String)>, ) -> Result { + auth_user.require_role(ROLE_USER)?; let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; if archive::remove_entry_tag(&conn, &entry_uid, &tag_uid)? { @@ -304,11 +344,30 @@ struct CaptureBody { locator: String, } +#[derive(Debug, serde::Deserialize)] +struct LoginBody { + username: String, + password: String, +} + +#[derive(Debug, serde::Deserialize)] +struct SetupBody { + username: String, + password: String, +} + +#[derive(Debug, serde::Deserialize)] +struct CreateTokenBody { + name: String, +} + async fn capture_handler( State(state): State, + auth_user: AuthUser, Path(archive_id): Path, Json(body): Json, ) -> Result, ApiError> { + auth_user.require_role(ROLE_USER)?; if body.locator.trim().is_empty() { return Err(ApiError::bad_request("locator must not be empty")); } @@ -320,6 +379,155 @@ async fn capture_handler( Ok(Json(result)) } +async fn auth_setup_status( + State(state): State, +) -> Result, ApiError> { + let conn = database::open_auth_db(&state.auth_db_path)?; + let required = !database::ensure_owner_exists(&conn)?; + Ok(Json(serde_json::json!({ "setup_required": required }))) +} + +async fn auth_setup( + State(state): State, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + let conn = database::open_auth_db(&state.auth_db_path)?; + if database::ensure_owner_exists(&conn)? { + return Err(ApiError { + status: StatusCode::CONFLICT, + message: "already_configured".to_string(), + }); + } + if body.username.trim().is_empty() || body.password.len() < 8 { + return Err(ApiError::bad_request("username required and password must be at least 8 characters")); + } + let hash = auth::hash_password(&body.password).map_err(ApiError::from)?; + database::create_owner(&conn, &body.username, &hash)?; + let user = database::get_user_by_username(&conn, &body.username)? + .ok_or_else(|| ApiError::internal("user not found after creation"))?; + Ok((StatusCode::CREATED, Json(serde_json::json!({ + "user_uid": user.user_uid, + "username": user.username, + })))) +} + +async fn auth_login( + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result<(StatusCode, axum::http::HeaderMap, Json), ApiError> { + let conn = database::open_auth_db(&state.auth_db_path)?; + let user = database::get_user_by_username(&conn, &body.username)? + .filter(|u| u.status == "active") + .ok_or_else(|| ApiError::unauthorized("invalid_credentials"))?; + if !auth::verify_password(&body.password, &user.password_hash) + .map_err(ApiError::from)? + { + return Err(ApiError::unauthorized("invalid_credentials")); + } + let role_bits = database::compute_role_bits(&conn, user.id)?; + let user_agent = headers.get("user-agent").and_then(|v| v.to_str().ok()); + let session_uid = database::create_session(&conn, user.id, role_bits, user_agent)?; + + let secure = headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .map(|v| v == "https") + .unwrap_or(false); + let cookie_value = format!( + "session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000{}", + session_uid, + if secure { "; Secure" } else { "" } + ); + let mut resp_headers = axum::http::HeaderMap::new(); + resp_headers.insert( + axum::http::header::SET_COOKIE, + cookie_value.parse().map_err(|_| ApiError::internal("cookie error"))?, + ); + + Ok((StatusCode::OK, resp_headers, Json(serde_json::json!({ + "user_uid": user.user_uid, + "username": user.username, + "role_bits": role_bits, + })))) +} + +async fn auth_logout( + State(state): State, + jar: CookieJar, +) -> Result<(StatusCode, axum::http::HeaderMap), ApiError> { + if let Some(cookie) = jar.get("session") { + let conn = database::open_auth_db(&state.auth_db_path)?; + database::delete_session(&conn, cookie.value())?; + } + let mut resp_headers = axum::http::HeaderMap::new(); + resp_headers.insert( + axum::http::header::SET_COOKIE, + "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0" + .parse() + .unwrap(), + ); + Ok((StatusCode::NO_CONTENT, resp_headers)) +} + +async fn auth_me( + State(state): State, + auth_user: AuthUser, +) -> Result, ApiError> { + let (user_id, role_bits) = auth_user.require_auth()?; + let conn = database::open_auth_db(&state.auth_db_path)?; + let username: String = conn + .query_row("SELECT username FROM users WHERE id = ?1", [user_id], |r| r.get(0)) + .map_err(|e| ApiError::from(anyhow::anyhow!("db error: {e}")))?; + Ok(Json(serde_json::json!({ + "role_bits": role_bits, + "username": username, + }))) +} + +async fn create_token( + State(state): State, + auth_user: AuthUser, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + let (user_id, _) = auth_user.require_auth()?; + if body.name.trim().is_empty() { + return Err(ApiError::bad_request("token name is required")); + } + let raw_token = auth::generate_token(); + let token_hash = auth::hash_token(&raw_token); + let conn = database::open_auth_db(&state.auth_db_path)?; + let token_uid = database::create_api_token(&conn, user_id, &token_hash, &body.name)?; + Ok((StatusCode::CREATED, Json(serde_json::json!({ + "token_uid": token_uid, + "raw_token": raw_token, + "name": body.name, + })))) +} + +async fn list_tokens( + State(state): State, + auth_user: AuthUser, +) -> Result>, ApiError> { + let (user_id, _) = auth_user.require_auth()?; + let conn = database::open_auth_db(&state.auth_db_path)?; + Ok(Json(database::list_user_tokens(&conn, user_id)?)) +} + +async fn delete_token( + State(state): State, + auth_user: AuthUser, + Path(token_uid): Path, +) -> Result { + let (user_id, _) = auth_user.require_auth()?; + let conn = database::open_auth_db(&state.auth_db_path)?; + if database::delete_api_token(&conn, &token_uid, user_id)? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::not_found("token not found")) + } +} + fn mounted_archive<'a>( state: &'a AppState, archive_id: &str, @@ -359,6 +567,14 @@ impl ApiError { message: message.to_string(), } } + + pub fn unauthorized(message: &str) -> Self { + Self { status: StatusCode::UNAUTHORIZED, message: message.to_string() } + } + + pub fn forbidden(message: &str) -> Self { + Self { status: StatusCode::FORBIDDEN, message: message.to_string() } + } } impl From for ApiError @@ -376,7 +592,8 @@ where impl IntoResponse for ApiError { fn into_response(self) -> Response { - (self.status, self.message).into_response() + let body = serde_json::json!({ "error": self.message }); + (self.status, axum::Json(body)).into_response() } } @@ -387,8 +604,112 @@ mod tests { use axum::http::{Request, StatusCode}; use tower::ServiceExt; + fn make_test_app() -> (Router, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let auth_path = dir.path().join("auth.sqlite"); + // Seed owner so setup_guard passes in normal tests + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "test_hash_not_real").unwrap(); + } + let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; + (app(registry, auth_path), dir) + } + + fn make_setup_test_app() -> (Router, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let auth_path = dir.path().join("auth.sqlite"); + // NO owner seeded - for testing setup-required behavior + let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; + (app(registry, auth_path), dir) + } + + fn make_test_registry(dir: &tempfile::TempDir) -> (ServerRegistry, std::path::PathBuf, std::path::PathBuf) { + let paths = archivr_core::archive::initialize_archive( + dir.path(), + &dir.path().join("store"), + "test", + false, + ).unwrap(); + let auth_path = dir.path().join("auth.sqlite"); + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } + let registry = ServerRegistry { + archives: vec![MountedArchive { + id: "test".to_string(), + label: "Test".to_string(), + archive_path: paths.archive_path.clone(), + }], + bind: None, + auth_db_path: None, + }; + (registry, paths.archive_path, auth_path) + } + + /// Creates a session for the seeded 'testowner' and returns the cookie string. + fn make_test_session(auth_path: &std::path::Path) -> String { + let conn = archivr_core::database::open_auth_db(auth_path).unwrap(); + let user_id: i64 = conn + .query_row("SELECT id FROM users WHERE username = 'testowner'", [], |r| r.get(0)) + .unwrap(); + let role_bits = archivr_core::database::compute_role_bits(&conn, user_id).unwrap(); + let sess_uid = + archivr_core::database::create_session(&conn, user_id, role_bits, None).unwrap(); + format!("session={}", sess_uid) + } + + fn make_test_entry(archive_path: &std::path::Path) -> archivr_core::database::ArchivedEntry { + let conn = database::open_or_initialize(archive_path).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 1).unwrap(); + let si = database::upsert_source_identity( + &conn, "web", "page", None, + Some("https://example.com/test"), + "https://example.com/test", + ) + .unwrap(); + database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: si, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "web".to_string(), + entity_kind: "page".to_string(), + title: Some("Test Entry".to_string()), + visibility: "private".to_string(), + representation_kind: "html".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ) + .unwrap() + } + + async fn body_json(response: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + + fn json_body(payload: &serde_json::Value) -> Body { + Body::from(serde_json::to_vec(payload).unwrap()) + } + #[tokio::test] async fn archives_endpoint_lists_mounted_archives() { + let dir = tempfile::tempdir().unwrap(); + let auth_path = dir.path().join("auth.sqlite"); + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } let registry = ServerRegistry { archives: vec![MountedArchive { id: "personal".to_string(), @@ -396,8 +717,9 @@ mod tests { archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"), }], bind: None, + auth_db_path: None, }; - let response = app(registry) + let response = app(registry, auth_path) .oneshot( Request::builder() .uri("/api/archives") @@ -412,7 +734,8 @@ mod tests { #[tokio::test] async fn missing_archive_returns_404() { - let response = app(ServerRegistry::default()) + let (test_app, _dir) = make_test_app(); + let response = test_app .oneshot( Request::builder() .uri("/api/archives/missing/entries") @@ -427,7 +750,8 @@ mod tests { #[tokio::test] async fn artifact_missing_archive_returns_404() { - let response = app(ServerRegistry::default()) + let (test_app, _dir) = make_test_app(); + let response = test_app .oneshot( Request::builder() .uri("/api/archives/nope/entries/entry_abc/artifacts/0") @@ -450,6 +774,11 @@ mod tests { ) .unwrap(); let archive_path = dir.path().join(".archivr"); + let auth_path = dir.path().join("auth.sqlite"); + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), @@ -457,8 +786,9 @@ mod tests { archive_path, }], bind: None, + auth_db_path: None, }; - let response = app(registry) + let response = app(registry, auth_path) .oneshot( Request::builder() .uri("/api/archives/test/entries/entry_doesnotexist/artifacts/0") @@ -481,6 +811,11 @@ mod tests { ) .unwrap(); let archive_path = dir.path().join(".archivr"); + let auth_path = dir.path().join("auth.sqlite"); + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), @@ -488,8 +823,9 @@ mod tests { archive_path, }], bind: None, + auth_db_path: None, }; - let response = app(registry) + let response = app(registry, auth_path) .oneshot( Request::builder() .uri("/api/archives/test/entries/entry_doesnotexist/artifacts/99") @@ -574,6 +910,11 @@ mod tests { .unwrap(); drop(conn); // release before the HTTP handler opens the same db file + let auth_path = dir.path().join("auth.sqlite"); + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), @@ -581,12 +922,13 @@ mod tests { archive_path: paths.archive_path.clone(), }], bind: None, + auth_db_path: None, }; let uri = format!( "/api/archives/test/entries/{}/artifacts/0", entry.entry_uid ); - let response = app(registry) + let response = app(registry, auth_path) .oneshot(Request::builder().uri(&uri).body(Body::empty()).unwrap()) .await .unwrap(); @@ -595,7 +937,8 @@ mod tests { #[tokio::test] async fn search_missing_archive_returns_404() { - let response = app(ServerRegistry::default()) + let (test_app, _dir) = make_test_app(); + let response = test_app .oneshot( Request::builder() .uri("/api/archives/nope/entries/search?q=anything") @@ -610,23 +953,8 @@ mod tests { #[tokio::test] async fn search_empty_q_returns_ok() { let dir = tempfile::tempdir().unwrap(); - archivr_core::archive::initialize_archive( - dir.path(), - &dir.path().join("store"), - "test", - false, - ) - .unwrap(); - let archive_path = dir.path().join(".archivr"); - let registry = ServerRegistry { - archives: vec![MountedArchive { - id: "test".to_string(), - label: "Test".to_string(), - archive_path, - }], - bind: None, - }; - let response = app(registry) + let (registry, _, auth_path) = make_test_registry(&dir); + let response = app(registry, auth_path) .oneshot( Request::builder() .uri("/api/archives/test/entries/search") @@ -641,23 +969,8 @@ mod tests { #[tokio::test] async fn search_unknown_prefix_returns_400() { let dir = tempfile::tempdir().unwrap(); - archivr_core::archive::initialize_archive( - dir.path(), - &dir.path().join("store"), - "test", - false, - ) - .unwrap(); - let archive_path = dir.path().join(".archivr"); - let registry = ServerRegistry { - archives: vec![MountedArchive { - id: "test".to_string(), - label: "Test".to_string(), - archive_path, - }], - bind: None, - }; - let response = app(registry) + let (registry, _, auth_path) = make_test_registry(&dir); + let response = app(registry, auth_path) .oneshot( Request::builder() .uri("/api/archives/test/entries/search?q=unknownprefix%3Aval") @@ -669,74 +982,12 @@ mod tests { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } - // ---- helpers ---- - - fn make_test_registry(dir: &tempfile::TempDir) -> (ServerRegistry, std::path::PathBuf) { - let paths = archivr_core::archive::initialize_archive( - dir.path(), - &dir.path().join("store"), - "test", - false, - ) - .unwrap(); - let registry = ServerRegistry { - archives: vec![MountedArchive { - id: "test".to_string(), - label: "Test".to_string(), - archive_path: paths.archive_path.clone(), - }], - bind: None, - }; - (registry, paths.archive_path) - } - - fn make_test_entry(archive_path: &std::path::Path) -> archivr_core::database::ArchivedEntry { - let conn = database::open_or_initialize(archive_path).unwrap(); - let user_id = database::ensure_default_user(&conn).unwrap(); - let run = database::create_archive_run(&conn, user_id, 1).unwrap(); - let si = database::upsert_source_identity( - &conn, "web", "page", None, - Some("https://example.com/test"), - "https://example.com/test", - ) - .unwrap(); - database::create_archived_entry( - &conn, - &database::NewEntry { - source_identity_id: si, - archive_run_id: run.id, - parent_entry_id: None, - root_entry_id: None, - created_by_user_id: user_id, - owned_by_user_id: user_id, - source_kind: "web".to_string(), - entity_kind: "page".to_string(), - title: Some("Test Entry".to_string()), - visibility: "private".to_string(), - representation_kind: "html".to_string(), - source_metadata_json: "{}".to_string(), - display_metadata_json: None, - }, - ) - .unwrap() - } - - async fn body_json(response: axum::response::Response) -> serde_json::Value { - let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - serde_json::from_slice(&bytes).unwrap() - } - - fn json_body(payload: &serde_json::Value) -> Body { - Body::from(serde_json::to_vec(payload).unwrap()) - } - // ---- tag route tests ---- #[tokio::test] async fn test_list_tags_unknown_archive() { - let response = app(ServerRegistry::default()) + let (test_app, _dir) = make_test_app(); + let response = test_app .oneshot( Request::builder() .uri("/api/archives/ghost/tags") @@ -750,7 +1001,8 @@ mod tests { #[tokio::test] async fn test_create_tag_unknown_archive() { - let response = app(ServerRegistry::default()) + let (test_app, _dir) = make_test_app(); + let response = test_app .oneshot( Request::builder() .method("POST") @@ -761,14 +1013,14 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); // auth fires before archive lookup } #[tokio::test] async fn test_create_tag_empty_path() { let dir = tempfile::tempdir().unwrap(); - let (registry, _) = make_test_registry(&dir); - let response = app(registry) + let (registry, _, auth_path) = make_test_registry(&dir); + let response = app(registry, auth_path) .oneshot( Request::builder() .method("POST") @@ -779,28 +1031,29 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); // auth fires before validation } #[tokio::test] async fn test_tag_round_trip() { let dir = tempfile::tempdir().unwrap(); - let (registry, _) = make_test_registry(&dir); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); - let create_response = app(registry.clone()) + let create_response = app(registry.clone(), auth_path.clone()) .oneshot( Request::builder() .method("POST") .uri("/api/archives/test/tags") .header("content-type", "application/json") + .header("cookie", &session_cookie) .body(json_body(&serde_json::json!({"path": "/science"}))) .unwrap(), ) .await .unwrap(); assert_eq!(create_response.status(), StatusCode::CREATED); - - let list_response = app(registry.clone()) + let list_response = app(registry.clone(), auth_path.clone()) .oneshot( Request::builder() .uri("/api/archives/test/tags") @@ -823,18 +1076,20 @@ mod tests { #[tokio::test] async fn test_entry_tag_assign_and_remove() { let dir = tempfile::tempdir().unwrap(); - let (registry, archive_path) = make_test_registry(&dir); + let (registry, archive_path, auth_path) = make_test_registry(&dir); let entry = make_test_entry(&archive_path); let entry_uid = entry.entry_uid.clone(); let entry_tags_uri = format!("/api/archives/test/entries/{entry_uid}/tags"); + let session_cookie = make_test_session(&auth_path); // Assign tag - let assign_response = app(registry.clone()) + let assign_response = app(registry.clone(), auth_path.clone()) .oneshot( Request::builder() .method("POST") .uri(&entry_tags_uri) .header("content-type", "application/json") + .header("cookie", &session_cookie) .body(json_body(&serde_json::json!({"tag_path": "/science"}))) .unwrap(), ) @@ -845,7 +1100,7 @@ mod tests { let tag_uid = assigned_tag["tag_uid"].as_str().unwrap().to_string(); // List entry tags — should contain the assigned tag - let list_response = app(registry.clone()) + let list_response = app(registry.clone(), auth_path.clone()) .oneshot( Request::builder() .uri(&entry_tags_uri) @@ -860,11 +1115,12 @@ mod tests { // Remove tag let delete_uri = format!("{entry_tags_uri}/{tag_uid}"); - let delete_response = app(registry.clone()) + let delete_response = app(registry.clone(), auth_path.clone()) .oneshot( Request::builder() .method("DELETE") .uri(&delete_uri) + .header("cookie", &session_cookie) .body(Body::empty()) .unwrap(), ) @@ -873,7 +1129,7 @@ mod tests { assert_eq!(delete_response.status(), StatusCode::NO_CONTENT); // List entry tags again — should be empty - let list2_response = app(registry.clone()) + let list2_response = app(registry.clone(), auth_path.clone()) .oneshot( Request::builder() .uri(&entry_tags_uri) @@ -890,17 +1146,19 @@ mod tests { #[tokio::test] async fn test_search_with_tag_param() { let dir = tempfile::tempdir().unwrap(); - let (registry, archive_path) = make_test_registry(&dir); + let (registry, archive_path, auth_path) = make_test_registry(&dir); let entry = make_test_entry(&archive_path); let entry_uid = entry.entry_uid.clone(); + let session_cookie = make_test_session(&auth_path); // Assign /science tag to entry - let assign_resp = app(registry.clone()) + let assign_resp = app(registry.clone(), auth_path.clone()) .oneshot( Request::builder() .method("POST") .uri(format!("/api/archives/test/entries/{entry_uid}/tags")) .header("content-type", "application/json") + .header("cookie", &session_cookie) .body(json_body(&serde_json::json!({"tag_path": "/science"}))) .unwrap(), ) @@ -909,7 +1167,7 @@ mod tests { assert_eq!(assign_resp.status(), StatusCode::CREATED, "assign tag should return 201"); // Search with ?tag=/science — entry should appear - let response = app(registry.clone()) + let response = app(registry.clone(), auth_path.clone()) .oneshot( Request::builder() .uri("/api/archives/test/entries/search?tag=%2Fscience") @@ -928,7 +1186,7 @@ mod tests { ); // Search with ?tag=/art — should return empty - let response2 = app(registry.clone()) + let response2 = app(registry.clone(), auth_path.clone()) .oneshot( Request::builder() .uri("/api/archives/test/entries/search?tag=%2Fart") @@ -948,8 +1206,8 @@ mod tests { #[tokio::test] async fn test_list_entry_tags_unknown_entry() { let dir = tempfile::tempdir().unwrap(); - let (registry, _) = make_test_registry(&dir); - let response = app(registry) + let (registry, _, auth_path) = make_test_registry(&dir); + let response = app(registry, auth_path) .oneshot( Request::builder() .uri("/api/archives/test/entries/ghost_uid/tags") @@ -964,8 +1222,8 @@ mod tests { #[tokio::test] async fn test_assign_entry_tag_unknown_entry() { let dir = tempfile::tempdir().unwrap(); - let (registry, _) = make_test_registry(&dir); - let response = app(registry) + let (registry, _, auth_path) = make_test_registry(&dir); + let response = app(registry, auth_path) .oneshot( Request::builder() .method("POST") @@ -976,16 +1234,16 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); // auth fires before entry lookup } #[tokio::test] async fn test_assign_entry_tag_empty_tag_path() { let dir = tempfile::tempdir().unwrap(); - let (registry, archive_path) = make_test_registry(&dir); + let (registry, archive_path, auth_path) = make_test_registry(&dir); let entry = make_test_entry(&archive_path); let entry_uid = entry.entry_uid.clone(); - let response = app(registry) + let response = app(registry, auth_path) .oneshot( Request::builder() .method("POST") @@ -996,14 +1254,14 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); // auth fires before validation } #[tokio::test] async fn test_remove_entry_tag_unknown_entry() { let dir = tempfile::tempdir().unwrap(); - let (registry, _) = make_test_registry(&dir); - let response = app(registry) + let (registry, _, auth_path) = make_test_registry(&dir); + let response = app(registry, auth_path) .oneshot( Request::builder() .method("DELETE") @@ -1013,12 +1271,13 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); // auth fires before entry lookup } #[tokio::test] async fn capture_rejects_empty_locator() { - let response = app(ServerRegistry::default()) + let (test_app, _dir) = make_test_app(); + let response = test_app .oneshot( Request::builder() .method("POST") @@ -1029,12 +1288,13 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); // auth fires before validation } #[tokio::test] async fn capture_rejects_unknown_archive() { - let response = app(ServerRegistry::default()) + let (test_app, _dir) = make_test_app(); + let response = test_app .oneshot( Request::builder() .method("POST") @@ -1045,7 +1305,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); // auth fires before archive lookup } #[tokio::test] @@ -1059,6 +1319,11 @@ mod tests { ) .unwrap(); let archive_path = dir.path().join(".archivr"); + let auth_path = dir.path().join("auth.sqlite"); + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap(); + } let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), @@ -1066,8 +1331,9 @@ mod tests { archive_path, }], bind: None, + auth_db_path: None, }; - let response = app(registry) + let response = app(registry, auth_path) .oneshot( Request::builder() .uri("/api/archives/test/blobs/0000000000000000000000000000000000000000000000000000000000000000") @@ -1079,4 +1345,80 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND); } + #[tokio::test] + async fn setup_required_before_owner_created() { + let (test_app, _dir) = make_setup_test_app(); + let response = test_app + .oneshot(Request::builder().uri("/api/auth/setup").body(Body::empty()).unwrap()) + .await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["setup_required"], true); + } + + #[tokio::test] + async fn setup_post_returns_409_on_repeat() { + let dir = tempfile::tempdir().unwrap(); + let auth_path = dir.path().join("auth.sqlite"); + // Seed an owner directly so the second POST hits CONFLICT + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + archivr_core::database::create_owner(&conn, "owner", "dummy").unwrap(); + } + let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; + let second_app = app(registry, auth_path); + let r2 = second_app + .oneshot(Request::builder().method("POST").uri("/api/auth/setup") + .header("content-type", "application/json") + .body(Body::from(r#"{"username":"owner2","password":"hunter2!"}"#)) + .unwrap()) + .await.unwrap(); + assert_eq!(r2.status(), StatusCode::CONFLICT); + } + + #[tokio::test] + async fn login_wrong_password_returns_401() { + let dir = tempfile::tempdir().unwrap(); + let auth_path = dir.path().join("auth.sqlite"); + { + let conn = archivr_core::database::open_auth_db(&auth_path).unwrap(); + let hash = crate::auth::hash_password("correct_password").unwrap(); + archivr_core::database::create_owner(&conn, "owner", &hash).unwrap(); + } + let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; + let response = app(registry, auth_path) + .oneshot(Request::builder().method("POST").uri("/api/auth/login") + .header("content-type", "application/json") + .body(Body::from(r#"{"username":"owner","password":"wrong"}"#)) + .unwrap()) + .await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn create_token_requires_auth() { + let (test_app, _dir) = make_test_app(); + let response = test_app + .oneshot(Request::builder().method("POST").uri("/api/auth/tokens") + .header("content-type", "application/json") + .body(Body::from(r#"{"name":"my token"}"#)) + .unwrap()) + .await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn capture_returns_401_for_unauthenticated() { + let (test_app, _dir) = make_test_app(); + let response = test_app + .oneshot(Request::builder().method("POST") + .uri("/api/archives/test/captures") + .header("content-type", "application/json") + .body(Body::from(r#"{"locator":"https://example.com"}"#)) + .unwrap()) + .await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + } diff --git a/crates/archivr-server/static/assets/index-Davzo15o.js b/crates/archivr-server/static/assets/index-Davzo15o.js new file mode 100644 index 0000000..cf5673d --- /dev/null +++ b/crates/archivr-server/static/assets/index-Davzo15o.js @@ -0,0 +1,40 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const u of i.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&r(u)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();var Go={exports:{}},ul={},Zo={exports:{}},O={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var nr=Symbol.for("react.element"),dc=Symbol.for("react.portal"),pc=Symbol.for("react.fragment"),hc=Symbol.for("react.strict_mode"),mc=Symbol.for("react.profiler"),vc=Symbol.for("react.provider"),gc=Symbol.for("react.context"),yc=Symbol.for("react.forward_ref"),wc=Symbol.for("react.suspense"),Sc=Symbol.for("react.memo"),kc=Symbol.for("react.lazy"),Iu=Symbol.iterator;function xc(e){return e===null||typeof e!="object"?null:(e=Iu&&e[Iu]||e["@@iterator"],typeof e=="function"?e:null)}var Jo={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},qo=Object.assign,bo={};function hn(e,t,n){this.props=e,this.context=t,this.refs=bo,this.updater=n||Jo}hn.prototype.isReactComponent={};hn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};hn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function es(){}es.prototype=hn.prototype;function Hi(e,t,n){this.props=e,this.context=t,this.refs=bo,this.updater=n||Jo}var Wi=Hi.prototype=new es;Wi.constructor=Hi;qo(Wi,hn.prototype);Wi.isPureReactComponent=!0;var $u=Array.isArray,ts=Object.prototype.hasOwnProperty,Qi={current:null},ns={key:!0,ref:!0,__self:!0,__source:!0};function rs(e,t,n){var r,l={},i=null,u=null;if(t!=null)for(r in t.ref!==void 0&&(u=t.ref),t.key!==void 0&&(i=""+t.key),t)ts.call(t,r)&&!ns.hasOwnProperty(r)&&(l[r]=t[r]);var o=arguments.length-2;if(o===1)l.children=n;else if(1>>1,X=E[B];if(0>>1;Bl(ze,L))$el(Te,ze)?(E[B]=Te,E[$e]=L,B=$e):(E[B]=ze,E[F]=L,B=F);else if($el(Te,L))E[B]=Te,E[$e]=L,B=$e;else break e}}return T}function l(E,T){var L=E.sortIndex-T.sortIndex;return L!==0?L:E.id-T.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var u=Date,o=u.now();e.unstable_now=function(){return u.now()-o}}var s=[],f=[],v=1,m=null,p=3,w=!1,S=!1,k=!1,P=typeof setTimeout=="function"?setTimeout:null,c=typeof clearTimeout=="function"?clearTimeout:null,a=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function d(E){for(var T=n(f);T!==null;){if(T.callback===null)r(f);else if(T.startTime<=E)r(f),T.sortIndex=T.expirationTime,t(s,T);else break;T=n(f)}}function g(E){if(k=!1,d(E),!S)if(n(s)!==null)S=!0,Ut(x);else{var T=n(f);T!==null&&gn(g,T.startTime-E)}}function x(E,T){S=!1,k&&(k=!1,c(z),z=-1),w=!0;var L=p;try{for(d(T),m=n(s);m!==null&&(!(m.expirationTime>T)||E&&!oe());){var B=m.callback;if(typeof B=="function"){m.callback=null,p=m.priorityLevel;var X=B(m.expirationTime<=T);T=e.unstable_now(),typeof X=="function"?m.callback=X:m===n(s)&&r(s),d(T)}else r(s);m=n(s)}if(m!==null)var At=!0;else{var F=n(f);F!==null&&gn(g,F.startTime-T),At=!1}return At}finally{m=null,p=L,w=!1}}var _=!1,N=null,z=-1,A=5,R=-1;function oe(){return!(e.unstable_now()-RE||125B?(E.sortIndex=L,t(f,E),n(s)===null&&E===n(f)&&(k?(c(z),z=-1):k=!0,gn(g,L-B))):(E.sortIndex=X,t(s,E),S||w||(S=!0,Ut(x))),E},e.unstable_shouldYield=oe,e.unstable_wrapCallback=function(E){var T=p;return function(){var L=p;p=T;try{return E.apply(this,arguments)}finally{p=L}}}})(ss);os.exports=ss;var Oc=os.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Fc=j,Se=Oc;function y(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Jl=Object.prototype.hasOwnProperty,Mc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Au={},Vu={};function Dc(e){return Jl.call(Vu,e)?!0:Jl.call(Au,e)?!1:Mc.test(e)?Vu[e]=!0:(Au[e]=!0,!1)}function Ic(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function $c(e,t,n,r){if(t===null||typeof t>"u"||Ic(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function fe(e,t,n,r,l,i,u){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=u}var ne={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ne[e]=new fe(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ne[t]=new fe(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ne[e]=new fe(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ne[e]=new fe(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ne[e]=new fe(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ne[e]=new fe(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ne[e]=new fe(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ne[e]=new fe(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ne[e]=new fe(e,5,!1,e.toLowerCase(),null,!1,!1)});var Yi=/[\-:]([a-z])/g;function Xi(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Yi,Xi);ne[t]=new fe(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Yi,Xi);ne[t]=new fe(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Yi,Xi);ne[t]=new fe(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ne[e]=new fe(e,1,!1,e.toLowerCase(),null,!1,!1)});ne.xlinkHref=new fe("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ne[e]=new fe(e,1,!1,e.toLowerCase(),null,!0,!0)});function Gi(e,t,n,r){var l=ne.hasOwnProperty(t)?ne[t]:null;(l!==null?l.type!==0:r||!(2o||l[u]!==i[o]){var s=` +`+l[u].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=u&&0<=o);break}}}finally{_l=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Nn(e):""}function Uc(e){switch(e.tag){case 5:return Nn(e.type);case 16:return Nn("Lazy");case 13:return Nn("Suspense");case 19:return Nn("SuspenseList");case 0:case 2:case 15:return e=Nl(e.type,!1),e;case 11:return e=Nl(e.type.render,!1),e;case 1:return e=Nl(e.type,!0),e;default:return""}}function ti(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Ht:return"Fragment";case Bt:return"Portal";case ql:return"Profiler";case Zi:return"StrictMode";case bl:return"Suspense";case ei:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case fs:return(e.displayName||"Context")+".Consumer";case cs:return(e._context.displayName||"Context")+".Provider";case Ji:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case qi:return t=e.displayName||null,t!==null?t:ti(e.type)||"Memo";case rt:t=e._payload,e=e._init;try{return ti(e(t))}catch{}}return null}function Ac(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ti(t);case 8:return t===Zi?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function gt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ps(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Vc(e){var t=ps(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(u){r=""+u,i.call(this,u)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ar(e){e._valueTracker||(e._valueTracker=Vc(e))}function hs(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=ps(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Dr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ni(e,t){var n=t.checked;return Q({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Hu(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=gt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function ms(e,t){t=t.checked,t!=null&&Gi(e,"checked",t,!1)}function ri(e,t){ms(e,t);var n=gt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?li(e,t.type,n):t.hasOwnProperty("defaultValue")&&li(e,t.type,gt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Wu(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function li(e,t,n){(t!=="number"||Dr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var jn=Array.isArray;function en(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=cr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function An(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Tn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Bc=["Webkit","ms","Moz","O"];Object.keys(Tn).forEach(function(e){Bc.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Tn[t]=Tn[e]})});function ws(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Tn.hasOwnProperty(e)&&Tn[e]?(""+t).trim():t+"px"}function Ss(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=ws(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Hc=Q({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function oi(e,t){if(t){if(Hc[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(y(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(y(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(y(61))}if(t.style!=null&&typeof t.style!="object")throw Error(y(62))}}function si(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var ai=null;function bi(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var ci=null,tn=null,nn=null;function Yu(e){if(e=ir(e)){if(typeof ci!="function")throw Error(y(280));var t=e.stateNode;t&&(t=fl(t),ci(e.stateNode,e.type,t))}}function ks(e){tn?nn?nn.push(e):nn=[e]:tn=e}function xs(){if(tn){var e=tn,t=nn;if(nn=tn=null,Yu(e),t)for(e=0;e>>=0,e===0?32:31-(ef(e)/tf|0)|0}var fr=64,dr=4194304;function Pn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Ar(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,u=n&268435455;if(u!==0){var o=u&~l;o!==0?r=Pn(o):(i&=u,i!==0&&(r=Pn(i)))}else u=n&~l,u!==0?r=Pn(u):i!==0&&(r=Pn(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function rr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Me(t),e[t]=n}function uf(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Rn),no=" ",ro=!1;function Bs(e,t){switch(e){case"keyup":return Ff.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Hs(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Wt=!1;function Df(e,t){switch(e){case"compositionend":return Hs(t);case"keypress":return t.which!==32?null:(ro=!0,no);case"textInput":return e=t.data,e===no&&ro?null:e;default:return null}}function If(e,t){if(Wt)return e==="compositionend"||!ou&&Bs(e,t)?(e=As(),jr=lu=ot=null,Wt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=oo(n)}}function Ys(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ys(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Xs(){for(var e=window,t=Dr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Dr(e.document)}return t}function su(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Kf(e){var t=Xs(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ys(n.ownerDocument.documentElement,n)){if(r!==null&&su(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=so(n,i);var u=so(n,r);l&&u&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==u.node||e.focusOffset!==u.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(u.node,u.offset)):(t.setEnd(u.node,u.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Qt=null,vi=null,Fn=null,gi=!1;function ao(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;gi||Qt==null||Qt!==Dr(r)||(r=Qt,"selectionStart"in r&&su(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Fn&&Kn(Fn,r)||(Fn=r,r=Hr(vi,"onSelect"),0Xt||(e.current=Ei[Xt],Ei[Xt]=null,Xt--)}function I(e,t){Xt++,Ei[Xt]=e.current,e.current=t}var yt={},ue=St(yt),he=St(!1),Lt=yt;function sn(e,t){var n=e.type.contextTypes;if(!n)return yt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function me(e){return e=e.childContextTypes,e!=null}function Qr(){U(he),U(ue)}function go(e,t,n){if(ue.current!==yt)throw Error(y(168));I(ue,t),I(he,n)}function ra(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(y(108,Ac(e)||"Unknown",l));return Q({},n,r)}function Kr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||yt,Lt=ue.current,I(ue,e),I(he,he.current),!0}function yo(e,t,n){var r=e.stateNode;if(!r)throw Error(y(169));n?(e=ra(e,t,Lt),r.__reactInternalMemoizedMergedChildContext=e,U(he),U(ue),I(ue,e)):U(he),I(he,n)}var Qe=null,dl=!1,Al=!1;function la(e){Qe===null?Qe=[e]:Qe.push(e)}function ld(e){dl=!0,la(e)}function kt(){if(!Al&&Qe!==null){Al=!0;var e=0,t=D;try{var n=Qe;for(D=1;e>=u,l-=u,Ke=1<<32-Me(t)+l|n<z?(A=N,N=null):A=N.sibling;var R=p(c,N,d[z],g);if(R===null){N===null&&(N=A);break}e&&N&&R.alternate===null&&t(c,N),a=i(R,a,z),_===null?x=R:_.sibling=R,_=R,N=A}if(z===d.length)return n(c,N),V&&Et(c,z),x;if(N===null){for(;zz?(A=N,N=null):A=N.sibling;var oe=p(c,N,R.value,g);if(oe===null){N===null&&(N=A);break}e&&N&&oe.alternate===null&&t(c,N),a=i(oe,a,z),_===null?x=oe:_.sibling=oe,_=oe,N=A}if(R.done)return n(c,N),V&&Et(c,z),x;if(N===null){for(;!R.done;z++,R=d.next())R=m(c,R.value,g),R!==null&&(a=i(R,a,z),_===null?x=R:_.sibling=R,_=R);return V&&Et(c,z),x}for(N=r(c,N);!R.done;z++,R=d.next())R=w(N,c,z,R.value,g),R!==null&&(e&&R.alternate!==null&&N.delete(R.key===null?z:R.key),a=i(R,a,z),_===null?x=R:_.sibling=R,_=R);return e&&N.forEach(function(xt){return t(c,xt)}),V&&Et(c,z),x}function P(c,a,d,g){if(typeof d=="object"&&d!==null&&d.type===Ht&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case sr:e:{for(var x=d.key,_=a;_!==null;){if(_.key===x){if(x=d.type,x===Ht){if(_.tag===7){n(c,_.sibling),a=l(_,d.props.children),a.return=c,c=a;break e}}else if(_.elementType===x||typeof x=="object"&&x!==null&&x.$$typeof===rt&&ko(x)===_.type){n(c,_.sibling),a=l(_,d.props),a.ref=En(c,_,d),a.return=c,c=a;break e}n(c,_);break}else t(c,_);_=_.sibling}d.type===Ht?(a=Tt(d.props.children,c.mode,g,d.key),a.return=c,c=a):(g=Mr(d.type,d.key,d.props,null,c.mode,g),g.ref=En(c,a,d),g.return=c,c=g)}return u(c);case Bt:e:{for(_=d.key;a!==null;){if(a.key===_)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){n(c,a.sibling),a=l(a,d.children||[]),a.return=c,c=a;break e}else{n(c,a);break}else t(c,a);a=a.sibling}a=Xl(d,c.mode,g),a.return=c,c=a}return u(c);case rt:return _=d._init,P(c,a,_(d._payload),g)}if(jn(d))return S(c,a,d,g);if(yn(d))return k(c,a,d,g);wr(c,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(n(c,a.sibling),a=l(a,d),a.return=c,c=a):(n(c,a),a=Yl(d,c.mode,g),a.return=c,c=a),u(c)):n(c,a)}return P}var cn=sa(!0),aa=sa(!1),Gr=St(null),Zr=null,Jt=null,du=null;function pu(){du=Jt=Zr=null}function hu(e){var t=Gr.current;U(Gr),e._currentValue=t}function Ni(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function ln(e,t){Zr=e,du=Jt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(pe=!0),e.firstContext=null)}function je(e){var t=e._currentValue;if(du!==e)if(e={context:e,memoizedValue:t,next:null},Jt===null){if(Zr===null)throw Error(y(308));Jt=e,Zr.dependencies={lanes:0,firstContext:e}}else Jt=Jt.next=e;return t}var Nt=null;function mu(e){Nt===null?Nt=[e]:Nt.push(e)}function ca(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,mu(t)):(n.next=l.next,l.next=n),t.interleaved=n,Je(e,r)}function Je(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var lt=!1;function vu(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function fa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Xe(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function pt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,M&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Je(e,n)}return l=r.interleaved,l===null?(t.next=t,mu(r)):(t.next=l.next,l.next=t),r.interleaved=t,Je(e,n)}function zr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,tu(e,n)}}function xo(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var u={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=u:i=i.next=u,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Jr(e,t,n,r){var l=e.updateQueue;lt=!1;var i=l.firstBaseUpdate,u=l.lastBaseUpdate,o=l.shared.pending;if(o!==null){l.shared.pending=null;var s=o,f=s.next;s.next=null,u===null?i=f:u.next=f,u=s;var v=e.alternate;v!==null&&(v=v.updateQueue,o=v.lastBaseUpdate,o!==u&&(o===null?v.firstBaseUpdate=f:o.next=f,v.lastBaseUpdate=s))}if(i!==null){var m=l.baseState;u=0,v=f=s=null,o=i;do{var p=o.lane,w=o.eventTime;if((r&p)===p){v!==null&&(v=v.next={eventTime:w,lane:0,tag:o.tag,payload:o.payload,callback:o.callback,next:null});e:{var S=e,k=o;switch(p=t,w=n,k.tag){case 1:if(S=k.payload,typeof S=="function"){m=S.call(w,m,p);break e}m=S;break e;case 3:S.flags=S.flags&-65537|128;case 0:if(S=k.payload,p=typeof S=="function"?S.call(w,m,p):S,p==null)break e;m=Q({},m,p);break e;case 2:lt=!0}}o.callback!==null&&o.lane!==0&&(e.flags|=64,p=l.effects,p===null?l.effects=[o]:p.push(o))}else w={eventTime:w,lane:p,tag:o.tag,payload:o.payload,callback:o.callback,next:null},v===null?(f=v=w,s=m):v=v.next=w,u|=p;if(o=o.next,o===null){if(o=l.shared.pending,o===null)break;p=o,o=p.next,p.next=null,l.lastBaseUpdate=p,l.shared.pending=null}}while(!0);if(v===null&&(s=m),l.baseState=s,l.firstBaseUpdate=f,l.lastBaseUpdate=v,t=l.shared.interleaved,t!==null){l=t;do u|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);Ft|=u,e.lanes=u,e.memoizedState=m}}function Eo(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Bl.transition;Bl.transition={};try{e(!1),t()}finally{D=n,Bl.transition=r}}function Pa(){return Pe().memoizedState}function sd(e,t,n){var r=mt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},za(e))Ta(t,n);else if(n=ca(e,t,n,r),n!==null){var l=ae();De(n,e,r,l),La(n,t,r)}}function ad(e,t,n){var r=mt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(za(e))Ta(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var u=t.lastRenderedState,o=i(u,n);if(l.hasEagerState=!0,l.eagerState=o,Ie(o,u)){var s=t.interleaved;s===null?(l.next=l,mu(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=ca(e,t,l,r),n!==null&&(l=ae(),De(n,e,r,l),La(n,t,r))}}function za(e){var t=e.alternate;return e===W||t!==null&&t===W}function Ta(e,t){Mn=br=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function La(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,tu(e,n)}}var el={readContext:je,useCallback:re,useContext:re,useEffect:re,useImperativeHandle:re,useInsertionEffect:re,useLayoutEffect:re,useMemo:re,useReducer:re,useRef:re,useState:re,useDebugValue:re,useDeferredValue:re,useTransition:re,useMutableSource:re,useSyncExternalStore:re,useId:re,unstable_isNewReconciler:!1},cd={readContext:je,useCallback:function(e,t){return Ae().memoizedState=[e,t===void 0?null:t],e},useContext:je,useEffect:_o,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Lr(4194308,4,Ea.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Lr(4194308,4,e,t)},useInsertionEffect:function(e,t){return Lr(4,2,e,t)},useMemo:function(e,t){var n=Ae();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ae();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=sd.bind(null,W,e),[r.memoizedState,e]},useRef:function(e){var t=Ae();return e={current:e},t.memoizedState=e},useState:Co,useDebugValue:Cu,useDeferredValue:function(e){return Ae().memoizedState=e},useTransition:function(){var e=Co(!1),t=e[0];return e=od.bind(null,e[1]),Ae().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=W,l=Ae();if(V){if(n===void 0)throw Error(y(407));n=n()}else{if(n=t(),b===null)throw Error(y(349));Ot&30||ma(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,_o(ga.bind(null,r,i,e),[e]),r.flags|=2048,er(9,va.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=Ae(),t=b.identifierPrefix;if(V){var n=Ye,r=Ke;n=(r&~(1<<32-Me(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=qn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(n,{is:r.is}):(e=u.createElement(n),n==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,n),e[Ve]=t,e[Gn]=r,Va(e,t,!1,!1),t.stateNode=e;e:{switch(u=si(n,r),n){case"dialog":$("cancel",e),$("close",e),l=r;break;case"iframe":case"object":case"embed":$("load",e),l=r;break;case"video":case"audio":for(l=0;lpn&&(t.flags|=128,r=!0,Cn(i,!1),t.lanes=4194304)}else{if(!r)if(e=qr(u),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Cn(i,!0),i.tail===null&&i.tailMode==="hidden"&&!u.alternate&&!V)return le(t),null}else 2*Y()-i.renderingStartTime>pn&&n!==1073741824&&(t.flags|=128,r=!0,Cn(i,!1),t.lanes=4194304);i.isBackwards?(u.sibling=t.child,t.child=u):(n=i.last,n!==null?n.sibling=u:t.child=u,i.last=u)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=Y(),t.sibling=null,n=H.current,I(H,r?n&1|2:n&1),t):(le(t),null);case 22:case 23:return Tu(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?ge&1073741824&&(le(t),t.subtreeFlags&6&&(t.flags|=8192)):le(t),null;case 24:return null;case 25:return null}throw Error(y(156,t.tag))}function yd(e,t){switch(cu(t),t.tag){case 1:return me(t.type)&&Qr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return fn(),U(he),U(ue),wu(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return yu(t),null;case 13:if(U(H),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(y(340));an()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return U(H),null;case 4:return fn(),null;case 10:return hu(t.type._context),null;case 22:case 23:return Tu(),null;case 24:return null;default:return null}}var kr=!1,ie=!1,wd=typeof WeakSet=="function"?WeakSet:Set,C=null;function qt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){K(e,t,r)}else n.current=null}function Mi(e,t,n){try{n()}catch(r){K(e,t,r)}}var Do=!1;function Sd(e,t){if(yi=Vr,e=Xs(),su(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var u=0,o=-1,s=-1,f=0,v=0,m=e,p=null;t:for(;;){for(var w;m!==n||l!==0&&m.nodeType!==3||(o=u+l),m!==i||r!==0&&m.nodeType!==3||(s=u+r),m.nodeType===3&&(u+=m.nodeValue.length),(w=m.firstChild)!==null;)p=m,m=w;for(;;){if(m===e)break t;if(p===n&&++f===l&&(o=u),p===i&&++v===r&&(s=u),(w=m.nextSibling)!==null)break;m=p,p=m.parentNode}m=w}n=o===-1||s===-1?null:{start:o,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(wi={focusedElem:e,selectionRange:n},Vr=!1,C=t;C!==null;)if(t=C,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,C=e;else for(;C!==null;){t=C;try{var S=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(S!==null){var k=S.memoizedProps,P=S.memoizedState,c=t.stateNode,a=c.getSnapshotBeforeUpdate(t.elementType===t.type?k:Re(t.type,k),P);c.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(y(163))}}catch(g){K(t,t.return,g)}if(e=t.sibling,e!==null){e.return=t.return,C=e;break}C=t.return}return S=Do,Do=!1,S}function Dn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&Mi(t,n,i)}l=l.next}while(l!==r)}}function ml(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Di(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Wa(e){var t=e.alternate;t!==null&&(e.alternate=null,Wa(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ve],delete t[Gn],delete t[xi],delete t[nd],delete t[rd])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Qa(e){return e.tag===5||e.tag===3||e.tag===4}function Io(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Qa(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Ii(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Wr));else if(r!==4&&(e=e.child,e!==null))for(Ii(e,t,n),e=e.sibling;e!==null;)Ii(e,t,n),e=e.sibling}function $i(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for($i(e,t,n),e=e.sibling;e!==null;)$i(e,t,n),e=e.sibling}var ee=null,Oe=!1;function nt(e,t,n){for(n=n.child;n!==null;)Ka(e,t,n),n=n.sibling}function Ka(e,t,n){if(Be&&typeof Be.onCommitFiberUnmount=="function")try{Be.onCommitFiberUnmount(ol,n)}catch{}switch(n.tag){case 5:ie||qt(n,t);case 6:var r=ee,l=Oe;ee=null,nt(e,t,n),ee=r,Oe=l,ee!==null&&(Oe?(e=ee,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):ee.removeChild(n.stateNode));break;case 18:ee!==null&&(Oe?(e=ee,n=n.stateNode,e.nodeType===8?Ul(e.parentNode,n):e.nodeType===1&&Ul(e,n),Wn(e)):Ul(ee,n.stateNode));break;case 4:r=ee,l=Oe,ee=n.stateNode.containerInfo,Oe=!0,nt(e,t,n),ee=r,Oe=l;break;case 0:case 11:case 14:case 15:if(!ie&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,u=i.destroy;i=i.tag,u!==void 0&&(i&2||i&4)&&Mi(n,t,u),l=l.next}while(l!==r)}nt(e,t,n);break;case 1:if(!ie&&(qt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(o){K(n,t,o)}nt(e,t,n);break;case 21:nt(e,t,n);break;case 22:n.mode&1?(ie=(r=ie)||n.memoizedState!==null,nt(e,t,n),ie=r):nt(e,t,n);break;default:nt(e,t,n)}}function $o(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new wd),t.forEach(function(r){var l=zd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Le(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=u),r&=~i}if(r=l,r=Y()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*xd(r/1960))-r,10e?16:e,st===null)var r=!1;else{if(e=st,st=null,rl=0,M&6)throw Error(y(331));var l=M;for(M|=4,C=e.current;C!==null;){var i=C,u=i.child;if(C.flags&16){var o=i.deletions;if(o!==null){for(var s=0;sY()-Pu?zt(e,0):ju|=n),ve(e,t)}function ec(e,t){t===0&&(e.mode&1?(t=dr,dr<<=1,!(dr&130023424)&&(dr=4194304)):t=1);var n=ae();e=Je(e,t),e!==null&&(rr(e,t,n),ve(e,n))}function Pd(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ec(e,n)}function zd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(y(314))}r!==null&&r.delete(t),ec(e,n)}var tc;tc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||he.current)pe=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return pe=!1,vd(e,t,n);pe=!!(e.flags&131072)}else pe=!1,V&&t.flags&1048576&&ia(t,Xr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Rr(e,t),e=t.pendingProps;var l=sn(t,ue.current);ln(t,n),l=ku(null,t,r,e,l,n);var i=xu();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,me(r)?(i=!0,Kr(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,vu(t),l.updater=hl,t.stateNode=l,l._reactInternals=t,Pi(t,r,e,n),t=Li(null,t,r,!0,i,n)):(t.tag=0,V&&i&&au(t),se(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Rr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Ld(r),e=Re(r,e),l){case 0:t=Ti(null,t,r,e,n);break e;case 1:t=Oo(null,t,r,e,n);break e;case 11:t=Lo(null,t,r,e,n);break e;case 14:t=Ro(null,t,r,Re(r.type,e),n);break e}throw Error(y(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Re(r,l),Ti(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Re(r,l),Oo(e,t,r,l,n);case 3:e:{if($a(t),e===null)throw Error(y(387));r=t.pendingProps,i=t.memoizedState,l=i.element,fa(e,t),Jr(t,r,null,n);var u=t.memoizedState;if(r=u.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:u.cache,pendingSuspenseBoundaries:u.pendingSuspenseBoundaries,transitions:u.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=dn(Error(y(423)),t),t=Fo(e,t,r,n,l);break e}else if(r!==l){l=dn(Error(y(424)),t),t=Fo(e,t,r,n,l);break e}else for(ye=dt(t.stateNode.containerInfo.firstChild),we=t,V=!0,Fe=null,n=aa(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(an(),r===l){t=qe(e,t,n);break e}se(e,t,r,n)}t=t.child}return t;case 5:return da(t),e===null&&_i(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,u=l.children,Si(r,l)?u=null:i!==null&&Si(r,i)&&(t.flags|=32),Ia(e,t),se(e,t,u,n),t.child;case 6:return e===null&&_i(t),null;case 13:return Ua(e,t,n);case 4:return gu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=cn(t,null,r,n):se(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Re(r,l),Lo(e,t,r,l,n);case 7:return se(e,t,t.pendingProps,n),t.child;case 8:return se(e,t,t.pendingProps.children,n),t.child;case 12:return se(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,u=l.value,I(Gr,r._currentValue),r._currentValue=u,i!==null)if(Ie(i.value,u)){if(i.children===l.children&&!he.current){t=qe(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var o=i.dependencies;if(o!==null){u=i.child;for(var s=o.firstContext;s!==null;){if(s.context===r){if(i.tag===1){s=Xe(-1,n&-n),s.tag=2;var f=i.updateQueue;if(f!==null){f=f.shared;var v=f.pending;v===null?s.next=s:(s.next=v.next,v.next=s),f.pending=s}}i.lanes|=n,s=i.alternate,s!==null&&(s.lanes|=n),Ni(i.return,n,t),o.lanes|=n;break}s=s.next}}else if(i.tag===10)u=i.type===t.type?null:i.child;else if(i.tag===18){if(u=i.return,u===null)throw Error(y(341));u.lanes|=n,o=u.alternate,o!==null&&(o.lanes|=n),Ni(u,n,t),u=i.sibling}else u=i.child;if(u!==null)u.return=i;else for(u=i;u!==null;){if(u===t){u=null;break}if(i=u.sibling,i!==null){i.return=u.return,u=i;break}u=u.return}i=u}se(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,ln(t,n),l=je(l),r=r(l),t.flags|=1,se(e,t,r,n),t.child;case 14:return r=t.type,l=Re(r,t.pendingProps),l=Re(r.type,l),Ro(e,t,r,l,n);case 15:return Ma(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Re(r,l),Rr(e,t),t.tag=1,me(r)?(e=!0,Kr(t)):e=!1,ln(t,n),Ra(t,r,l),Pi(t,r,l,n),Li(null,t,r,!0,e,n);case 19:return Aa(e,t,n);case 22:return Da(e,t,n)}throw Error(y(156,t.tag))};function nc(e,t){return zs(e,t)}function Td(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function _e(e,t,n,r){return new Td(e,t,n,r)}function Ru(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Ld(e){if(typeof e=="function")return Ru(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Ji)return 11;if(e===qi)return 14}return 2}function vt(e,t){var n=e.alternate;return n===null?(n=_e(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Mr(e,t,n,r,l,i){var u=2;if(r=e,typeof e=="function")Ru(e)&&(u=1);else if(typeof e=="string")u=5;else e:switch(e){case Ht:return Tt(n.children,l,i,t);case Zi:u=8,l|=8;break;case ql:return e=_e(12,n,t,l|2),e.elementType=ql,e.lanes=i,e;case bl:return e=_e(13,n,t,l),e.elementType=bl,e.lanes=i,e;case ei:return e=_e(19,n,t,l),e.elementType=ei,e.lanes=i,e;case ds:return gl(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case cs:u=10;break e;case fs:u=9;break e;case Ji:u=11;break e;case qi:u=14;break e;case rt:u=16,r=null;break e}throw Error(y(130,e==null?e:typeof e,""))}return t=_e(u,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function Tt(e,t,n,r){return e=_e(7,e,r,t),e.lanes=n,e}function gl(e,t,n,r){return e=_e(22,e,r,t),e.elementType=ds,e.lanes=n,e.stateNode={isHidden:!1},e}function Yl(e,t,n){return e=_e(6,e,null,t),e.lanes=n,e}function Xl(e,t,n){return t=_e(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Rd(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Pl(0),this.expirationTimes=Pl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Pl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Ou(e,t,n,r,l,i,u,o,s){return e=new Rd(e,t,n,o,s),t===1?(t=1,i===!0&&(t|=8)):t=0,i=_e(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},vu(i),e}function Od(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(uc)}catch(e){console.error(e)}}uc(),us.exports=ke;var $d=us.exports,oc,Ko=$d;oc=Ko.createRoot,Ko.hydrateRoot;async function $t(e){const t=await fetch(e);if(!t.ok)throw new Error(`${t.status} ${t.statusText}`);return t.json()}async function Ud(){return $t("/api/archives")}async function Ad(e){return $t(`/api/archives/${e}/entries`)}async function Vd(e,t,n){const r=new URLSearchParams;return t&&r.set("q",t),n&&r.set("tag",n),$t(`/api/archives/${e}/entries/search?${r}`)}async function Bd(e,t){return $t(`/api/archives/${e}/entries/${t}`)}async function Gl(e,t){return $t(`/api/archives/${e}/entries/${t}/tags`)}async function Hd(e,t,n){const r=await fetch(`/api/archives/${e}/entries/${t}/tags`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tag_path:n})});if(!r.ok)throw new Error(`Failed to add tag (${r.status})`)}async function Wd(e,t,n){const r=await fetch(`/api/archives/${e}/entries/${t}/tags/${n}`,{method:"DELETE"});if(!r.ok)throw new Error(`Remove failed (${r.status})`)}async function Yo(e){return $t(`/api/archives/${e}/runs`)}async function Zl(e){return $t(`/api/archives/${e}/tags`)}async function Qd(e,t){const n=await fetch(`/api/archives/${e}/captures`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locator:t})});if(!n.ok){const r=await n.text();throw new Error(r||`HTTP ${n.status}`)}}async function Kd(){return(await(await fetch("/api/auth/setup")).json()).setup_required===!0}async function Yd(e,t){const n=await fetch("/api/auth/setup",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({username:e,password:t})});if(!n.ok)throw new Error((await n.json()).error||"Setup failed");return n.json()}async function Xd(e,t){const n=await fetch("/api/auth/login",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({username:e,password:t})});if(!n.ok)throw new Error((await n.json()).error||"Login failed");return n.json()}async function Gd(){await fetch("/api/auth/logout",{method:"POST"})}async function Zd(){const e=await fetch("/api/auth/me");return e.status===401?null:e.json()}const Jd=window.fetch;window.fetch=async(...e)=>{var n;const t=await Jd(...e);return t.status===401&&((typeof e[0]=="string"?e[0]:((n=e[0])==null?void 0:n.url)??"").includes("/api/auth/")||window.dispatchEvent(new CustomEvent("auth:expired"))),t};function qd({onLogin:e}){const[t,n]=j.useState(""),[r,l]=j.useState(""),[i,u]=j.useState(null),[o,s]=j.useState(!1);async function f(v){v.preventDefault(),u(null),s(!0);try{const m=await Xd(t,r);e(m)}catch(m){u(m.message)}finally{s(!1)}}return h.jsxs("div",{className:"login-page",children:[h.jsx("h1",{children:"Archivr"}),h.jsxs("form",{onSubmit:f,children:[h.jsxs("label",{children:["Username",h.jsx("input",{type:"text",value:t,onChange:v=>n(v.target.value),autoFocus:!0,required:!0})]}),h.jsxs("label",{children:["Password",h.jsx("input",{type:"password",value:r,onChange:v=>l(v.target.value),required:!0})]}),i&&h.jsx("p",{className:"error",children:i}),h.jsx("button",{type:"submit",disabled:o,children:o?"Logging in…":"Log in"})]})]})}function bd({onComplete:e}){const[t,n]=j.useState(""),[r,l]=j.useState(""),[i,u]=j.useState(""),[o,s]=j.useState(null),[f,v]=j.useState(!1);async function m(p){if(p.preventDefault(),r!==i){s("Passwords do not match");return}if(r.length<8){s("Password must be at least 8 characters");return}s(null),v(!0);try{await Yd(t,r),e()}catch(w){s(w.message)}finally{v(!1)}}return h.jsxs("div",{className:"setup-page",children:[h.jsx("h1",{children:"Welcome to Archivr"}),h.jsx("p",{children:"Create your owner account to get started."}),h.jsxs("form",{onSubmit:m,children:[h.jsxs("label",{children:["Username",h.jsx("input",{type:"text",value:t,onChange:p=>n(p.target.value),autoFocus:!0,required:!0})]}),h.jsxs("label",{children:["Password",h.jsx("input",{type:"password",value:r,onChange:p=>l(p.target.value),required:!0})]}),h.jsxs("label",{children:["Confirm password",h.jsx("input",{type:"password",value:i,onChange:p=>u(p.target.value),required:!0})]}),o&&h.jsx("p",{className:"error",children:o}),h.jsx("button",{type:"submit",disabled:f,children:f?"Creating account…":"Create account"})]})]})}function ep({archives:e,archiveId:t,onArchiveChange:n,view:r,onViewChange:l,onCaptureClick:i}){const{currentUser:u,setCurrentUser:o}=j.useContext(fc)??{},[s,f]=j.useState(!1);async function v(){f(!0),await Gd(),o(null),window.location.reload()}return h.jsxs("header",{className:"topbar",children:[h.jsx("div",{className:"brand",children:"Archivr"}),h.jsx("select",{className:"archive-switcher","aria-label":"Select archive",value:t??"",onChange:m=>n(m.target.value),children:e.map(m=>h.jsx("option",{value:m.id,children:m.label},m.id))}),h.jsx("nav",{className:"nav","aria-label":"Primary",children:["archive","runs","admin","tags"].map(m=>h.jsx("button",{className:`nav-link${r===m?" is-active":""}`,onClick:()=>l(m),children:m.charAt(0).toUpperCase()+m.slice(1)},m))}),h.jsx("button",{className:"capture-button",onClick:i,children:"+ Capture"}),u&&h.jsxs("div",{className:"user-menu",children:[h.jsx("span",{className:"username",children:u.username}),h.jsx("button",{onClick:v,disabled:s,className:"logout-btn",children:s?"Logging out…":"Log out"})]})]})}function tp({open:e,archiveId:t,onClose:n,onCaptured:r}){const l=j.useRef(null),[i,u]=j.useState(""),[o,s]=j.useState(null),[f,v]=j.useState(!1);j.useEffect(()=>{const p=l.current;if(!p)return;const w=()=>n();return p.addEventListener("close",w),()=>p.removeEventListener("close",w)},[n]),j.useEffect(()=>{const p=l.current;p&&(e?(u(""),s(null),p.open||p.showModal()):p.open&&p.close())},[e]);async function m(){var p;if(!i.trim()){s("Enter a locator.");return}v(!0),s(null);try{await Qd(t,i.trim()),(p=l.current)==null||p.close(),r()}catch(w){s(w.message)}finally{v(!1)}}return h.jsx("dialog",{ref:l,className:"capture-dialog",children:h.jsxs("div",{className:"capture-dialog-inner",children:[h.jsx("h2",{className:"capture-dialog-title",children:"Capture"}),h.jsx("label",{htmlFor:"capture-locator",className:"capture-label",children:"Locator"}),h.jsx("input",{id:"capture-locator",className:"capture-input",type:"text",placeholder:"tweet:1234567890 or https://...",value:i,onChange:p=>u(p.target.value),onKeyDown:p=>{p.key==="Enter"&&m()},autoComplete:"off"}),o&&h.jsx("div",{className:"capture-error",children:o}),h.jsxs("div",{className:"capture-actions",children:[h.jsx("button",{type:"button",className:"capture-cancel",onClick:()=>{var p;return(p=l.current)==null?void 0:p.close()},children:"Cancel"}),h.jsx("button",{type:"button",className:"capture-submit",onClick:m,disabled:f,children:f?"Capturing…":"Capture"})]})]})})}function sc(e){if(!e)return"0 B";const t=["B","KB","MB","GB"];let n=e,r=0;for(;n>=1024&&rString(r).padStart(2,"0");return`${t.getUTCFullYear()}-${n(t.getUTCMonth()+1)}-${n(t.getUTCDate())} ${n(t.getUTCHours())}:${n(t.getUTCMinutes())}`}const Xo={youtube:'',x:'',instagram:'',facebook:'',tiktok:'',reddit:'',snapchat:'',local:'',web:'',other:'?'};function np(e){return Xo[e]??Xo.other}function rp({entry:e,archiveId:t,isSelected:n,onSelect:r}){const[l,i]=j.useState(!1),o=e.source_kind==="web"&&e.entity_kind==="page"&&e.has_favicon&&t&&!l?h.jsx("img",{src:`/api/archives/${t}/entries/${e.entry_uid}/favicon`,width:"16",height:"16",alt:"",onError:()=>i(!0),style:{objectFit:"contain"}}):h.jsx("span",{dangerouslySetInnerHTML:{__html:np(e.source_kind)}});return h.jsxs("div",{className:n?"is-selected":void 0,tabIndex:0,"data-entry-uid":e.entry_uid,onClick:r,onKeyDown:s=>{s.key==="Enter"&&r()},children:[h.jsx("div",{className:"col-added",children:ac(e.archived_at)}),h.jsxs("div",{className:"col-title",children:[h.jsx("span",{className:"source-icon",children:o}),h.jsx("span",{className:"entry-title",children:Pt(e.title)||Pt(e.entry_uid)})]}),h.jsx("div",{className:"col-type",children:h.jsx("span",{className:"type-pill",children:Pt(e.entity_kind)})}),h.jsx("div",{className:"col-size",children:sc(e.total_artifact_bytes)}),h.jsx("div",{className:"url-cell col-url",children:Pt(e.original_url)})]})}function lp({entries:e,selectedEntryUid:t,onSelectEntry:n,archiveId:r}){return h.jsx("section",{id:"archive-view",className:"view is-active",children:h.jsxs("div",{className:"entry-table",children:[h.jsxs("div",{className:"entry-header-row",children:[h.jsx("div",{className:"col-added",children:"Added"}),h.jsx("div",{className:"col-title",children:"Title"}),h.jsx("div",{className:"col-type",children:"Type"}),h.jsx("div",{className:"col-size",children:"Size"}),h.jsx("div",{className:"col-url",children:"Original URL"})]}),h.jsx("div",{id:"entries-body",children:e.map(l=>h.jsx(rp,{entry:l,archiveId:r,isSelected:l.entry_uid===t,onSelect:()=>n(l)},l.entry_uid))})]})})}function ip({runs:e}){return h.jsx("section",{id:"runs-view",className:"view is-active",children:h.jsxs("table",{className:"entry-table",children:[h.jsx("thead",{children:h.jsxs("tr",{children:[h.jsx("th",{children:"Started"}),h.jsx("th",{children:"Status"}),h.jsx("th",{children:"Requested"}),h.jsx("th",{children:"Completed"}),h.jsx("th",{children:"Failed"})]})}),h.jsx("tbody",{children:e.map((t,n)=>h.jsxs("tr",{children:[h.jsx("td",{children:t.started_at??""}),h.jsx("td",{children:t.status??""}),h.jsx("td",{children:t.requested_count??""}),h.jsx("td",{children:t.completed_count??""}),h.jsx("td",{children:t.failed_count??""})]},n))})]})})}function up({archives:e}){return h.jsxs("section",{id:"admin-view",className:"view admin-view is-active",children:[h.jsx("h1",{children:"Mounted Archives"}),h.jsx("div",{className:"admin-list",children:e.map(t=>h.jsxs("div",{className:"admin-archive",children:[h.jsx("strong",{children:t.label}),h.jsx("div",{className:"muted",children:t.archive_path})]},t.id))})]})}function cc({node:e,tagFilter:t,onTagFilterSet:n,onViewChange:r}){var u;const l=t===e.tag.full_path;function i(){const o=l?null:e.tag.full_path;n(o),r("archive")}return h.jsxs("li",{children:[h.jsx("button",{className:`tag-node-btn${l?" is-active":""}`,title:e.tag.full_path,onClick:i,children:e.tag.name}),((u=e.children)==null?void 0:u.length)>0&&h.jsx("div",{className:"tag-children",children:h.jsx("ul",{className:"tag-tree-list",children:e.children.map(o=>h.jsx(cc,{node:o,tagFilter:t,onTagFilterSet:n,onViewChange:r},o.tag.tag_uid))})})]})}function op({tagNodes:e,tagFilter:t,onTagFilterSet:n,onViewChange:r}){return h.jsx("section",{id:"tags-view",className:"view is-active",children:h.jsx("div",{className:"tag-tree",children:e.length===0?h.jsx("div",{children:"No tags yet."}):h.jsx("ul",{className:"tag-tree-list",children:e.map(l=>h.jsx(cc,{node:l,tagFilter:t,onTagFilterSet:n,onViewChange:r},l.tag.tag_uid))})})})}function sp({archiveId:e,selectedEntry:t,onTagFilterSet:n,tagNodes:r,onTagsRefresh:l}){const[i,u]=j.useState(null),[o,s]=j.useState([]),[f,v]=j.useState(""),[m,p]=j.useState(""),w=j.useRef(0);j.useEffect(()=>{if(!t||!e){u(null),s([]);return}const P=++w.current;u(null),s([]),Promise.all([Bd(e,t.entry_uid),Gl(e,t.entry_uid)]).then(([c,a])=>{P===w.current&&(u(c),s(a))}).catch(()=>{})},[t,e]);async function S(){const P=f.trim();if(!(!P||!t))try{await Hd(e,t.entry_uid,P),v(""),p("");const c=await Gl(e,t.entry_uid);s(c),l()}catch(c){p(c.message)}}async function k(P){try{await Wd(e,t.entry_uid,P);const c=await Gl(e,t.entry_uid);s(c),l()}catch{}}return h.jsxs("aside",{className:"context-rail",children:[h.jsx("div",{className:"rail-title",children:"Context"}),t?i?h.jsxs("div",{className:"rail-body",children:[h.jsx("strong",{className:"rail-entry-title",children:Pt(i.summary.title)||Pt(i.summary.entry_uid)}),h.jsxs("div",{className:"rail-section",children:[i.summary.original_url&&h.jsxs("div",{className:"rail-item",children:[h.jsx("span",{className:"rail-label",children:"Original URL"}),":"," ",h.jsx("a",{href:i.summary.original_url,target:"_blank",rel:"noopener noreferrer",className:"rail-url-link",children:i.summary.original_url})]}),[["Added",ac(i.summary.archived_at)],["Source",i.summary.source_kind],["Type",i.summary.entity_kind],["Visibility",i.summary.visibility],["Structured root",i.structured_root_relpath]].map(([P,c])=>h.jsxs("div",{className:"rail-item",children:[h.jsx("span",{className:"rail-label",children:P}),": ",Pt(c)]},P))]}),i.artifacts.length>0?h.jsxs("div",{className:"rail-section",children:[h.jsxs("div",{className:"rail-section-heading",children:["Artifacts (",i.artifacts.length,")"]}),h.jsx("ul",{className:"artifact-list",children:i.artifacts.map((P,c)=>h.jsx("li",{children:h.jsxs("a",{href:`/api/archives/${e}/entries/${i.summary.entry_uid}/artifacts/${c}`,target:"_blank",rel:"noopener noreferrer",className:"artifact-link",children:[P.artifact_role.replace(/_/g," "),P.byte_size!=null?` (${sc(P.byte_size)})`:""]})},c))})]}):h.jsx("div",{className:"rail-item muted",children:"No artifacts."})]}):h.jsx("div",{className:"rail-body",children:"Loading…"}):h.jsx("div",{className:"rail-body",children:"Select an entry."}),t&&h.jsxs(h.Fragment,{children:[h.jsx("div",{className:"entry-tags",children:o.length===0?h.jsx("span",{className:"muted",children:"No tags."}):o.map(P=>h.jsxs("span",{className:"tag-pill",title:P.full_path,children:[P.name,h.jsx("button",{className:"remove-tag",title:`Remove tag ${P.full_path}`,onClick:()=>k(P.tag_uid),children:"×"})]},P.tag_uid))}),h.jsxs("div",{className:"assign-tag-form",children:[h.jsx("input",{className:"assign-tag-input",type:"text",placeholder:"/science/cs",autoComplete:"off",value:f,onChange:P=>v(P.target.value),onKeyDown:P=>{P.key==="Enter"&&S()}}),h.jsx("button",{className:"assign-tag-btn",onClick:S,children:"Add tag"}),m&&h.jsx("div",{className:"muted",style:{fontSize:"0.85em",color:"var(--accent)"},children:m})]})]})]})}const fc=j.createContext(null);function ap(){const[e,t]=j.useState("loading"),[n,r]=j.useState(null);j.useEffect(()=>{(async()=>{if(await Kd()){t("setup");return}const ze=await Zd();if(!ze){t("login");return}r(ze),t("authenticated")})()},[]),j.useEffect(()=>{const F=()=>{r(null),t("login")};return window.addEventListener("auth:expired",F),()=>window.removeEventListener("auth:expired",F)},[]);const[l,i]=j.useState([]),[u,o]=j.useState(null),[s,f]=j.useState([]),[v,m]=j.useState(null),[p,w]=j.useState(null),[S,k]=j.useState(null),[P,c]=j.useState("archive"),[a,d]=j.useState(""),[g,x]=j.useState(""),[_,N]=j.useState(!1),[z,A]=j.useState([]),[R,oe]=j.useState([]),[xt,et]=j.useState(!1),tt=j.useCallback(async(F,ze,$e)=>{if(F){N(!0);try{let Te;ze||$e?Te=await Vd(F,ze,$e):Te=await Ad(F),f(Te),x(Te.length===0?"No results":`${Te.length} result${Te.length===1?"":"s"}`)}catch{f([]),x("Search failed. Try again.")}finally{N(!1)}}},[]);j.useEffect(()=>{Ud().then(F=>{if(i(F),F.length>0){const ze=F[0].id;o(ze)}})},[]),j.useEffect(()=>{u&&(k(null),w(null),m(null),Promise.all([tt(u,"",null),Yo(u).then(A),Zl(u).then(oe)]))},[u]),j.useEffect(()=>{if(u===null)return;const F=setTimeout(()=>{tt(u,a,S)},300);return()=>clearTimeout(F)},[a,u]),j.useEffect(()=>{u!==null&&(c("archive"),tt(u,a,S))},[S,u]);const xl=j.useCallback(F=>{o(F)},[]),Ut=j.useCallback(F=>{c(F),F==="tags"&&u&&Zl(u).then(oe)},[u]),gn=j.useCallback(F=>{m(F?F.entry_uid:null),w(F)},[]),E=j.useCallback(F=>{k(F)},[]),T=j.useCallback(()=>{k(null)},[]),L=j.useCallback(()=>{u&&Zl(u).then(oe)},[u]),B=j.useCallback(()=>{et(!0)},[]),X=j.useCallback(()=>{et(!1)},[]),At=j.useCallback(()=>{u&&Promise.all([tt(u,a,S),Yo(u).then(A)])},[u,a,S,tt]);return e==="loading"?h.jsx("div",{className:"auth-loading",children:"Loading\\u2026"}):e==="setup"?h.jsx(bd,{onComplete:()=>t("login")}):e==="login"?h.jsx(qd,{onLogin:F=>{r(F),t("authenticated")}}):h.jsx(fc.Provider,{value:{currentUser:n,setCurrentUser:r},children:h.jsxs(h.Fragment,{children:[h.jsx(ep,{archives:l,archiveId:u,onArchiveChange:xl,view:P,onViewChange:Ut,onCaptureClick:B}),h.jsxs("main",{className:"app-shell",children:[h.jsxs("div",{className:"workspace",children:[P==="archive"&&h.jsxs("div",{className:"search-row",children:[h.jsx("input",{className:"search-input",type:"search","aria-label":"Search archive","aria-busy":_,value:a,onChange:F=>d(F.target.value)}),h.jsxs("div",{className:"result-count",children:[g,S&&h.jsxs("button",{className:"tag-filter-badge",onClick:T,children:["× ",S]})]})]}),P==="archive"&&h.jsx(lp,{entries:s,selectedEntryUid:v,onSelectEntry:gn,archiveId:u,tagFilter:S,onClearTagFilter:T,searchQuery:a,onSearchChange:d,resultCount:g,searchBusy:_}),P==="runs"&&h.jsx(ip,{runs:z}),P==="admin"&&h.jsx(up,{archives:l}),P==="tags"&&h.jsx(op,{tagNodes:R,tagFilter:S,onTagFilterSet:E,onViewChange:Ut})]}),h.jsx(sp,{archiveId:u,selectedEntry:p,onTagFilterSet:E,tagNodes:R,onTagsRefresh:L})]}),h.jsx(tp,{open:xt,archiveId:u,onClose:X,onCaptured:At})]})})}oc(document.getElementById("root")).render(h.jsx(j.StrictMode,{children:h.jsx(ap,{})})); diff --git a/crates/archivr-server/static/index.html b/crates/archivr-server/static/index.html index c3da2f9..b116753 100644 --- a/crates/archivr-server/static/index.html +++ b/crates/archivr-server/static/index.html @@ -4,7 +4,7 @@ Archivr - + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f663efe..f6f119b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,8 @@ -import { useState, useEffect, useCallback, useRef } from 'react' -import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags } from './api' +import { useState, useEffect, useCallback, useRef, createContext } from 'react' +import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe } from './api' +import LoginPage from './components/LoginPage.jsx' +import SetupPage from './components/SetupPage.jsx' + import Topbar from './components/Topbar' import CaptureDialog from './components/CaptureDialog' import EntriesView from './components/EntriesView' @@ -8,7 +11,29 @@ import AdminView from './components/AdminView' import TagsView from './components/TagsView' import ContextRail from './components/ContextRail' +export const AuthContext = createContext(null); + export default function App() { + const [authState, setAuthState] = useState('loading'); + const [currentUser, setCurrentUser] = useState(null); + + useEffect(() => { + (async () => { + const needsSetup = await checkSetup(); + if (needsSetup) { setAuthState('setup'); return; } + const user = await fetchMe(); + if (!user) { setAuthState('login'); return; } + setCurrentUser(user); + setAuthState('authenticated'); + })(); + }, []); + + useEffect(() => { + const handler = () => { setCurrentUser(null); setAuthState('login'); }; + window.addEventListener('auth:expired', handler); + return () => window.removeEventListener('auth:expired', handler); + }, []); + const [archives, setArchives] = useState([]) const [archiveId, setArchiveId] = useState(null) const [entries, setEntries] = useState([]) @@ -127,77 +152,83 @@ export default function App() { ]) }, [archiveId, searchQuery, tagFilter, loadEntries]) + if (authState === 'loading') return
Loading\u2026
; + if (authState === 'setup') return setAuthState('login')} />; + if (authState === 'login') return { setCurrentUser(user); setAuthState('authenticated'); }} />; + return ( - <> - -
-
- {view === 'archive' && ( -
- setSearchQuery(e.target.value)} - /> -
- {resultCount} - {tagFilter && ( - - )} -
-
- )} - {view === 'archive' && ( - - )} - {view === 'runs' && } - {view === 'admin' && } - {view === 'tags' && ( - - )} -
- + <> + -
- - +
+
+ {view === 'archive' && ( +
+ setSearchQuery(e.target.value)} + /> +
+ {resultCount} + {tagFilter && ( + + )} +
+
+ )} + {view === 'archive' && ( + + )} + {view === 'runs' && } + {view === 'admin' && } + {view === 'tags' && ( + + )} +
+ +
+ + + ) } diff --git a/frontend/src/api.js b/frontend/src/api.js index 0ce034b..1af25c8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -68,3 +68,54 @@ export async function submitCapture(archiveId, locator) { throw new Error(msg || `HTTP ${res.status}`); } } + +// ── Auth helpers ───────────────────────────────────────────────────────────── + +export async function checkSetup() { + const r = await fetch('/api/auth/setup'); + const data = await r.json(); + return data.setup_required === true; +} + +export async function doSetup(username, password) { + const r = await fetch('/api/auth/setup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (!r.ok) throw new Error((await r.json()).error || 'Setup failed'); + return r.json(); +} + +export async function login(username, password) { + const r = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (!r.ok) throw new Error((await r.json()).error || 'Login failed'); + return r.json(); +} + +export async function logout() { + await fetch('/api/auth/logout', { method: 'POST' }); +} + +export async function fetchMe() { + const r = await fetch('/api/auth/me'); + if (r.status === 401) return null; + return r.json(); +} + +// ── 401 interceptor ─────────────────────────────────────────────────────────── +const _origFetch = window.fetch; +window.fetch = async (...args) => { + const r = await _origFetch(...args); + if (r.status === 401) { + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? ''; + if (!url.includes('/api/auth/')) { + window.dispatchEvent(new CustomEvent('auth:expired')); + } + } + return r; +}; diff --git a/frontend/src/components/LoginPage.jsx b/frontend/src/components/LoginPage.jsx new file mode 100644 index 0000000..105ed5a --- /dev/null +++ b/frontend/src/components/LoginPage.jsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import { login } from '../api.js'; + +export default function LoginPage({ onLogin }) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const user = await login(username, password); + onLogin(user); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + return ( +
+

Archivr

+
+ + + {error &&

{error}

} + +
+
+ ); +} diff --git a/frontend/src/components/SetupPage.jsx b/frontend/src/components/SetupPage.jsx new file mode 100644 index 0000000..aca4cb3 --- /dev/null +++ b/frontend/src/components/SetupPage.jsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { doSetup } from '../api.js'; + +export default function SetupPage({ onComplete }) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + if (password !== confirm) { + setError('Passwords do not match'); + return; + } + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + setError(null); + setLoading(true); + try { + await doSetup(username, password); + onComplete(); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + return ( +
+

Welcome to Archivr

+

Create your owner account to get started.

+
+ + + + {error &&

{error}

} + +
+
+ ); +} diff --git a/frontend/src/components/Topbar.jsx b/frontend/src/components/Topbar.jsx index b81d72a..fb55ea7 100644 --- a/frontend/src/components/Topbar.jsx +++ b/frontend/src/components/Topbar.jsx @@ -1,4 +1,18 @@ +import { useContext, useState } from 'react'; +import { AuthContext } from '../App.jsx'; +import { logout as apiLogout } from '../api.js'; + export default function Topbar({ archives, archiveId, onArchiveChange, view, onViewChange, onCaptureClick }) { + const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}; + const [loggingOut, setLoggingOut] = useState(false); + + async function handleLogout() { + setLoggingOut(true); + await apiLogout(); + setCurrentUser(null); + window.location.reload(); + } + return (
Archivr
@@ -15,6 +29,14 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV ))} + {currentUser && ( +
+ {currentUser.username} + +
+ )}
- ) + ); }