mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat(auth): Track 4 auth foundation — sessions, roles, login, setup wizard
Implements the full auth foundation layer across 13 atomic task commits: Core DB (archivr-core/database.rs): - initialize_auth_schema: roles, user_roles, sessions, api_tokens, instance_settings - open_auth_db: dedicated server-level auth SQLite (separate from archive DBs) - create_owner, compute_role_bits: cumulative role assignment (guest=1 user=2 admin=4 owner=8) - create_session, get_session, delete_session, touch_session, delete_expired_sessions - create_api_token, get_user_for_token, list_user_tokens, delete_api_token Server (archivr-server): - auth.rs: AuthUser Axum extractor (cookie→session or Bearer→token), Argon2id password hashing, random token generation, role bit constants - AppState gains auth_db_path; ServerRegistry gains optional auth_db_path field - main.rs: auth DB path computed from config dir, session cleanup background task (24h) - setup_guard middleware: 503 SERVICE_UNAVAILABLE for all non-/api/auth/ routes until first owner account is created via POST /api/auth/setup - Auth endpoints: GET|POST /api/auth/setup, POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me, GET|POST|DELETE /api/auth/tokens - WRITE routes guarded with ROLE_USER: captures, tag create/assign/remove Frontend: - LoginPage.jsx, SetupPage.jsx (new) - App.jsx: AuthContext, auth state machine (loading→setup→login→authenticated), setup check on mount, auth:expired event listener - api.js: checkSetup, doSetup, login, logout, fetchMe, 401 interceptor - Topbar.jsx: user menu with username and logout button Docs: - NEXT.md: Track 4 marked done, Tracks 5-8 stubs added, old tracks renumbered 159 tests green. Frontend builds cleanly.
This commit is contained in:
commit
f76f5438f2
16 changed files with 1693 additions and 267 deletions
189
Cargo.lock
generated
189
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
69
NEXT.md
69
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.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<Connection> {
|
||||
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<bool> {
|
||||
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<i64> {
|
||||
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<Option<AuthUserRecord>> {
|
||||
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<u32> {
|
||||
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<String> {
|
||||
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<Option<SessionRecord>> {
|
||||
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<usize> {
|
||||
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<String> {
|
||||
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<Option<i64>> {
|
||||
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<bool> {
|
||||
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<Vec<ApiTokenRecord>> {
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub fn ensure_default_user(conn: &Connection) -> Result<i64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
127
crates/archivr-server/src/auth.rs
Normal file
127
crates/archivr-server/src/auth.rs
Normal file
|
|
@ -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<AppState> for AuthUser {
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, std::convert::Infallible> {
|
||||
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<String> {
|
||||
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<bool> {
|
||||
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())
|
||||
}
|
||||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -16,10 +16,13 @@ pub struct MountedArchive {
|
|||
pub struct ServerRegistry {
|
||||
#[serde(default)]
|
||||
pub archives: Vec<MountedArchive>,
|
||||
/// 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<String>,
|
||||
/// 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<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
pub fn load_registry(path: &Path) -> Result<ServerRegistry> {
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
40
crates/archivr-server/static/assets/index-Davzo15o.js
Normal file
40
crates/archivr-server/static/assets/index-Davzo15o.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-CcezQ0kg.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-Davzo15o.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJpQthbx.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -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 <div className="auth-loading">Loading\u2026</div>;
|
||||
if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
|
||||
if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
archives={archives}
|
||||
archiveId={archiveId}
|
||||
onArchiveChange={handleArchiveChange}
|
||||
view={view}
|
||||
onViewChange={handleViewChange}
|
||||
onCaptureClick={handleCaptureClick}
|
||||
/>
|
||||
<main className="app-shell">
|
||||
<div className="workspace">
|
||||
{view === 'archive' && (
|
||||
<div className="search-row">
|
||||
<input
|
||||
className="search-input"
|
||||
type="search"
|
||||
aria-label="Search archive"
|
||||
aria-busy={searchBusy}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className="result-count">
|
||||
{resultCount}
|
||||
{tagFilter && (
|
||||
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
|
||||
× {tagFilter}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{view === 'archive' && (
|
||||
<EntriesView
|
||||
entries={entries}
|
||||
selectedEntryUid={selectedEntryUid}
|
||||
onSelectEntry={selectEntry}
|
||||
archiveId={archiveId}
|
||||
tagFilter={tagFilter}
|
||||
onClearTagFilter={handleClearTagFilter}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
resultCount={resultCount}
|
||||
searchBusy={searchBusy}
|
||||
/>
|
||||
)}
|
||||
{view === 'runs' && <RunsView runs={runs} />}
|
||||
{view === 'admin' && <AdminView archives={archives} />}
|
||||
{view === 'tags' && (
|
||||
<TagsView
|
||||
tagNodes={tagNodes}
|
||||
tagFilter={tagFilter}
|
||||
onTagFilterSet={handleTagFilterSet}
|
||||
onViewChange={handleViewChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ContextRail
|
||||
<AuthContext.Provider value={{ currentUser, setCurrentUser }}>
|
||||
<>
|
||||
<Topbar
|
||||
archives={archives}
|
||||
archiveId={archiveId}
|
||||
selectedEntry={selectedEntry}
|
||||
onTagFilterSet={handleTagFilterSet}
|
||||
tagNodes={tagNodes}
|
||||
onTagsRefresh={handleTagsRefresh}
|
||||
onArchiveChange={handleArchiveChange}
|
||||
view={view}
|
||||
onViewChange={handleViewChange}
|
||||
onCaptureClick={handleCaptureClick}
|
||||
/>
|
||||
</main>
|
||||
<CaptureDialog
|
||||
open={captureDialogOpen}
|
||||
archiveId={archiveId}
|
||||
onClose={handleCaptureClose}
|
||||
onCaptured={handleCaptured}
|
||||
/>
|
||||
</>
|
||||
<main className="app-shell">
|
||||
<div className="workspace">
|
||||
{view === 'archive' && (
|
||||
<div className="search-row">
|
||||
<input
|
||||
className="search-input"
|
||||
type="search"
|
||||
aria-label="Search archive"
|
||||
aria-busy={searchBusy}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className="result-count">
|
||||
{resultCount}
|
||||
{tagFilter && (
|
||||
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
|
||||
× {tagFilter}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{view === 'archive' && (
|
||||
<EntriesView
|
||||
entries={entries}
|
||||
selectedEntryUid={selectedEntryUid}
|
||||
onSelectEntry={selectEntry}
|
||||
archiveId={archiveId}
|
||||
tagFilter={tagFilter}
|
||||
onClearTagFilter={handleClearTagFilter}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
resultCount={resultCount}
|
||||
searchBusy={searchBusy}
|
||||
/>
|
||||
)}
|
||||
{view === 'runs' && <RunsView runs={runs} />}
|
||||
{view === 'admin' && <AdminView archives={archives} />}
|
||||
{view === 'tags' && (
|
||||
<TagsView
|
||||
tagNodes={tagNodes}
|
||||
tagFilter={tagFilter}
|
||||
onTagFilterSet={handleTagFilterSet}
|
||||
onViewChange={handleViewChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ContextRail
|
||||
archiveId={archiveId}
|
||||
selectedEntry={selectedEntry}
|
||||
onTagFilterSet={handleTagFilterSet}
|
||||
tagNodes={tagNodes}
|
||||
onTagsRefresh={handleTagsRefresh}
|
||||
/>
|
||||
</main>
|
||||
<CaptureDialog
|
||||
open={captureDialogOpen}
|
||||
archiveId={archiveId}
|
||||
onClose={handleCaptureClose}
|
||||
onCaptured={handleCaptured}
|
||||
/>
|
||||
</>
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
54
frontend/src/components/LoginPage.jsx
Normal file
54
frontend/src/components/LoginPage.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="login-page">
|
||||
<h1>Archivr</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Logging in\u2026' : 'Log in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/SetupPage.jsx
Normal file
73
frontend/src/components/SetupPage.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="setup-page">
|
||||
<h1>Welcome to Archivr</h1>
|
||||
<p>Create your owner account to get started.</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Creating account\u2026' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<header className="topbar">
|
||||
<div className="brand">Archivr</div>
|
||||
|
|
@ -15,6 +29,14 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
|
|||
))}
|
||||
</nav>
|
||||
<button className="capture-button" onClick={onCaptureClick}>+ Capture</button>
|
||||
{currentUser && (
|
||||
<div className="user-menu">
|
||||
<span className="username">{currentUser.username}</span>
|
||||
<button onClick={handleLogout} disabled={loggingOut} className="logout-btn">
|
||||
{loggingOut ? 'Logging out\u2026' : 'Log out'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue