1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

Compare commits

...

78 commits

Author SHA1 Message Date
46ad816c4c
fix(flake): add openssl to buildInputs for Rust packages (#10)
Garnix CI was failing because openssl-sys couldn't find the OpenSSL
library. Added pkgs.openssl to buildInputs for both archivr_cli_unwrapped
and archivr_server_unwrapped to provide the necessary system dependency
and allow pkg-config to locate openssl.pc.
2026-06-27 11:54:31 +02:00
91e73d85e7
feat(collections): Track 8 — collection rename, delete, add/remove entries, per-entry visibility UI 2026-06-26 17:33:58 +02:00
ae92448fa3
build(frontend): bundle assets for collections UI 2026-06-26 17:33:50 +02:00
80dce80033
feat(collections): frontend — CollectionsView rename, delete, add/remove entries, per-entry visibility 2026-06-26 17:33:46 +02:00
c4ce2129bb
feat(collections): frontend — api.js updateCollection and deleteCollection helpers 2026-06-26 17:33:42 +02:00
383cec1f81
feat(collections): routes — PATCH|DELETE /api/archives/:id/collections/:uid, visibility_bits in GET response 2026-06-26 17:33:38 +02:00
533e9d5d2e
feat(collections): db — update_collection, delete_collection helpers 2026-06-26 17:33:35 +02:00
96eaf50609
docs(settings): mark Track 7 done in NEXT.md 2026-06-26 17:20:06 +02:00
d558ce2a3f
feat(settings): Track 7 — account profile, password change, API tokens UI, instance settings
- db: display_name column migration, InstanceSettings struct, 6 new pub helpers
  (get/update_instance_settings, update_user_display_name, update_user_password,
  get_user_password_hash, get_user_display_name)
- routes: PATCH /api/auth/me (display name + password change, current-password verify);
  GET|PATCH /api/admin/instance-settings (ROLE_ADMIN); auth/me now returns display_name
- frontend/api.js: updateProfile, changePassword, listTokens, createToken,
  deleteToken, getInstanceSettings, updateInstanceSettings helpers
- frontend: SettingsView (Profile / API Tokens / Instance tabs), settings nav in
  Topbar, App routing for view === 'settings'
- 7 new routes tests; 176 tests green
2026-06-26 17:19:43 +02:00
ea0a8f80d1
test(settings): routes tests for profile update, password change, instance settings 2026-06-26 17:19:26 +02:00
44f18f4dcc
build(frontend): bundle assets for settings UI 2026-06-26 17:18:30 +02:00
78e763c377
feat(settings): frontend — SettingsView component, settings nav, App routing 2026-06-26 17:17:45 +02:00
677e5c439b
feat(settings): frontend — api.js helpers for profile, tokens, and instance settings 2026-06-26 17:17:40 +02:00
dabe014e7e
feat(settings): routes — PATCH /api/auth/me, GET|PATCH /api/admin/instance-settings 2026-06-26 17:17:37 +02:00
69af2d2060
feat(settings): db — display_name column, InstanceSettings struct, profile and instance-settings helpers 2026-06-26 17:17:34 +02:00
4d0daf2dc0
chore: update Cargo.lock after collections feature 2026-06-26 17:07:48 +02:00
915878ff10
feat(collections): Track 6 permissions & visibility — collection model, role-bitmask visibility, collection UI 2026-06-26 17:06:18 +02:00
1b91e7ef5e
docs(collections): mark Track 6 done in NEXT.md 2026-06-26 17:05:57 +02:00
cb390dfaab
build(frontend): bundle assets for collections UI 2026-06-26 17:05:22 +02:00
763cb8e17f
feat(collections): frontend — CollectionsView, nav, visibility in ContextRail 2026-06-26 17:04:49 +02:00
dd56fc8c70
test(collections): update tag-search test to send auth (private entries require auth) 2026-06-26 17:01:43 +02:00
24c5321f6e
feat(collections): collection CRUD + entry-visibility routes 2026-06-26 17:00:19 +02:00
02cab149fa
feat(collections): visibility-filtered entry queries + collection helpers 2026-06-26 16:51:48 +02:00
ab054f6256
feat(collections): schema — collections + collection_entries tables 2026-06-26 16:48:22 +02:00
2c3d3eb86e
feat(users): Track 5 user management — admin panel, custom roles, banning
Adds admin-only user management API and UI:

- DB: UserSummary, RoleRecord structs; 10 new auth-DB helpers (list_users, create_user,
  get_user_by_uid, set_user_status, assign_role, remove_role, list_roles, create_custom_role,
  invalidate_user_sessions, get_user_id_by_uid) with 4 tests
- Server: 5 admin routes (/api/admin/users, /api/admin/users/:uid/status,
  /api/admin/users/:uid/roles, /api/admin/roles) with 7 handlers + 2 tests
- Frontend: 7 admin API helpers in api.js; AdminView two-tab panel (Users/Roles)
  with ban/unban, create user, create custom role forms
- Role bitmask: guest=1, user=2, admin=4, owner=8; custom roles bit>=4
- Ban invalidates all active sessions; only-owner guard prevents last owner removal

169 tests green, frontend builds clean.
2026-06-26 16:24:15 +02:00
b3b0c8ec8d
docs(users): mark Track 5 done in NEXT.md 2026-06-26 16:24:04 +02:00
dcbced3590
Merge branch 'task/user-mgmt-backend' 2026-06-26 16:22:56 +02:00
2335776347
feat(users): admin routes — list/create users, roles, set status 2026-06-26 16:22:08 +02:00
6357cc518e
Merge branch 'task/user-mgmt-frontend' 2026-06-26 16:18:40 +02:00
ffecb72230
Merge branch 'task/user-mgmt-db' 2026-06-26 16:16:50 +02:00
48da356eee
feat(users): user management DB helpers (list_users, create_user, assign_role, custom roles) 2026-06-26 15:03:40 +02:00
2042752177
feat(users): admin user management UI + api helpers 2026-06-26 15:02:50 +02:00
ff3d20ae04
feat(capture): Track 3 async capture jobs — spawn_blocking job queue
POST /api/archives/:id/captures now returns 202 Accepted immediately with
{ job_uid, status: "pending" }. The capture runs in a tokio::task::spawn_blocking
thread and updates the job status to running → completed/failed in the archive DB.

Core DB (database.rs):
- capture_jobs table: id, job_uid, archive_id, run_uid, status, error_text, created_at, updated_at
- create_capture_job, update_capture_job_status, get_capture_job, fail_stalled_capture_jobs

Core archive (archive.rs):
- CaptureJobSummary type + get_capture_job query

Server (routes.rs):
- capture_handler: creates job, spawns background capture, returns 202 immediately
- GET /api/archives/:id/capture_jobs/:job_uid: poll job status
- main.rs: on startup, marks any 'running' jobs from previous session as 'failed'

Frontend:
- api.js: submitCapture returns job object; pollCaptureJob added
- CaptureDialog.jsx: polls every 500ms after submit, shows 'Running…' state,
  handles completed (close + refresh) and failed (show error), cleans up interval

163 tests green. Frontend builds cleanly.
2026-06-26 12:59:00 +02:00
9f7b3c912a
build(frontend): update bundled assets for async capture polling 2026-06-26 12:59:00 +02:00
116b30e0e9
Merge branch 'task/async-capture-frontend' 2026-06-26 12:58:31 +02:00
5e31a4188c
Merge branch 'task/async-capture-backend' 2026-06-26 12:58:31 +02:00
da2b58ef96
feat(capture): mark stalled running jobs failed on server startup 2026-06-26 12:57:59 +02:00
cfd51778b1
feat(capture): async capture_handler (spawn_blocking) + job poll endpoint 2026-06-26 12:57:44 +02:00
27b37c3b6d
feat(capture): CaptureJobSummary type and archive::get_capture_job 2026-06-26 12:56:38 +02:00
d86a4d92a8
feat(capture): capture_jobs schema and DB helpers 2026-06-26 12:56:10 +02:00
9ac28459ed
feat(capture): async polling in CaptureDialog + pollCaptureJob api helper 2026-06-26 12:54:31 +02:00
f76f5438f2
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.
2026-06-26 12:04:08 +02:00
7cebf06124
chore: update Cargo.lock after frontend build 2026-06-26 12:03:30 +02:00
37fd057c1f
fix(auth): use rfc3339 for session expires_at; update tag tests for auth guards 2026-06-26 12:03:13 +02:00
7671049f55
Merge branch 'task/auth-docs' 2026-06-26 11:59:31 +02:00
8edd17d694
Merge branch 'task/auth-topbar' 2026-06-26 11:59:31 +02:00
a983b6090a
Merge branch 'task/auth-frontend' 2026-06-26 11:59:31 +02:00
59a37a2ce3
Merge branch 'task/auth-session-cleanup' 2026-06-26 11:59:24 +02:00
059196252b
Merge branch 'task/auth-route-protection' 2026-06-26 11:59:24 +02:00
64706cb976
Merge branch 'task/auth-tokens' 2026-06-26 11:59:24 +02:00
6fd5e8881e
Merge branch 'task/auth-endpoints' 2026-06-26 11:59:24 +02:00
c806b0bbaf
Merge branch 'task/auth-extractor' 2026-06-26 11:59:24 +02:00
0a3cde9005
Merge branch 'task/auth-appstate' 2026-06-26 11:59:24 +02:00
98bb5f03b0
Merge branch 'task/auth-session-helpers' 2026-06-26 11:59:18 +02:00
2e2ce4b6ac
Merge branch 'task/auth-user-helpers' 2026-06-26 11:59:18 +02:00
8ed79192eb
Merge branch 'task/auth-schema' 2026-06-26 11:59:18 +02:00
1bbc0a108c
Merge branch 'task/auth-deps' 2026-06-26 11:59:18 +02:00
f9d2ac80e7
feat(auth): session cleanup background task (24h interval) 2026-06-26 11:57:14 +02:00
cb376aa986
feat(auth): apply ROLE_USER guard to WRITE routes (captures, tags) 2026-06-26 11:56:38 +02:00
36c7d575e2
feat(auth): API token endpoints (create, list, delete) 2026-06-26 11:55:05 +02:00
affb8aadc0
feat(auth): login, logout, /me, setup endpoints + setup_guard middleware 2026-06-26 11:53:58 +02:00
db28d038d9
feat(auth): AuthUser extractor, password helpers, token generation 2026-06-26 11:50:29 +02:00
a4239ba5fc
feat(auth): add auth_db_path to AppState, registry, and main.rs 2026-06-26 11:46:23 +02:00
86e8049f1b
feat(auth): session and token DB helpers 2026-06-26 11:32:32 +02:00
f482707b75
feat(auth): user and role DB helpers (create_owner, compute_role_bits) 2026-06-26 11:31:20 +02:00
696a5e1ac7
feat(auth): add initialize_auth_schema, open_auth_db, and auth record types 2026-06-26 11:30:02 +02:00
7fcc207de7
feat(auth): user menu and logout button in Topbar 2026-06-26 11:29:13 +02:00
0a5c899c5e
feat(auth): frontend login/setup pages + auth state in App.jsx 2026-06-26 11:28:58 +02:00
57fc48d73c
feat(auth): add argon2, rand, axum-extra dependencies 2026-06-26 11:28:51 +02:00
decaef389b
docs: mark Track 4 auth foundation done, add Tracks 5-8 stubs, renumber roadmap 2026-06-26 11:28:21 +02:00
7584e309de
chore: remove docs/superpowers from gitignore (not for VCS) 2026-06-26 11:19:40 +02:00
d81b8fd793
docs: add Track 4 auth foundation implementation plan 2026-06-25 17:38:30 +02:00
151835258b
docs: fix 8 reviewer findings in auth foundation spec
- Role hierarchy: cumulative assignment + ROLE_GUEST floor; owner gets role_bits=15
- ALTER TABLE: move default_entry_visibility into CREATE TABLE DDL (idempotent)
- Session cookie: add Secure flag conditionally on HTTPS (X-Forwarded-Proto)
- API token role_bits: live query user_roles per request (vs snapshot)
- POST /api/auth/setup after complete: returns 409 already_configured
- users.role placeholder: use 'admin' in ensure_owner_exists INSERT
- Disabled users: extractor JOINs users and checks status='active'
- last_seen_at throttle: conditional update if >60s elapsed
2026-06-25 17:27:33 +02:00
10aeebd878
docs: add Track 4 auth foundation design spec + whitelist docs/superpowers in gitignore 2026-06-25 17:20:17 +02:00
5fba6987a3
fix(capture): remove debug println! from hash_exists 2026-06-25 16:19:33 +02:00
351e74df09
feat(fonts): wire font extraction into capture pipeline + blob serving route
- capture.rs: add archive_id: Option<&str> to perform_capture; when Some,
  call font_extractor::extract_and_rewrite before hashing HTML, register
  each font as a deduplicated blob + 'font' artifact
- main.rs: pass None as archive_id (CLI keeps fonts embedded)
- routes.rs: add GET /api/archives/:id/blobs/:sha256 (serve_blob handler),
  pass Some(&archive_id) to perform_capture in capture_handler,
  add ApiError::internal constructor
2026-06-25 16:07:50 +02:00
22b0a55730
feat(fonts): hash_bytes, get_blob_by_sha256, font_extractor module
- fix(hash): hash raw bytes instead of lossy UTF-8; add hash_bytes
- feat(database): add get_blob_by_sha256 lookup
- feat(font_extractor): extract embedded font data-URIs from archived HTML
2026-06-25 16:02:57 +02:00
f6d636fce2
feat(singlefile): add UA, disable-web-security, and user-data-dir
- --user-agent: realistic Chrome UA so servers don't block headless string
- --browser-args=[--disable-web-security, --user-data-dir]: lets single-file
  inline fonts from any cross-origin CDN (e.g. fonts.gstatic.com) regardless
  of ACAO headers; user-data-dir required for --disable-web-security to take
  effect in newer Chromium builds (otherwise silently ignored)
2026-06-25 11:39:24 +02:00
38 changed files with 7859 additions and 313 deletions

2
.gitignore vendored
View file

@ -5,7 +5,6 @@
!docs/
!docs/LICENSE
!docs/README*
!crates
!crates/**
@ -18,6 +17,7 @@
!Cargo.toml
!Cargo.lock
!ARCHIVR-MENTAL-MODEL.md
!NEXT.md
!frontend/
!frontend/**

190
Cargo.lock generated
View file

@ -125,7 +125,13 @@ version = "0.1.0"
dependencies = [
"anyhow",
"archivr-core",
"argon2",
"axum",
"axum-extra",
"base64",
"chrono",
"rand",
"rusqlite",
"serde",
"serde_json",
"tempfile",
@ -135,6 +141,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 +231,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 +378,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 +434,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 +448,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@ -979,6 +1054,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 +1088,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 +1158,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 +1196,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 +1235,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 +1567,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 +1656,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"

View file

@ -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"] }

225
NEXT.md Normal file
View file

@ -0,0 +1,225 @@
# Archivr Next Work Plan
> **For future agentic workers:** Read `ARCHIVR-MENTAL-MODEL.md` first, then this file.
> All six tracks from the previous handoff are complete. This document is the current roadmap.
## Current State
All five product tracks from the prior session are done:
| Feature | Status |
|---|---|
| Entry detail + artifact serving | Done |
| Server-side search with prefix filters | Done |
| Hierarchical tags (CRUD, tree, entry assignment, filter) | Done |
| Browser capture button and POST endpoint | Done |
| Local-only auth boundary, bind address config, docs | Done |
The frontend is a React + Vite app (`frontend/`) that builds into `crates/archivr-server/static/`.
The remaining gaps come from `docs/README.md` milestones that were never in the prior tracks.
---
## Remaining Work (in order)
### ~~1. Generic URL capture — plain file download~~ ✅ Done
**Implemented:** `crates/archivr-core/src/downloader/http.rs` (`download` fn, extension helpers,
HTML rejection), `Source::Url` variant in `capture.rs`, `determine_source` routes all unmatched
`http://`/`https://` URLs to `Source::Url`, `perform_capture` arm calls the downloader and flows
through the existing temp → hash → raw store pipeline. `reqwest 0.12` (blocking) added to workspace.
`docs/README.md` "URLs" milestone checked off. 112 tests green.
---
### ~~2. Web page archiving — single-file HTML snapshots~~ ✅ Done
**Implemented:** `crates/archivr-core/src/downloader/singlefile.rs` (new module; shells out to
`single-file-cli` via `ARCHIVR_SINGLE_FILE` env var; launches headless Chromium via
`ARCHIVR_CHROME`; flags: `--browser-wait-until=networkidle2`, `--remove-unused-styles=false`,
`--block-scripts=false`, `--remove-alternative-medias=false` for full CSS/font fidelity).
`Source::WebPage` variant added to `capture.rs`; HEAD-first probe (`probe_url_kind` in `http.rs`)
routes `text/html` responses to `Source::WebPage` and everything else to `Source::Url`.
Font deduplication implemented as a follow-on: `font_extractor.rs` post-processes the saved HTML,
extracts embedded `@font-face` base64 blobs, stores each as a deduplicated artifact in the raw
store, and rewrites the HTML to reference them via `/api/archives/:id/blobs/:sha256`.
`hash_bytes` added to `hash.rs`; `get_blob_by_sha256` added to `database.rs`;
`GET /api/archives/:id/blobs/:sha256` route added to `routes.rs`.
`flake.nix` updated: `pkgs.single-file-cli` and (Linux-only) `pkgs.chromium` added to
`buildInputs`; `ARCHIVR_SINGLE_FILE` and `ARCHIVR_CHROME` env vars wired into both wrappers.
`docs/README.md` "Archive web pages" milestone checked off. 143 tests green.
---
### 3. Async capture jobs
**What:** Capture currently runs synchronously on the HTTP request thread. A YouTube video,
a large tweet thread, or a slow monolith page will stall the browser request. This track
adds a job queue so `POST /api/archives/:id/captures` returns immediately and the UI polls
for completion.
**Where to edit:**
| File | Change |
|---|---|
| `crates/archivr-core/src/database.rs` | Add `capture_jobs` table to the schema: `(id, job_uid, run_uid, archive_id, status TEXT CHECK(status IN ('pending','running','completed','failed')), error_text, created_at, updated_at)`. Add `create_capture_job`, `update_capture_job_status`, `get_capture_job` helpers. |
| `crates/archivr-core/src/archive.rs` | Add `CaptureJob` summary type and `get_capture_job` query. |
| `crates/archivr-server/src/routes.rs` | `POST /api/archives/:id/captures` → insert job row, spawn `tokio::task::spawn_blocking` for the capture call, return `{ job_uid, status: "pending" }` immediately. Add `GET /api/archives/:id/capture_jobs/:job_uid` route. |
| `frontend/src/api.js` | Add `pollCaptureJob(archiveId, jobUid)` |
| `frontend/src/components/CaptureDialog.jsx` | After submit: enter polling loop (500 ms interval), show "Running…" state, handle `completed` (close + refresh) and `failed` (show error). |
**Acceptance criteria:**
- `POST /captures` returns within 50 ms regardless of capture duration.
- UI shows "Running…" and updates when the job finishes.
- Failed captures surface the error message in the dialog.
- A server restart while a job is `running` leaves it stalled as `running`; on next startup,
jobs stuck in `running` should be marked `failed` with a note ("interrupted by server restart").
- Existing `cargo test` passes.
**Key risk:** `spawn_blocking` runs on a Tokio thread pool thread. A very long capture
(15-minute YouTube video) will occupy one thread for the duration. For now that is acceptable.
A proper queue (channel + worker task) can replace it later without changing the DB schema or API.
---
### ~~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~~ ✅ Done
**Implemented:** `crates/archivr-core/src/database.rs`: `UserSummary` and `RoleRecord` structs;
10 new pub functions (`invalidate_user_sessions`, `get_user_id_by_uid`, `list_users`,
`get_user_by_uid`, `create_user`, `set_user_status`, `assign_role`, `remove_role`, `list_roles`,
`create_custom_role`). `crates/archivr-server/src/routes.rs`: 5 admin routes
(`GET|POST /api/admin/users`, `PATCH /api/admin/users/:uid/status`,
`POST|DELETE /api/admin/users/:uid/roles`, `GET|POST /api/admin/roles`); 7 handler functions;
request body structs. `frontend/src/api.js`: 7 admin helpers (`listAdminUsers`,
`createAdminUser`, `setUserStatus`, `assignRole`, `removeRole`, `listRoles`, `createRole`).
`frontend/src/components/AdminView.jsx`: two-tab admin panel (Users / Roles) with ban/unban,
create user form, create custom role form. Role bitmask: guest=1, user=2, admin=4, owner=8;
custom roles get bit_position≥4. Ban invalidates all sessions. Only-owner guard on role removal.
169 tests green.
---
### ~~6. Permissions & visibility — collection model~~ ✅ Done
**Implemented:** `database.rs`: `collections` + `collection_entries` tables; seed default collection ('All Entries');
migration inserts existing entries into default collection with `visibility_bits` derived from legacy `visibility` string
(`'public'`→3, `'unlisted'`→2, `'private'`→0). 8 new pub functions (`ensure_default_collection`, `create_collection`,
`list_collections`, `get_collection_by_uid`, `add_entry_to_collection`, `update_collection_entry_visibility`,
`remove_entry_from_collection`, `get_entry_collection_memberships`). `create_archived_entry` auto-enrolls new entries
into the default collection. `archive.rs`: `list_root_entries` + `search_entries` accept `caller_bits: u32` and enforce
collection visibility (`admin/owner bypass`; others filtered by `visibility_bits & caller_bits`). `list_entries_for_collection`,
`get_entry_collections`, `EntryCollectionMembership` added. `routes.rs`: read routes extract auth and pass caller_bits;
5 collection-entry route groups (`GET|POST /api/archives/:id/collections`, `GET /api/archives/:id/collections/:uid`,
`POST|DELETE|PATCH /api/archives/:id/collections/:uid/entries(/:entry_uid)`,
`GET /api/archives/:id/entries/:uid/collections`). Frontend: `CollectionsView.jsx` (list + detail + create form);
`collections` nav item in Topbar; entry collection memberships + visibility shown in ContextRail. 169 tests green.
Depends on Track 5.
---
### ~~7. Settings~~ ✅ Done
**Implemented:** `database.rs`: `display_name TEXT` column migration (ALTER TABLE, idempotent);
`InstanceSettings` struct; 6 new pub fns (`get_instance_settings`, `update_instance_settings`,
`update_user_display_name`, `update_user_password`, `get_user_password_hash`,
`get_user_display_name`). `routes.rs`: `PATCH /api/auth/me` (display name update + current-password-
verified password change); `GET|PATCH /api/admin/instance-settings` (ROLE_ADMIN);
`auth_me` now returns `display_name`. `frontend/src/api.js`: 7 new helpers (`updateProfile`,
`changePassword`, `listTokens`, `createToken`, `deleteToken`, `getInstanceSettings`,
`updateInstanceSettings`). `frontend/src/components/SettingsView.jsx`: tabbed settings page
(Profile — display name + password change; API Tokens — create/list/revoke with one-time reveal;
Instance — public index, public content, open registration toggles + default visibility select).
`Topbar.jsx`: settings nav button; user-menu shows display_name ?? username.
`App.jsx`: renders SettingsView for `view === 'settings'`. 176 tests green. 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 56.
---
### 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
present in the bucket. The DB is also backed up.
**Design decisions to make before implementing:**
- CLI subcommand (`archivr backup`) vs. scheduled via the server vs. both.
- Per-archive config vs. a global backup profile.
- Whether to back up only the blob store or also the SQLite DB.
**Recommended shape:**
```toml
# In the archive's own config or in archivr-server.toml
[backup.s3]
bucket = "my-archivr-backup"
prefix = "personal/"
endpoint = "https://..." # optional, for R2/B2
region = "us-east-1"
# credentials via AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars
```
**Where to edit (when ready):**
| File | Change |
|---|---|
| `crates/archivr-core/src/backup.rs` | New module. Walk `store/raw/` tree, list objects in bucket, diff, upload new blobs. Also snapshot and upload `archivr.sqlite`. |
| `crates/archivr-cli/src/main.rs` | Add `archivr backup [--archive <path>]` subcommand |
| `Cargo.toml` | Add `aws-sdk-s3` or `object_store` crate dependency |
| `docs/README.md` | Document backup config and command |
**Acceptance criteria:**
- `archivr backup` uploads all blobs not already in the bucket.
- A second run uploads nothing (idempotent).
- DB snapshot is included.
- Missing or invalid credentials return a clear error before any uploads begin.
---
### 10. Cloud storage archiving (Google Drive, Dropbox, OneDrive)
Deferred. Each requires per-service OAuth, API clients, and download logic. Implement after
Tracks 14 are stable.
When approached, add a `Source::GoogleDrive`, `Source::Dropbox`, etc. variant in `capture.rs`
and a corresponding downloader module. Consider `rclone` as a shell-out strategy analogous to
`yt-dlp` and `monolith` — it handles auth and download for all three services.
---
## What to Do First
Tracks 1, 2, 3, 4, 5, and 6 are complete. Track 7 (settings) is the next priority.
Open the next thread with:
```text
Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 7: settings —
account profile, password change, instance settings UI, API token management.
Create a task-level implementation plan first, then wait for approval.
```

View file

@ -63,7 +63,7 @@ fn main() -> Result<()> {
}
};
let archive_paths = archive::read_archive_paths(&archive_path)?;
let result = archivr_core::capture::perform_capture(&archive_paths, path)?;
let result = archivr_core::capture::perform_capture(&archive_paths, path, None)?;
println!("Archived: run {}", result.run_uid);
Ok(())
}

View file

@ -60,6 +60,17 @@ pub struct RunSummary {
pub error_summary: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct CaptureJobSummary {
pub job_uid: String,
pub archive_id: String,
pub run_uid: Option<String>,
pub status: String,
pub error_text: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Tag {
pub tag_uid: String,
@ -74,6 +85,15 @@ pub struct TagNode {
pub children: Vec<TagNode>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct CollectionSummary {
pub collection_uid: String,
pub name: String,
pub slug: String,
pub default_visibility_bits: u32,
pub created_at: String,
}
pub fn find_archive_path_from(start: &Path) -> Result<Option<PathBuf>> {
let mut dir = start.to_path_buf();
loop {
@ -173,7 +193,7 @@ pub fn initialize_store_directories(store_path: &Path) -> Result<()> {
Ok(())
}
pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary>> {
pub fn list_root_entries(conn: &rusqlite::Connection, caller_bits: u32) -> Result<Vec<EntrySummary>> {
let mut stmt = conn.prepare(
"SELECT
e.entry_uid,
@ -192,12 +212,20 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id
LEFT JOIN blobs b ON b.id = ea.blob_id
WHERE e.parent_entry_id IS NULL
AND (
CAST(?1 AS INTEGER) & 12 != 0
OR EXISTS (
SELECT 1 FROM collection_entries ce
WHERE ce.entry_id = e.id
AND ce.visibility_bits & CAST(?1 AS INTEGER) != 0
)
)
GROUP BY e.id
ORDER BY e.archived_at DESC, e.id DESC",
)?;
let entries = stmt
.query_map([], |row| {
.query_map([caller_bits as i64], |row| {
Ok(EntrySummary {
entry_uid: row.get(0)?,
archived_at: row.get(1)?,
@ -241,7 +269,7 @@ pub fn get_entry_detail(
return Ok(None);
};
let summary = list_root_entries(conn)?
let summary = list_root_entries(conn, u32::MAX)?
.into_iter()
.find(|entry| entry.entry_uid == entry_uid)
.context("entry disappeared while loading detail")?;
@ -299,6 +327,110 @@ pub fn list_runs(conn: &rusqlite::Connection) -> Result<Vec<RunSummary>> {
Ok(runs)
}
pub fn get_capture_job(
conn: &rusqlite::Connection,
job_uid: &str,
) -> Result<Option<CaptureJobSummary>> {
Ok(database::get_capture_job(conn, job_uid)?.map(|r| CaptureJobSummary {
job_uid: r.job_uid,
archive_id: r.archive_id,
run_uid: r.run_uid,
status: r.status,
error_text: r.error_text,
created_at: r.created_at,
updated_at: r.updated_at,
}))
}
/// Lists all collections in the archive.
pub fn list_collections(conn: &rusqlite::Connection) -> Result<Vec<CollectionSummary>> {
let records = database::list_collections(conn)?;
Ok(records
.into_iter()
.map(|r| CollectionSummary {
collection_uid: r.collection_uid,
name: r.name,
slug: r.slug,
default_visibility_bits: r.default_visibility_bits,
created_at: r.created_at,
})
.collect())
}
/// Represents an entry's membership in a collection with its visibility bits.
#[derive(Debug, Clone, serde::Serialize)]
pub struct EntryCollectionMembership {
pub collection_uid: String,
pub visibility_bits: u32,
}
/// Returns collection memberships for the given entry_uid.
/// Returns Ok(None) if the entry_uid does not exist.
pub fn get_entry_collections(
conn: &rusqlite::Connection,
entry_uid: &str,
) -> Result<Option<Vec<EntryCollectionMembership>>> {
let Some(entry_id) = conn
.query_row(
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
[entry_uid],
|row| row.get::<_, i64>(0),
)
.optional()?
else {
return Ok(None);
};
let memberships = database::get_entry_collection_memberships(conn, entry_id)?;
Ok(Some(
memberships
.into_iter()
.map(|(_, uid, bits)| EntryCollectionMembership {
collection_uid: uid,
visibility_bits: bits,
})
.collect(),
))
}
/// Returns entries belonging to a collection, filtered by caller_bits visibility.
/// Caller with admin/owner bits (4|8) sees all entries regardless of visibility_bits.
pub fn list_entries_for_collection(
conn: &rusqlite::Connection,
collection_id: i64,
caller_bits: u32,
) -> Result<Vec<EntrySummary>> {
let sql = format!(
"{} {} \
JOIN collection_entries ce ON ce.entry_id = e.id \
WHERE ce.collection_id = ?1 \
AND (CAST(?2 AS INTEGER) & 12 != 0 \
OR (ce.visibility_bits & CAST(?2 AS INTEGER)) != 0) \
GROUP BY e.id \
ORDER BY e.archived_at DESC, e.id DESC",
ENTRY_SELECT_COLS,
ENTRY_FROM_JOINS,
);
let mut stmt = conn.prepare(&sql)?;
let entries = stmt
.query_map([collection_id, caller_bits as i64], |row| {
Ok(EntrySummary {
entry_uid: row.get(0)?,
archived_at: row.get(1)?,
source_kind: row.get(2)?,
entity_kind: row.get(3)?,
title: row.get(4)?,
visibility: row.get(5)?,
original_url: row.get(6)?,
artifact_count: row.get(7)?,
total_artifact_bytes: row.get(8)?,
parent_entry_uid: row.get(9)?,
has_favicon: row.get::<_, i64>(10)? != 0,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
/// Resolves an artifact to its absolute on-disk path under `store_path`.
///
/// `artifact.relpath` is a store-relative path (e.g. `raw/a/b/abc.pdf`).
@ -324,7 +456,7 @@ pub fn resolve_artifact_path(
Ok(canonical_artifact)
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone)]
pub struct SearchEntriesQuery {
/// Free-text term: LIKE-matched against title, canonical_url, entry_uid, source_kind, entity_kind, visibility
pub q: Option<String>,
@ -342,6 +474,25 @@ pub struct SearchEntriesQuery {
pub before: Option<String>,
/// Tag full_path filter; includes all entries (root + child) matching the tag subtree
pub tag: Option<String>,
/// Role bits of the caller for visibility filtering. Admins (bits 4/8) bypass all filters.
/// Pass `u32::MAX` internally to bypass all visibility. Pass 0 for unauthenticated guests only.
pub caller_bits: u32,
}
impl Default for SearchEntriesQuery {
fn default() -> Self {
Self {
q: None,
source_kind: None,
entity_kind: None,
url: None,
title: None,
after: None,
before: None,
tag: None,
caller_bits: u32::MAX,
}
}
}
/// Parses a raw search string into a [`SearchEntriesQuery`].
@ -472,6 +623,15 @@ pub fn search_entries(
params.push(b.clone());
}
// Visibility filter
let n = params.len() + 1;
sql.push_str(&format!(
" AND (CAST(?{n} AS INTEGER) & 12 != 0 \
OR EXISTS (SELECT 1 FROM collection_entries ce \
WHERE ce.entry_id = e.id AND ce.visibility_bits & CAST(?{n} AS INTEGER) != 0))"
));
params.push(query.caller_bits.to_string());
sql.push_str(" GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC");
let mut stmt = conn.prepare(&sql)?;
@ -823,7 +983,7 @@ mod tests {
database::complete_archive_run_item(&conn, item.id, entry.id).unwrap();
database::finish_archive_run(&conn, run.id).unwrap();
let entries = list_root_entries(&conn).unwrap();
let entries = list_root_entries(&conn, u32::MAX).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].title.as_deref(), Some("Saved Article"));
assert_eq!(entries[0].artifact_count, 1);
@ -994,7 +1154,7 @@ mod tests {
#[test]
fn search_empty_query_returns_all_root_entries() {
let conn = make_test_db_with_entries();
let all = list_root_entries(&conn).unwrap();
let all = list_root_entries(&conn, u32::MAX).unwrap();
let searched = search_entries(&conn, &SearchEntriesQuery::default()).unwrap();
assert_eq!(all.len(), searched.len());
}

View file

@ -332,9 +332,6 @@ fn determine_source(path: &str) -> Source {
fn hash_exists(hash: &str, file_extension: &str, store_path: &Path) -> Result<bool> {
let path = store_path.join(raw_relative_path_from_hash(hash, file_extension)?);
println!("Checking {}", path.display());
Ok(path.exists())
}
@ -675,7 +672,11 @@ fn fail_run(
anyhow::anyhow!("{}", message)
}
pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<CaptureResult> {
pub fn perform_capture(
archive_paths: &ArchivePaths,
locator: &str,
archive_id: Option<&str>,
) -> Result<CaptureResult> {
let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S%.3f").to_string();
let store_path = &archive_paths.store_path;
@ -790,13 +791,32 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<Ca
.join("temp")
.join(&timestamp)
.join(format!("{timestamp}{file_extension}"));
let byte_size = fs::metadata(&temp_html)
.with_context(|| format!("failed to stat staged file {}", temp_html.display()))?
// Font extraction: rewrite the HTML in-place before hashing.
// Only runs when archive_id is known (server context). CLI passes
// None and keeps fonts embedded — no behaviour change for CLI.
let (html_hash, byte_size, extracted_fonts) =
if let Some(aid) = archive_id {
let content = fs::read_to_string(&temp_html)
.with_context(|| format!("failed to read {}", temp_html.display()))?;
let (rewritten, fonts) =
downloader::font_extractor::extract_and_rewrite(&content, store_path, aid)
.unwrap_or_else(|_| (content.clone(), vec![])); // non-fatal
fs::write(&temp_html, rewritten.as_bytes())
.with_context(|| "failed to write rewritten HTML")?;
let size = rewritten.len() as i64;
let new_hash = crate::hash::hash_bytes(rewritten.as_bytes());
(new_hash, size, fonts)
} else {
let size = fs::metadata(&temp_html)
.with_context(|| format!("failed to stat {}", temp_html.display()))?
.len() as i64;
(result.html_hash.clone(), size, vec![])
};
// 1. Move HTML to raw store (if this hash hasn't been seen before).
if !hash_exists(&result.html_hash, &file_extension, store_path)? {
move_temp_to_raw(&temp_html, &result.html_hash, store_path)?;
if !hash_exists(&html_hash, &file_extension, store_path)? {
move_temp_to_raw(&temp_html, &html_hash, store_path)?;
}
// 2. Process favicon while the temp dir still exists.
@ -837,7 +857,7 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<Ca
locator,
locator,
source,
&result.html_hash,
&html_hash,
&file_extension,
byte_size,
result.title,
@ -859,6 +879,31 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<Ca
);
}
// 6. Register each extracted font as a deduplicated blob + artifact.
for font in extracted_fonts {
let font_blob = database::BlobRecord {
sha256: font.sha256.clone(),
byte_size: font.byte_size,
mime_type: mime_for_font_ext(&font.ext),
extension: font.ext.strip_prefix('.').map(|s| s.to_string()),
raw_relpath: font.raw_relpath.clone(),
};
if let Ok(blob_id) = database::upsert_blob(&conn, &font_blob) {
let _ = database::add_entry_artifact(
&conn,
&database::NewArtifact {
entry_id: entry.id,
artifact_role: "font".to_string(),
storage_area: "raw".to_string(),
relpath: font.raw_relpath,
blob_id: Some(blob_id),
logical_path: None,
metadata_json: None,
},
);
}
}
database::finish_archive_run(&conn, run.id)?;
return Ok(CaptureResult {
run_uid: run.run_uid.clone(),
@ -1019,6 +1064,16 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<Ca
})
}
fn mime_for_font_ext(ext: &str) -> Option<String> {
match ext {
".woff2" => Some("font/woff2".to_string()),
".woff" => Some("font/woff".to_string()),
".ttf" => Some("font/ttf".to_string()),
".otf" => Some("font/otf".to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,187 @@
use anyhow::Result;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use regex::Regex;
use std::{fs, path::Path};
use crate::hash::hash_bytes;
#[derive(Debug, Clone)]
pub struct ExtractedFont {
/// SHA3-256 hex of the raw font bytes.
pub sha256: String,
/// File extension including leading dot, e.g. `".woff2"`.
pub ext: String,
/// Size in bytes of the decoded font.
pub byte_size: i64,
/// Store-relative path, e.g. `"raw/a/b/abc...woff2"`.
pub raw_relpath: String,
}
/// Scans `html` for `@font-face` `src` attributes containing `data:font/...;base64,...`
/// URLs, decodes each font, stores it in `{store_path}/raw/`, and rewrites the src
/// to `/api/archives/{archive_id}/blobs/{sha256}`.
///
/// Returns `(rewritten_html, Vec<ExtractedFont>)`. Each occurrence is reported
/// individually; the caller deduplicates via `upsert_blob`.
pub fn extract_and_rewrite(
html: &str,
store_path: &Path,
archive_id: &str,
) -> Result<(String, Vec<ExtractedFont>)> {
// Matches: url(data:font/MIME;base64,DATA) or url("data:font/MIME;base64,DATA")
// Also matches data:application/font-... for older MIME types.
// Group 2 captures the full MIME type (e.g. "font/woff2"), group 3 the base64 payload.
let re = Regex::new(
r#"url\("?(data:((?:font|application)/[^;]+);base64,([A-Za-z0-9+/=]+))"?\)"#,
)?;
let mut fonts = Vec::new();
let rewritten = re.replace_all(html, |caps: &regex::Captures| {
let mime = caps[2].to_ascii_lowercase();
let b64_data = &caps[3];
let ext = match mime_to_ext(&mime) {
Some(e) => e,
None => return caps[0].to_string(), // unknown MIME — leave as-is
};
let bytes = match B64.decode(b64_data) {
Ok(b) => b,
Err(_) => return caps[0].to_string(), // corrupt base64 — leave as-is
};
let sha256 = hash_bytes(&bytes);
let raw_relpath = font_raw_relpath(&sha256, ext);
let abs_path = store_path.join(&raw_relpath);
if !abs_path.exists() {
if let Some(parent) = abs_path.parent() {
let _ = fs::create_dir_all(parent);
}
if fs::write(&abs_path, &bytes).is_err() {
return caps[0].to_string(); // write failed — leave as-is
}
}
fonts.push(ExtractedFont {
sha256: sha256.clone(),
ext: ext.to_string(),
byte_size: bytes.len() as i64,
raw_relpath,
});
format!("url(/api/archives/{archive_id}/blobs/{sha256})")
});
Ok((rewritten.into_owned(), fonts))
}
fn font_raw_relpath(sha256: &str, ext: &str) -> String {
let mut chars = sha256.chars();
let a = chars.next().unwrap_or('0');
let b = chars.next().unwrap_or('0');
format!("raw/{a}/{b}/{sha256}{ext}")
}
fn mime_to_ext(mime: &str) -> Option<&'static str> {
match mime {
"font/woff2" | "application/font-woff2" => Some(".woff2"),
"font/woff" | "application/font-woff" => Some(".woff"),
"font/ttf" | "font/truetype" | "application/x-font-truetype" => Some(".ttf"),
"font/otf" | "font/opentype" | "application/x-font-opentype" => Some(".otf"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn html_with_woff2_font() -> String {
let font_b64 = B64.encode(b"WOFF2FAKEDATA");
format!(
"<style>@font-face{{font-family:Test;\
src:url(data:font/woff2;base64,{font_b64})}}</style>\
<p>Hello</p>"
)
}
#[test]
fn replaces_data_url_with_api_url() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("raw")).unwrap();
let html = html_with_woff2_font();
let (rewritten, fonts) = extract_and_rewrite(&html, tmp.path(), "myarchive").unwrap();
assert_eq!(fonts.len(), 1, "should extract one font");
let font = &fonts[0];
assert_eq!(font.ext, ".woff2");
assert_eq!(font.byte_size, b"WOFF2FAKEDATA".len() as i64);
assert!(!font.sha256.is_empty());
assert!(!rewritten.contains("data:font"), "data URL must be gone from HTML");
assert!(
rewritten.contains(&format!("/api/archives/myarchive/blobs/{}", font.sha256)),
"rewritten HTML must contain the local API URL"
);
}
#[test]
fn font_file_is_written_to_raw_store() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("raw")).unwrap();
let html = html_with_woff2_font();
let (_, fonts) = extract_and_rewrite(&html, tmp.path(), "myarchive").unwrap();
let font = &fonts[0];
let raw_path = tmp.path().join(&font.raw_relpath);
assert!(raw_path.exists(), "font file must exist at raw_relpath");
let written = std::fs::read(&raw_path).unwrap();
assert_eq!(written, b"WOFF2FAKEDATA");
}
#[test]
fn deduplicates_identical_fonts() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("raw")).unwrap();
let font_b64 = B64.encode(b"SAMEDATA");
let html = format!(
"<style>\
@font-face{{font-family:A;src:url(data:font/woff2;base64,{font_b64})}}\
@font-face{{font-family:B;src:url(data:font/woff2;base64,{font_b64})}}\
</style>"
);
let (_, fonts) = extract_and_rewrite(&html, tmp.path(), "x").unwrap();
assert_eq!(fonts.len(), 2);
assert_eq!(fonts[0].sha256, fonts[1].sha256);
let raw_path = tmp.path().join(&fonts[0].raw_relpath);
assert!(raw_path.exists());
}
#[test]
fn html_without_font_data_urls_is_unchanged() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("raw")).unwrap();
let html = "<style>body { color: red; }</style><p>no fonts</p>";
let (rewritten, fonts) = extract_and_rewrite(html, tmp.path(), "x").unwrap();
assert_eq!(fonts.len(), 0);
assert_eq!(rewritten, html);
}
#[test]
fn ttf_font_gets_correct_extension() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("raw")).unwrap();
let font_b64 = B64.encode(b"TTFDATA");
let html = format!(
"<style>@font-face{{src:url(data:font/ttf;base64,{font_b64})}}</style>"
);
let (_, fonts) = extract_and_rewrite(&html, tmp.path(), "x").unwrap();
assert_eq!(fonts[0].ext, ".ttf");
}
}

View file

@ -5,3 +5,4 @@ pub mod ytdlp;
pub mod metadata;
pub mod http;
pub mod singlefile;
pub mod font_extractor;

View file

@ -63,6 +63,16 @@ fn save_with(
)
.context("failed to write single-file user script")?;
// Chrome's user-data-dir for this capture. Required alongside
// --disable-web-security — newer Chromium silently ignores that flag
// without a writable user-data-dir. Using a subdirectory of temp_dir
// keeps it isolated and it gets cleaned up with the rest of the temp dir.
let chrome_data_dir = temp_dir.join("chrome-data");
let browser_args = format!(
"[\"--disable-web-security\",\"--user-data-dir={}\"]",
chrome_data_dir.display()
);
let out = Command::new(single_file)
.arg(url)
.arg(&out_file)
@ -72,6 +82,11 @@ fn save_with(
// Extra delay after networkidle2: Cloudflare Fonts injects @font-face
// CSS after HTML parse, so the font hook needs more time to see it.
.arg("--browser-wait-delay=2000")
// Realistic UA: some origins block headless Chrome's default UA string.
.arg("--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
// Chrome-level flags: disable CORS so fonts from any CDN origin can be
// read and inlined (e.g. fonts.gstatic.com without ACAO:*).
.arg(format!("--browser-args={browser_args}"))
// Preserve all CSS: single-file's defaults strip rules it considers
// "unused" (breaks CSS nesting) and remove @media blocks that don't
// match the capture viewport (breaks responsive layout).

View file

@ -1,17 +1,56 @@
use anyhow::Result;
use sha3::{Digest, Sha3_256};
use std::{fs::File, io::Read, path::Path};
use std::path::Path;
pub fn hash_file(path: &Path) -> Result<String> {
let mut file = File::open(path)?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)?;
Ok(hash(String::from_utf8_lossy(&buf).to_string()))
}
pub fn hash(path: String) -> String {
/// Computes the SHA3-256 hex digest of `data` directly on raw bytes.
pub fn hash_bytes(data: &[u8]) -> String {
let mut hasher = Sha3_256::new();
hasher.update(path.as_bytes());
let result = hasher.finalize();
hex::encode(result)
hasher.update(data);
hex::encode(hasher.finalize())
}
/// Reads `path` fully and returns its SHA3-256 hex digest.
/// Uses raw bytes — correct for both text and binary files.
pub fn hash_file(path: &Path) -> Result<String> {
let buf = std::fs::read(path)?;
Ok(hash_bytes(&buf))
}
/// Hashes an arbitrary string (used for legacy callers; prefer `hash_bytes`).
pub fn hash(s: String) -> String {
hash_bytes(s.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_bytes_and_hash_file_agree_on_same_data() {
let data = b"hello archivr fonts";
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), data).unwrap();
assert_eq!(hash_bytes(data), hash_file(tmp.path()).unwrap());
}
#[test]
fn hash_bytes_is_stable() {
let h = hash_bytes(b"archivr");
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn hash_bytes_differs_from_hash_of_lossy_utf8_for_binary_data() {
let binary = b"\xff\xfe\xfd";
let direct = hash_bytes(binary);
let lossy = String::from_utf8_lossy(binary).to_string();
let via_old_path = {
use sha3::{Digest, Sha3_256};
let mut h = Sha3_256::new();
h.update(lossy.as_bytes());
hex::encode(h.finalize())
};
assert_ne!(direct, via_old_path);
}
}

View file

@ -12,8 +12,14 @@ 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
rusqlite.workspace = true
[dev-dependencies]
tempfile.workspace = true
tower.workspace = true
serde_json.workspace = true

View 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())
}

View file

@ -1,3 +1,4 @@
mod auth;
mod registry;
mod routes;
@ -12,10 +13,45 @@ 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());
// On startup, mark any jobs that were 'running' when the server last stopped as 'failed'.
for archive in &registry.archives {
if let Ok(conn) = archivr_core::database::open_or_initialize(&archive.archive_path) {
match archivr_core::database::fail_stalled_capture_jobs(&conn) {
Ok(n) if n > 0 => eprintln!("info: marked {n} stalled capture job(s) as failed in '{}'", archive.id),
Err(e) => eprintln!("warn: stalled job cleanup failed for '{}': {e:#}", archive.id),
_ => {}
}
}
}
// 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 +61,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?;

View file

@ -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 &registry.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, &registry).unwrap();
@ -109,6 +110,7 @@ mod tests {
},
],
bind: None,
auth_db_path: None,
};
let err = validate_registry(&registry).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(&registry).is_ok());

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<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>
<link rel="stylesheet" crossorigin href="/assets/index-DJpQthbx.css">
<script type="module" crossorigin src="/assets/index-C6h0jcH3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D40QFUPh.css">
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,403 @@
# Auth Foundation Design
**Track:** 4 of the roadmap (inserted after Track 3: Async capture jobs)
**Date:** 2026-06-25
**Status:** Approved for implementation
---
## Context & Roadmap Position
Archivr is evolving from a local-only tool (single hard-coded user, 127.0.0.1 binding) into a
self-hosted multi-user platform — think ArchiveBox but with real accounts, roles, and
public/private visibility. This track lays the foundation. All subsequent tracks depend on it.
**Full decomposition:**
| Track | Scope | Depends on |
|---|---|---|
| 4 (this) | Auth foundation | — |
| 5 | User management — registration, custom roles, admin panel | Track 4 |
| 6 | Permissions & visibility — collection model, per-membership visibility | Track 5 |
| 7 | Settings — account profile, instance-wide toggles | Track 5 |
| 8 | Collections UI | Tracks 56 |
---
## Goals
- Password-protected login with cookie sessions and API tokens
- Role table with bitmask-based visibility (extensible to custom roles in Track 5)
- Auth middleware that protects write/admin routes
- First-run owner setup wizard
- Frontend login page and session-aware API calls
## Non-Goals (explicitly deferred)
- Custom role creation UI → Track 5
- User registration flow → Track 5
- Visibility enforcement on queries → Track 6
- Collection model (replacing `archived_entries.visibility`) → Track 6
- Account settings page → Track 7
- API token management UI → Track 7
---
## Schema
### New table: `roles`
```sql
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY,
role_uid TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE, -- 'guest', 'user', 'admin', 'owner', or custom
name TEXT NOT NULL,
level INTEGER NOT NULL, -- ordering: guest=0, user=1, admin=3, owner=4
bit_position INTEGER NOT NULL UNIQUE, -- position in visibility bitmask
is_builtin INTEGER NOT NULL DEFAULT 0 CHECK (is_builtin IN (0, 1))
);
```
**Built-in rows seeded at schema init:**
| slug | level | bit_position | bit value | is_builtin |
|---|---|---|---|---|
| guest | 0 | 0 | 1 | 1 |
| user | 1 | 1 | 2 | 1 |
| admin | 3 | 2 | 4 | 1 |
| owner | 4 | 3 | 8 | 1 |
Bit position 2 (value 4) is reserved for `admin`. Bit positions 4+ (values 16, 32, …) are assigned
to custom roles in Track 5. Level 2 is reserved for custom roles sitting between `user` and `admin`.
**role_bits computation — implicit guest floor:**
`role_bits` for any **authenticated** user is computed as:
```
role_bits = ROLE_GUEST | (OR of bit values for all rows in user_roles)
```
The `ROLE_GUEST` bit (1) is always included for authenticated users so they can access
public (guest-visible) content. Example: an owner assigned only the `owner` role gets
`role_bits = 1 | 8 = 9`, which passes `role_bits & ROLE_USER (2) = 0` — still broken.
**Therefore, role assignment is cumulative by level.** When a role is assigned, all
built-in roles at lower levels are also assigned:
- Assigning `owner` (level 4) → also assign `admin`, `user` in `user_roles`
- Assigning `admin` (level 3) → also assign `user` in `user_roles`
- Assigning `user` (level 1) → no additional rows
- `guest` is never assigned; it is the implicit unauthenticated floor
Setup creates owner with three `user_roles` rows: `user`, `admin`, `owner`.
Resulting `role_bits = ROLE_GUEST | ROLE_USER | ROLE_ADMIN | ROLE_OWNER = 1|2|4|8 = 15`.
**Visibility check:** `viewer.role_bits & content.visibility != 0` passes if the viewer
has any bit the content requires. Owner (15) can see everything. User (1|2=3) can see
guest-visible (1) and user-visible (2) content but not admin-only (4). ✓
`is_builtin = 1` rows cannot be deleted.
### New table: `sessions`
```sql
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, -- snapshot of bitmask at login time; role changes take effect on next login
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
expires_at TEXT NOT NULL, -- 30 days from last_seen_at
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
```
### New table: `api_tokens`
```sql
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, -- SHA-256 of raw token; raw token never stored
name TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
expires_at TEXT -- NULL = never expires
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
```
### `users` table — existing, minimally changed
The existing `role TEXT NOT NULL CHECK (role IN ('admin','user'))` column is **kept but inert**
auth middleware reads from `user_roles`, not this column. It will be removed in Track 5 cleanup.
`ensure_owner_exists` must supply a value for this column; use `'admin'` as the placeholder.
`ensure_default_user` is replaced by `ensure_owner_exists` which returns `false` if no owner
row exists in `user_roles` (triggers setup mode). The old local-admin stub is never created on
fresh instances. Session lookup JOINs `users` and checks `users.status = 'active'`; a session
belonging to a disabled user resolves to `AuthUser::Guest`.
### `instance_settings` — one new column
The column is added **inside** the existing `CREATE TABLE IF NOT EXISTS instance_settings` DDL,
not via `ALTER TABLE` (which is not idempotent in `initialize_schema`):
```sql
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 -- 2 = user-visible by default
);
```
The existing `INSERT OR IGNORE INTO instance_settings … VALUES (1, 0, 0, 0)` seed row must be
updated to include the new column: `VALUES (1, 0, 0, 0, 2)`.
### `archived_entries.visibility` — deprecated, not removed
Flagged with a `-- DEPRECATED: replaced by collection_entries.visibility in Track 6` comment in
`initialize_schema`. No data migration needed yet; Track 6 handles it.
---
## Auth Flow
### Login
```
POST /api/auth/login
Body: { username: string, password: string }
```
1. Look up user by username.
2. Verify password with Argon2id (`argon2` crate).
3. Compute `role_bits = ROLE_GUEST | (OR of bit values for all user_roles rows)` (cumulative; see Schema § role_bits computation).
4. Insert `sessions` row (`session_uid` = UUID, `expires_at` = now + 30 days).
5. Set-Cookie: `session=<session_uid>; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000`.
Add `Secure` flag when the request arrived over HTTPS (detected via `X-Forwarded-Proto: https`
header or TLS connection info). Omit `Secure` for plain HTTP to support local dev without TLS.
6. Return `200 { user_uid, username, role_bits }`.
On failure: `401 { error: "invalid_credentials" }` — same message for unknown user and wrong
password (no user enumeration).
### Logout
```
POST /api/auth/logout
```
Deletes the `sessions` row for the current session cookie. Responds with
`Set-Cookie: session=; Max-Age=0` to clear the browser cookie. Returns `204`.
### Current user
```
GET /api/auth/me
```
Returns `200 { user_uid, username, role_bits }` for an authenticated request, or `401` for a
guest. The frontend calls this once on mount to restore session state.
### First-run setup
```
GET /api/auth/setup → 200 { setup_required: bool }
POST /api/auth/setup → 201 { user_uid, username }
Body: { username: string, password: string }
```
`setup_required` is `true` when no user has the `owner` role in `user_roles`. On `POST`:
- If setup is **already complete** (an owner exists): return `409 { error: "already_configured" }`.
- Otherwise: create the user (with `users.role = 'admin'` as placeholder), assign `user_roles`
rows for `user`, `admin`, `owner` (cumulative), seed `instance_settings` row if absent.
Return `201 { user_uid, username }`. Normal login flow applies immediately after.
All non-setup API routes return `503 { error: "setup_required" }` until setup is complete.
The following routes are **exempt** from the 503 check: `GET /api/auth/setup`,
`POST /api/auth/setup`, `GET /` (static), `GET /assets/*` (static).
### API tokens
```
POST /api/auth/tokens → 201 { token_uid, raw_token, name, created_at }
GET /api/auth/tokens → 200 [{ token_uid, name, created_at, last_used_at }]
DELETE /api/auth/tokens/:token_uid → 204
```
`raw_token` is a cryptographically random 32-byte value, base64url-encoded, returned once. The
server stores only its SHA-256 hash. The management UI for these endpoints is in Track 7; the
endpoints are implemented here.
### Password hashing
Argon2id with default parameters from the `argon2` crate (memory=19 MiB, iterations=2,
parallelism=1). The current `"disabled-local-password"` sentinel in `ensure_default_user` becomes
irrelevant once setup is required on fresh instances.
### Session expiry & cleanup
`last_seen_at` is updated on every authenticated request using a **conditional update**: the
session row is already read during extraction; if `now() - last_seen_at > 60s`, issue an UPDATE.
This adds no extra query — only an extra UPDATE when the threshold is crossed.
`expires_at` = `last_seen_at + 30 days`, recalculated on each UPDATE. A background task in
`archivr-server/src/main.rs` runs `DELETE FROM sessions WHERE expires_at < now()` at startup
and every 24 hours via `tokio::time::interval`.
---
## Auth Extractor
New file: `crates/archivr-server/src/auth.rs`
```rust
pub enum AuthUser {
Guest,
Authenticated { user_id: i64, role_bits: u32 },
}
impl AuthUser {
pub fn require_auth(&self) -> Result<(i64, u32), ApiError> // 401 if Guest
pub fn require_role(&self, bit: u32) -> Result<(), ApiError> // 403 if bit not set
pub fn has_role(&self, bit: u32) -> bool
}
// Role bit constants
pub const ROLE_GUEST: u32 = 1;
pub const ROLE_USER: u32 = 2;
pub const ROLE_ADMIN: u32 = 4;
pub const ROLE_OWNER: u32 = 8;
```
Implemented as an Axum `FromRequestParts` extractor. Tries `session` cookie first, then
`Authorization: Bearer` header.
- **Cookie path**: look up `sessions` row JOIN `users` WHERE `session_uid = ?`
AND `users.status = 'active'` AND `expires_at > now()`. Use cached `role_bits` from the
session row.
- **Bearer path**: SHA-256 the token, look up `api_tokens` row JOIN `users` WHERE
`token_hash = ?` AND `users.status = 'active'` AND (`expires_at IS NULL OR expires_at > now()`).
Compute `role_bits` live: `ROLE_GUEST | (OR of user_roles bit values for that user)`.
Update `api_tokens.last_used_at`.
- Missing or invalid credential → `AuthUser::Guest` (never a hard error at extraction time).
---
## Route Protection Tiers
The existing security-boundary comment block in `routes.rs` is updated:
| Tier | Requirement | Examples |
|---|---|---|
| `STATIC` | none | `GET /`, `GET /assets/*` |
| `PUBLIC_READ` | none (visibility filtering deferred to Track 6) | `GET /api/archives/:id/entries` |
| `AUTH_READ` | `ROLE_USER` bit | authenticated entry access |
| `WRITE` | `ROLE_USER` bit | `POST /api/archives/:id/captures`, tag mutations |
| `ADMIN` | `ROLE_ADMIN` bit | `GET /api/admin/archives`, user management |
| `OWNER` | `ROLE_OWNER` bit | instance settings, ownership transfer |
**Error responses:**
- No/invalid session → `401` (frontend redirects to login)
- Valid session, insufficient role → `403`
- Private resource accessed without sufficient role → `404` (do not reveal existence)
Track 4 applies `ROLE_USER` enforcement to all existing `WRITE` routes and `ROLE_ADMIN` to
`/api/admin/*`. `PUBLIC_READ` routes return all data for now; Track 6 adds visibility filters.
---
## Frontend Changes
### New components
| Component | Purpose |
|---|---|
| `SetupPage.jsx` | First-run owner account creation wizard |
| `LoginPage.jsx` | Username/password login form |
### App.jsx changes
- On mount: call `GET /api/auth/setup`; if `setup_required`, render `<SetupPage>` and nothing else.
- Otherwise: call `GET /api/auth/me`; store result as `currentUser` state (null = guest).
- Pass `currentUser` down via React context (`AuthContext`).
- Any `401` response from any API call sets `currentUser` to null → triggers `<LoginPage>`.
### api.js changes
- Thin response interceptor: if status is `401`, dispatch a global `auth:expired` event that
`App.jsx` listens to and handles by clearing `currentUser`.
- No token storage in JS — cookies are handled entirely by the browser.
### Topbar.jsx changes
- When `currentUser` is set: show `username` and a **Log out** button.
- Log out calls `POST /api/auth/logout`, then clears `currentUser`.
### What is NOT in Track 4 frontend
- Settings page (Track 7)
- User management UI (Track 5)
- Role or visibility controls (Track 6)
- API token management UI (Track 7)
---
## New Dependencies
| Crate | Purpose |
|---|---|
| `argon2` | Password hashing (Argon2id) |
| `rand` | Cryptographically random token generation |
| `tower-cookies` | Cookie extraction in Axum (or use `axum-extra`) |
Add to `archivr-server/Cargo.toml` and workspace `Cargo.toml` as needed.
---
## Files Changed
| File | Change |
|---|---|
| `crates/archivr-core/src/database.rs` | Add `roles`, `user_roles`, `sessions`, `api_tokens` tables; seed built-in roles; add `instance_settings.default_entry_visibility`; replace `ensure_default_user` with `ensure_owner_exists`; add session/token CRUD helpers |
| `crates/archivr-server/src/auth.rs` | New: `AuthUser` extractor, role bit constants, session/token lookup |
| `crates/archivr-server/src/routes.rs` | Add auth endpoints (`/api/auth/*`); apply `AuthUser` extractor to WRITE/ADMIN routes; update security-boundary comment |
| `crates/archivr-server/src/main.rs` | Session cleanup background task |
| `frontend/src/App.jsx` | Setup check, auth state, `AuthContext` |
| `frontend/src/api.js` | 401 interceptor |
| `frontend/src/components/LoginPage.jsx` | New |
| `frontend/src/components/SetupPage.jsx` | New |
| `frontend/src/components/Topbar.jsx` | User menu + logout |
| `Cargo.toml` | Add `argon2`, `rand`, `tower-cookies` (or `axum-extra`) |
---
## Test Coverage
- `database.rs`: role seeding, `ensure_owner_exists`, session CRUD, token hash round-trip
- `auth.rs`: extractor resolves cookie → session → user; extractor resolves Bearer → token → user;
missing credential → Guest; expired session → Guest
- `routes.rs`: login happy path; login wrong password returns 401; logout clears session;
setup endpoint returns 503 after setup complete; WRITE route returns 401 for Guest;
WRITE route returns 403 for insufficient role; setup flow end-to-end
---
## Track Numbering Update for NEXT.md
Original tracks 4 and 5 shift to 8 and 9. Collections is a named future track (no number until
scoped):
| # | Track |
|---|---|
| 3 | Async capture jobs |
| **4** | **Auth foundation (this spec)** |
| **5** | **User management** |
| **6** | **Permissions & visibility (collection model)** |
| **7** | **Settings** |
| **8** | **Collections UI** |
| 9 | Cloud backup (was 4) |
| 10 | Cloud storage (was 5) |

View file

@ -73,6 +73,7 @@
cargoLock
nativeBuildInputs
;
buildInputs = [ pkgs.openssl ];
cargoBuildFlags = [
"-p"
"archivr-cli"
@ -90,6 +91,7 @@
cargoLock
nativeBuildInputs
;
buildInputs = [ pkgs.openssl ];
cargoBuildFlags = [
"-p"
"archivr-server"

View file

@ -1,14 +1,41 @@
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'
import RunsView from './components/RunsView'
import AdminView from './components/AdminView'
import TagsView from './components/TagsView'
import CollectionsView from './components/CollectionsView'
import SettingsView from './components/SettingsView'
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,7 +154,12 @@ 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 (
<AuthContext.Provider value={{ currentUser, setCurrentUser }}>
<>
<Topbar
archives={archives}
@ -183,6 +215,12 @@ export default function App() {
onViewChange={handleViewChange}
/>
)}
{view === 'collections' && (
<CollectionsView archiveId={archiveId} />
)}
{view === 'settings' && (
<SettingsView />
)}
</div>
<ContextRail
archiveId={archiveId}
@ -199,5 +237,6 @@ export default function App() {
onCaptured={handleCaptured}
/>
</>
</AuthContext.Provider>
)
}

View file

@ -64,7 +64,250 @@ export async function submitCapture(archiveId, locator) {
body: JSON.stringify({ locator }),
});
if (!res.ok) {
const msg = await res.text();
throw new Error(msg || `HTTP ${res.status}`);
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
return res.json(); // { job_uid, status: "pending" }
}
export async function pollCaptureJob(archiveId, jobUid) {
return getJson(`/api/archives/${archiveId}/capture_jobs/${jobUid}`);
}
// ── 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();
}
// ── Profile & settings helpers ───────────────────────────────────────────────
export async function updateProfile(displayName) {
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: displayName }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function changePassword(currentPassword, newPassword) {
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
if (!res.ok) {
let msg = await res.text();
try { msg = JSON.parse(msg).error ?? msg; } catch {}
throw new Error(msg);
}
}
export async function listTokens() {
return getJson('/api/auth/tokens');
}
export async function createToken(name) {
const res = await fetch('/api/auth/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteToken(tokenUid) {
const res = await fetch(`/api/auth/tokens/${tokenUid}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
}
export async function getInstanceSettings() {
return getJson('/api/admin/instance-settings');
}
export async function updateInstanceSettings(patch) {
const res = await fetch('/api/admin/instance-settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(await res.text());
}
// ── Admin helpers ─────────────────────────────────────────────────────────────
export async function listAdminUsers() {
return getJson('/api/admin/users');
}
export async function createAdminUser(username, password, email) {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, email: email || undefined }),
});
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
return res.json();
}
export async function setUserStatus(userUid, status) {
const res = await fetch(`/api/admin/users/${userUid}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
return res.json();
}
export async function assignRole(userUid, roleSlug) {
const res = await fetch(`/api/admin/users/${userUid}/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role_slug: roleSlug }),
});
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
}
export async function removeRole(userUid, roleSlug) {
const res = await fetch(`/api/admin/users/${userUid}/roles/${encodeURIComponent(roleSlug)}`, {
method: 'DELETE',
});
if (!res.ok && res.status !== 204) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
}
export async function listRoles() {
return getJson('/api/admin/roles');
}
export async function createRole(slug, name) {
const res = await fetch('/api/admin/roles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, name }),
});
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
return res.json();
}
// ── Collection helpers ─────────────────────────────────────────────────────
export async function listCollections(archiveId) {
return getJson(`/api/archives/${archiveId}/collections`);
}
export async function createCollection(archiveId, name, slug, defaultVisibilityBits = 2) {
const res = await fetch(`/api/archives/${archiveId}/collections`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, slug, default_visibility_bits: defaultVisibilityBits }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
return res.json();
}
export async function getCollection(archiveId, collUid) {
return getJson(`/api/archives/${archiveId}/collections/${collUid}`);
}
export async function addEntryToCollection(archiveId, collUid, entryUid, visibilityBits = 2) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ entry_uid: entryUid, visibility_bits: visibilityBits }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
export async function removeEntryFromCollection(archiveId, collUid, entryUid) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries/${entryUid}`, {
method: 'DELETE',
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
export async function updateEntryVisibility(archiveId, collUid, entryUid, visibilityBits) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries/${entryUid}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ visibility_bits: visibilityBits }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
export async function listEntryCollections(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/collections`);
}
export async function updateCollection(archiveId, collUid, patch) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error ?? res.statusText) }
}
export async function deleteCollection(archiveId, collUid) {
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}`, { method: 'DELETE' })
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error ?? res.statusText) }
}
// ── 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;
};

View file

@ -1,15 +1,218 @@
import { useState, useEffect, useContext, useCallback } from 'react'
import { AuthContext } from '../App.jsx'
import {
listAdminUsers, createAdminUser, setUserStatus, assignRole, removeRole,
listRoles, createRole
} from '../api.js'
const ROLE_ADMIN = 4
export default function AdminView({ archives }) {
const { currentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && (currentUser.role_bits & ROLE_ADMIN) !== 0
const [tab, setTab] = useState('users')
const [users, setUsers] = useState([])
const [roles, setRoles] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
// Create user form state
const [newUsername, setNewUsername] = useState('')
const [newPassword, setNewPassword] = useState('')
const [newEmail, setNewEmail] = useState('')
const [createError, setCreateError] = useState(null)
const [creating, setCreating] = useState(false)
// Create role form state
const [newRoleSlug, setNewRoleSlug] = useState('')
const [newRoleName, setNewRoleName] = useState('')
const [roleCreateError, setRoleCreateError] = useState(null)
const [creatingRole, setCreatingRole] = useState(false)
const refresh = useCallback(async () => {
if (!isAdmin) return
setLoading(true)
setError(null)
try {
const [u, r] = await Promise.all([listAdminUsers(), listRoles()])
setUsers(u)
setRoles(r)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}, [isAdmin])
useEffect(() => { refresh() }, [refresh])
async function handleToggleStatus(user) {
const next = user.status === 'active' ? 'disabled' : 'active'
try {
await setUserStatus(user.user_uid, next)
setUsers(us => us.map(u => u.user_uid === user.user_uid ? { ...u, status: next } : u))
} catch (e) {
setError(e.message)
}
}
async function handleCreateUser(e) {
e.preventDefault()
if (!newUsername.trim() || !newPassword) { setCreateError('Username and password required'); return }
setCreating(true)
setCreateError(null)
try {
await createAdminUser(newUsername.trim(), newPassword, newEmail.trim() || undefined)
setNewUsername('')
setNewPassword('')
setNewEmail('')
await refresh()
} catch (e) {
setCreateError(e.message)
} finally {
setCreating(false)
}
}
async function handleCreateRole(e) {
e.preventDefault()
if (!newRoleSlug.trim() || !newRoleName.trim()) { setRoleCreateError('Slug and name required'); return }
setCreatingRole(true)
setRoleCreateError(null)
try {
await createRole(newRoleSlug.trim(), newRoleName.trim())
setNewRoleSlug('')
setNewRoleName('')
await refresh()
} catch (e) {
setRoleCreateError(e.message)
} finally {
setCreatingRole(false)
}
}
if (!isAdmin) {
return (
<section id="admin-view" className="view admin-view is-active">
<h1>Mounted Archives</h1>
<h1>Admin</h1>
<p className="muted">You need admin privileges to access this panel.</p>
<h2>Mounted Archives</h2>
<div className="admin-list">
{archives.map(archive => (
<div key={archive.id} className="admin-archive">
<strong>{archive.label}</strong>
<div className="muted">{archive.archive_path}</div>
{archives.map(a => (
<div key={a.id} className="admin-archive">
<strong>{a.label}</strong>
<div className="muted">{a.archive_path}</div>
</div>
))}
</div>
</section>
)
}
return (
<section id="admin-view" className="view admin-view is-active">
<h1>Admin</h1>
<div className="admin-tabs">
<button className={`admin-tab${tab === 'users' ? ' is-active' : ''}`} onClick={() => setTab('users')}>Users</button>
<button className={`admin-tab${tab === 'roles' ? ' is-active' : ''}`} onClick={() => setTab('roles')}>Roles</button>
<button className={`admin-tab${tab === 'archives' ? ' is-active' : ''}`} onClick={() => setTab('archives')}>Archives</button>
</div>
{error && <div className="capture-error">{error}</div>}
{tab === 'users' && (
<div className="admin-section">
<h2>Users</h2>
{loading ? <p className="muted">Loading</p> : (
<table className="admin-table">
<thead>
<tr><th>Username</th><th>Email</th><th>Roles</th><th>Status</th><th>Actions</th></tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.user_uid} className={u.status === 'disabled' ? 'admin-row-disabled' : ''}>
<td>{u.username}</td>
<td className="muted">{u.email || '—'}</td>
<td>{u.role_slugs.join(', ') || '—'}</td>
<td><span className={`status-badge status-${u.status}`}>{u.status}</span></td>
<td>
<button className="admin-action-btn" onClick={() => handleToggleStatus(u)}>
{u.status === 'active' ? 'Ban' : 'Unban'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<h3>Create User</h3>
<form className="admin-form" onSubmit={handleCreateUser}>
<input className="admin-input" placeholder="Username" value={newUsername}
onChange={e => setNewUsername(e.target.value)} required />
<input className="admin-input" type="password" placeholder="Password (min 8 chars)"
value={newPassword} onChange={e => setNewPassword(e.target.value)} required />
<input className="admin-input" type="email" placeholder="Email (optional)"
value={newEmail} onChange={e => setNewEmail(e.target.value)} />
{createError && <div className="capture-error">{createError}</div>}
<button className="capture-submit" type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create User'}
</button>
</form>
</div>
)}
{tab === 'roles' && (
<div className="admin-section">
<h2>Roles</h2>
{loading ? <p className="muted">Loading</p> : (
<table className="admin-table">
<thead>
<tr><th>Slug</th><th>Name</th><th>Level</th><th>Bit</th><th>Built-in</th></tr>
</thead>
<tbody>
{roles.map(r => (
<tr key={r.role_uid}>
<td><code>{r.slug}</code></td>
<td>{r.name}</td>
<td>{r.level}</td>
<td>{r.bit_position}</td>
<td>{r.is_builtin ? '✓' : ''}</td>
</tr>
))}
</tbody>
</table>
)}
<h3>Create Custom Role</h3>
<form className="admin-form" onSubmit={handleCreateRole}>
<input className="admin-input" placeholder="Slug (e.g. moderator)" value={newRoleSlug}
onChange={e => setNewRoleSlug(e.target.value)} required />
<input className="admin-input" placeholder="Display Name (e.g. Moderator)"
value={newRoleName} onChange={e => setNewRoleName(e.target.value)} required />
{roleCreateError && <div className="capture-error">{roleCreateError}</div>}
<button className="capture-submit" type="submit" disabled={creatingRole}>
{creatingRole ? 'Creating…' : 'Create Role'}
</button>
</form>
</div>
)}
{tab === 'archives' && (
<div className="admin-section">
<h2>Mounted Archives</h2>
<div className="admin-list">
{archives.map(a => (
<div key={a.id} className="admin-archive">
<strong>{a.label}</strong>
<div className="muted">{a.archive_path}</div>
</div>
))}
</div>
</div>
)}
</section>
)
}

View file

@ -1,16 +1,21 @@
import { useRef, useEffect, useState } from 'react'
import { submitCapture } from '../api'
import { submitCapture, pollCaptureJob } from '../api'
export default function CaptureDialog({ open, archiveId, onClose, onCaptured }) {
const dialogRef = useRef(null)
const [locator, setLocator] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [jobStatus, setJobStatus] = useState(null) // null | 'running' | 'completed' | 'failed'
const pollRef = useRef(null)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
const handleClose = () => onClose()
const handleClose = () => {
clearInterval(pollRef.current)
onClose()
}
dialog.addEventListener('close', handleClose)
return () => dialog.removeEventListener('close', handleClose)
}, [onClose])
@ -21,6 +26,9 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured })
if (open) {
setLocator('')
setError(null)
setJobStatus(null)
setBusy(false)
clearInterval(pollRef.current)
if (!dialog.open) dialog.showModal()
} else {
if (dialog.open) dialog.close()
@ -31,17 +39,47 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured })
if (!locator.trim()) { setError('Enter a locator.'); return }
setBusy(true)
setError(null)
setJobStatus(null)
try {
await submitCapture(archiveId, locator.trim())
const job = await submitCapture(archiveId, locator.trim())
setJobStatus('running')
pollRef.current = setInterval(async () => {
try {
const updated = await pollCaptureJob(archiveId, job.job_uid)
if (updated.status === 'completed') {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setJobStatus('completed')
dialogRef.current?.close()
onCaptured()
} else if (updated.status === 'failed') {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setJobStatus('failed')
setError(updated.error_text || 'Capture failed.')
}
// pending / running: keep polling
} catch (pollErr) {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setError(pollErr.message)
}
}, 500)
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
function buttonLabel() {
if (!busy) return 'Capture'
if (jobStatus === 'running') return 'Running\u2026'
return 'Capturing\u2026'
}
return (
<dialog ref={dialogRef} className="capture-dialog">
<div className="capture-dialog-inner">
@ -56,7 +94,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured })
<div className="capture-actions">
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>Cancel</button>
<button type="button" className="capture-submit" onClick={handleSubmit} disabled={busy}>
{busy ? 'Capturing\u2026' : 'Capture'}
{buttonLabel()}
</button>
</div>
</div>

View file

@ -0,0 +1,366 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import {
listCollections, createCollection, getCollection,
addEntryToCollection, removeEntryFromCollection,
updateEntryVisibility, updateCollection, deleteCollection,
} from '../api.js'
const VIS_OPTIONS = [
{ value: 0, label: 'Private' },
{ value: 2, label: 'Users only' },
{ value: 3, label: 'Public' },
]
const VIS_LABEL = v => VIS_OPTIONS.find(o => o.value === v)?.label ?? String(v)
export default function CollectionsView({ archiveId }) {
const [collections, setCollections] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [selectedUid, setSelectedUid] = useState(null)
const [collDetail, setCollDetail] = useState(null)
const [detailLoading, setDetailLoading] = useState(false)
const [detailError, setDetailError] = useState(null)
// Create form
const [newName, setNewName] = useState('')
const [newSlug, setNewSlug] = useState('')
const [newVis, setNewVis] = useState(2)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState(null)
// Add-entry form
const [addUid, setAddUid] = useState('')
const [addVis, setAddVis] = useState(2)
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState(null)
// Inline rename state
const [renaming, setRenaming] = useState(false)
const [renameVal, setRenameVal] = useState('')
const renameRef = useRef(null)
const selected = collections.find(c => c.collection_uid === selectedUid) ?? null
const isDefault = selected?.slug === '_default_'
const refreshList = useCallback(async () => {
if (!archiveId) return
setLoading(true)
setError(null)
try {
const cols = await listCollections(archiveId)
setCollections(cols)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}, [archiveId])
const refreshDetail = useCallback(async (uid) => {
if (!uid) { setCollDetail(null); return }
setDetailLoading(true)
setDetailError(null)
try {
const d = await getCollection(archiveId, uid)
setCollDetail(d)
} catch (e) {
setDetailError(e.message)
} finally {
setDetailLoading(false)
}
}, [archiveId])
useEffect(() => { refreshList() }, [refreshList])
useEffect(() => { refreshDetail(selectedUid) }, [selectedUid, refreshDetail])
// Auto-focus rename input
useEffect(() => { if (renaming && renameRef.current) renameRef.current.focus() }, [renaming])
async function handleCreate(e) {
e.preventDefault()
const name = newName.trim()
const slug = newSlug.trim()
if (!name || !slug) return
setCreating(true)
setCreateError(null)
try {
const coll = await createCollection(archiveId, name, slug, newVis)
setNewName('')
setNewSlug('')
setNewVis(2)
await refreshList()
setSelectedUid(coll.collection_uid)
} catch (err) {
setCreateError(err.message)
} finally {
setCreating(false)
}
}
async function handleRenameCommit() {
const name = renameVal.trim()
if (!name || !selected) { setRenaming(false); return }
try {
await updateCollection(archiveId, selected.collection_uid, { name })
await refreshList()
setCollDetail(d => d ? { ...d, name } : d)
} catch (e) {
setError(e.message)
} finally {
setRenaming(false)
}
}
async function handleVisChange(newVisVal) {
if (!selected) return
try {
await updateCollection(archiveId, selected.collection_uid, { default_visibility_bits: newVisVal })
await refreshList()
setCollDetail(d => d ? { ...d, default_visibility_bits: newVisVal } : d)
} catch (e) {
setError(e.message)
}
}
async function handleDelete() {
if (!selected) return
if (!window.confirm(`Delete collection "${selected.name}"? Entries will not be deleted.`)) return
try {
await deleteCollection(archiveId, selected.collection_uid)
setSelectedUid(null)
setCollDetail(null)
await refreshList()
} catch (e) {
setError(e.message)
}
}
async function handleAddEntry(e) {
e.preventDefault()
const uid = addUid.trim()
if (!uid || !selected) return
setAdding(true)
setAddError(null)
try {
await addEntryToCollection(archiveId, selected.collection_uid, uid, addVis)
setAddUid('')
await refreshDetail(selected.collection_uid)
} catch (err) {
setAddError(err.message)
} finally {
setAdding(false)
}
}
async function handleRemoveEntry(entryUid) {
if (!selected) return
try {
await removeEntryFromCollection(archiveId, selected.collection_uid, entryUid)
await refreshDetail(selected.collection_uid)
} catch (e) {
setDetailError(e.message)
}
}
async function handleEntryVisChange(entryUid, vis) {
if (!selected) return
try {
await updateEntryVisibility(archiveId, selected.collection_uid, entryUid, vis)
setCollDetail(d => d ? {
...d,
entries: d.entries.map(en =>
en.entry_uid === entryUid ? { ...en, collection_visibility_bits: vis } : en
),
} : d)
} catch (e) {
setDetailError(e.message)
}
}
if (!archiveId) return <div className="view-placeholder">Select an archive.</div>
return (
<div className="collections-view">
<h2 className="collections-heading">Collections</h2>
{loading && <div className="muted">Loading</div>}
{error && <div className="collections-error">{error} <button onClick={() => setError(null)} className="coll-dismiss">×</button></div>}
<div className="collections-layout">
{/* Sidebar */}
<div className="collections-sidebar">
{collections.map(c => (
<button
key={c.collection_uid}
className={`coll-row${selectedUid === c.collection_uid ? ' is-active' : ''}`}
onClick={() => setSelectedUid(c.collection_uid)}
>
<span className="coll-row-name">{c.name}</span>
<span className="coll-row-meta">{VIS_LABEL(c.default_visibility_bits)}</span>
</button>
))}
{collections.length === 0 && !loading && (
<div className="muted" style={{ padding: '8px 12px' }}>No collections yet.</div>
)}
</div>
{/* Detail pane */}
{selected ? (
<div className="coll-detail">
{/* Header */}
<div className="coll-detail-header">
{renaming ? (
<input
ref={renameRef}
className="coll-rename-input"
value={renameVal}
onChange={e => setRenameVal(e.target.value)}
onBlur={handleRenameCommit}
onKeyDown={e => {
if (e.key === 'Enter') handleRenameCommit()
if (e.key === 'Escape') setRenaming(false)
}}
/>
) : (
<h3
className={`coll-detail-name${isDefault ? '' : ' coll-detail-name--editable'}`}
title={isDefault ? undefined : 'Click to rename'}
onClick={() => { if (!isDefault) { setRenameVal(selected.name); setRenaming(true) } }}
>
{collDetail?.name ?? selected.name}
{!isDefault && <span className="coll-edit-hint"> </span>}
</h3>
)}
{!isDefault && (
<button className="coll-delete-btn" onClick={handleDelete} title="Delete collection">Delete</button>
)}
</div>
{/* Visibility */}
<div className="coll-detail-vis">
<span className="coll-vis-label">Default visibility</span>
<select
className="coll-vis-select"
value={collDetail?.default_visibility_bits ?? selected.default_visibility_bits}
onChange={e => handleVisChange(Number(e.target.value))}
disabled={isDefault}
>
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
{/* Entries */}
<div className="coll-entries-section">
<div className="coll-section-heading">Entries</div>
{detailLoading && <div className="muted">Loading</div>}
{detailError && <div className="collections-error">{detailError}</div>}
{!detailLoading && collDetail && (
collDetail.entries.length === 0 ? (
<div className="muted">No entries in this collection.</div>
) : (
<ul className="coll-entries-list">
{collDetail.entries.map(entry => (
<li key={entry.entry_uid} className="coll-entry-row">
<div className="coll-entry-info">
<span className="coll-entry-title">{entry.title || entry.entry_uid}</span>
<span className="coll-entry-kind muted">{entry.source_kind}</span>
</div>
<div className="coll-entry-actions">
<select
className="coll-entry-vis-select"
value={entry.collection_visibility_bits}
onChange={e => handleEntryVisChange(entry.entry_uid, Number(e.target.value))}
>
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
{!isDefault && (
<button
className="coll-entry-remove"
onClick={() => handleRemoveEntry(entry.entry_uid)}
title="Remove from collection"
>×</button>
)}
</div>
</li>
))}
</ul>
)
)}
</div>
{/* Add entry (non-default only) */}
{!isDefault && (
<form className="coll-add-entry-form" onSubmit={handleAddEntry}>
<div className="coll-section-heading">Add entry</div>
<div className="coll-add-entry-row">
<input
className="coll-add-entry-input"
type="text"
value={addUid}
onChange={e => setAddUid(e.target.value)}
placeholder="entry_uid"
required
/>
<select
className="coll-vis-select"
value={addVis}
onChange={e => setAddVis(Number(e.target.value))}
>
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<button className="coll-add-btn" type="submit" disabled={adding}>
{adding ? '…' : 'Add'}
</button>
</div>
{addError && <div className="collections-error" style={{ marginTop: 4 }}>{addError}</div>}
</form>
)}
</div>
) : (
<div className="coll-detail coll-detail--empty">
<div className="muted">Select a collection to view details.</div>
</div>
)}
</div>
{/* Create form */}
<details className="coll-create-details" style={{ marginTop: '1.5rem' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Create collection</summary>
<form onSubmit={handleCreate} style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: 400 }}>
<label>
Name
<input
className="capture-input"
type="text"
value={newName}
onChange={e => { setNewName(e.target.value); if (!newSlug) setNewSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) }}
placeholder="My Collection"
required
/>
</label>
<label>
Slug
<input
className="capture-input"
type="text"
value={newSlug}
onChange={e => setNewSlug(e.target.value)}
placeholder="my-collection"
required
/>
</label>
<label>
Default visibility
<select className="capture-input" style={{ height: 42 }} value={newVis} onChange={e => setNewVis(Number(e.target.value))}>
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
{createError && <div className="collections-error">{createError}</div>}
<button className="capture-submit" type="submit" disabled={creating} style={{ alignSelf: 'flex-start', padding: '8px 20px' }}>
{creating ? 'Creating…' : 'Create'}
</button>
</form>
</details>
</div>
)
}

View file

@ -1,11 +1,12 @@
import { useState, useEffect, useRef } from 'react'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag } from '../api'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections } from '../api'
import { formatTimestamp, formatBytes, valueText } from '../utils'
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) {
const [detail, setDetail] = useState(null)
const [tags, setTags] = useState([])
const [assignInput, setAssignInput] = useState('')
const [entryCollections, setEntryCollections] = useState([])
const [assignError, setAssignError] = useState('')
const selectSeqRef = useRef(0)
@ -13,6 +14,7 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
if (!selectedEntry || !archiveId) {
setDetail(null)
setTags([])
setEntryCollections([])
return
}
const seq = ++selectSeqRef.current
@ -21,10 +23,12 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
Promise.all([
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
fetchEntryTags(archiveId, selectedEntry.entry_uid),
]).then(([det, tgs]) => {
listEntryCollections(archiveId, selectedEntry.entry_uid),
]).then(([det, tgs, ecs]) => {
if (seq !== selectSeqRef.current) return
setDetail(det)
setTags(tgs)
setEntryCollections(ecs)
}).catch(() => {})
}, [selectedEntry, archiveId])
@ -156,6 +160,19 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
</div>
)}
</div>
{selectedEntry && entryCollections.length > 0 && (
<div className="rail-section">
<div className="rail-section-heading">Collections</div>
{entryCollections.map(c => (
<div key={c.collection_uid} className="rail-item">
<span className="rail-label">{c.collection_uid}</span>:{' '}
<span className="muted">
{{ 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }[c.visibility_bits] ?? `bits:${c.visibility_bits}`}
</span>
</div>
))}
</div>
)}
</>
)}
</aside>

View 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>
);
}

View file

@ -0,0 +1,271 @@
import { useState, useEffect, useContext, useCallback } from 'react'
import { AuthContext } from '../App.jsx'
import {
updateProfile, changePassword,
listTokens, createToken, deleteToken,
getInstanceSettings, updateInstanceSettings,
} from '../api.js'
const ROLE_ADMIN = 4
const ROLE_OWNER = 8
export default function SettingsView() {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
const [tab, setTab] = useState('profile')
return (
<section className="admin-view">
<h1>Settings</h1>
<div className="admin-tabs" style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
{['profile', 'tokens', ...(isAdmin ? ['instance'] : [])].map(t => (
<button key={t}
className={`nav-link${tab === t ? ' is-active' : ''}`}
style={{ borderBottom: tab === t ? '2px solid var(--accent)' : undefined, color: 'var(--ink)' }}
onClick={() => setTab(t)}>
{t === 'profile' ? 'Profile' : t === 'tokens' ? 'API Tokens' : 'Instance'}
</button>
))}
</div>
{tab === 'profile' && <ProfileTab currentUser={currentUser} setCurrentUser={setCurrentUser} />}
{tab === 'tokens' && <TokensTab />}
{tab === 'instance' && isAdmin && <InstanceTab />}
</section>
)
}
function ProfileTab({ currentUser, setCurrentUser }) {
const [displayName, setDisplayName] = useState(currentUser?.display_name ?? '')
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState(null)
const [curPw, setCurPw] = useState('')
const [newPw, setNewPw] = useState('')
const [confirmPw, setConfirmPw] = useState('')
const [pwSaving, setPwSaving] = useState(false)
const [pwMsg, setPwMsg] = useState(null)
async function handleSaveProfile(e) {
e.preventDefault()
setSaving(true)
setSaveMsg(null)
try {
await updateProfile(displayName)
setCurrentUser(u => ({ ...u, display_name: displayName || null }))
setSaveMsg({ ok: true, text: 'Saved.' })
} catch (err) {
setSaveMsg({ ok: false, text: err.message })
} finally {
setSaving(false)
}
}
async function handleChangePassword(e) {
e.preventDefault()
if (newPw !== confirmPw) { setPwMsg({ ok: false, text: 'Passwords do not match.' }); return }
setPwSaving(true)
setPwMsg(null)
try {
await changePassword(curPw, newPw)
setCurPw(''); setNewPw(''); setConfirmPw('')
setPwMsg({ ok: true, text: 'Password changed.' })
} catch (err) {
setPwMsg({ ok: false, text: err.message })
} finally {
setPwSaving(false)
}
}
return (
<div style={{ maxWidth: 480 }}>
<div className="admin-section">
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Display Name</h2>
<form className="admin-form" onSubmit={handleSaveProfile}>
<input className="admin-input" placeholder={currentUser?.username ?? ''}
value={displayName} onChange={e => setDisplayName(e.target.value)} />
{saveMsg && (
<div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>
)}
<button className="capture-submit" type="submit" disabled={saving}>
{saving ? 'Saving\u2026' : 'Save'}
</button>
</form>
</div>
<div className="admin-section" style={{ marginTop: 28 }}>
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Change Password</h2>
<form className="admin-form" onSubmit={handleChangePassword}>
<input className="admin-input" type="password" placeholder="Current password"
value={curPw} onChange={e => setCurPw(e.target.value)} required />
<input className="admin-input" type="password" placeholder="New password"
value={newPw} onChange={e => setNewPw(e.target.value)} required />
<input className="admin-input" type="password" placeholder="Confirm new password"
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} required />
{pwMsg && (
<div className={pwMsg.ok ? 'muted' : 'capture-error'}>{pwMsg.text}</div>
)}
<button className="capture-submit" type="submit" disabled={pwSaving}>
{pwSaving ? 'Changing\u2026' : 'Change Password'}
</button>
</form>
</div>
</div>
)
}
function TokensTab() {
const [tokens, setTokens] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [newToken, setNewToken] = useState(null) // { raw_token, name }
const refresh = useCallback(async () => {
setLoading(true); setError(null)
try { setTokens(await listTokens()) }
catch (e) { setError(e.message) }
finally { setLoading(false) }
}, [])
useEffect(() => { refresh() }, [refresh])
async function handleCreate(e) {
e.preventDefault()
if (!newName.trim()) return
setCreating(true)
try {
const tok = await createToken(newName.trim())
setNewToken(tok)
setNewName('')
refresh()
} catch (err) {
setError(err.message)
} finally {
setCreating(false)
}
}
async function handleDelete(tokenUid) {
try {
await deleteToken(tokenUid)
setTokens(ts => ts.filter(t => t.token_uid !== tokenUid))
} catch (err) {
setError(err.message)
}
}
return (
<div style={{ maxWidth: 640 }}>
<h2 style={{ fontSize: 15, marginBottom: 12 }}>API Tokens</h2>
{newToken && (
<div style={{ background: '#e8f5e9', border: '1px solid #a5d6a7', padding: '12px 14px', marginBottom: 16, fontSize: 13 }}>
<strong>Token created.</strong> Copy it now \u2014 it will not be shown again.<br />
<code style={{ wordBreak: 'break-all', display: 'block', marginTop: 6, padding: '6px 8px', background: '#f5f5f5', border: '1px solid #ddd' }}>
{newToken.raw_token}
</code>
<button style={{ marginTop: 8, fontSize: 12, border: '1px solid #aaa', background: 'none', cursor: 'pointer' }}
onClick={() => setNewToken(null)}>Dismiss</button>
</div>
)}
<form className="admin-form" onSubmit={handleCreate} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input className="admin-input" placeholder="Token name" value={newName}
onChange={e => setNewName(e.target.value)} style={{ flex: 1 }} required />
<button className="capture-submit" type="submit" disabled={creating}>
{creating ? 'Creating\u2026' : 'Create'}
</button>
</form>
{error && <div className="capture-error">{error}</div>}
{loading ? <div className="muted">Loading\u2026</div> : (
<div className="admin-list">
{tokens.length === 0 && <div className="muted">No tokens yet.</div>}
{tokens.map(tok => (
<div key={tok.token_uid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
border: '1px solid var(--line)', background: 'var(--paper-3)', padding: '10px 12px' }}>
<div>
<strong style={{ fontSize: 14 }}>{tok.name}</strong>
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
Created {tok.created_at.slice(0, 10)}
{tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`}
</div>
</div>
<button onClick={() => handleDelete(tok.token_uid)}
style={{ border: '1px solid var(--line)', background: 'none', cursor: 'pointer',
color: 'var(--accent)', fontSize: 12, padding: '4px 10px' }}>
Revoke
</button>
</div>
))}
</div>
)}
</div>
)
}
function InstanceTab() {
const [settings, setSettings] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState(null)
useEffect(() => {
(async () => {
try { setSettings(await getInstanceSettings()) }
catch (e) { setError(e.message) }
finally { setLoading(false) }
})()
}, [])
async function handleSave(e) {
e.preventDefault()
setSaving(true); setSaveMsg(null)
try {
await updateInstanceSettings(settings)
setSaveMsg({ ok: true, text: 'Saved.' })
} catch (err) {
setSaveMsg({ ok: false, text: err.message })
} finally {
setSaving(false)
}
}
if (loading) return <div className="muted">Loading\u2026</div>
if (error) return <div className="capture-error">{error}</div>
if (!settings) return null
return (
<div style={{ maxWidth: 480 }}>
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Instance Settings</h2>
<form onSubmit={handleSave}>
<div className="admin-section">
{[
['public_index_enabled', 'Public index (unauthenticated browsing)'],
['public_entry_content_enabled', 'Public entry content'],
['open_registration_enabled', 'Open registration'],
].map(([key, label]) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, cursor: 'pointer' }}>
<input type="checkbox" checked={!!settings[key]}
onChange={e => setSettings(s => ({ ...s, [key]: e.target.checked }))} />
{label}
</label>
))}
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Default entry visibility</label>
<select className="admin-input" value={settings.default_entry_visibility}
onChange={e => setSettings(s => ({ ...s, default_entry_visibility: Number(e.target.value) }))}>
<option value={0}>Private (0)</option>
<option value={2}>Unlisted (2)</option>
<option value={3}>Public (3)</option>
</select>
</div>
</div>
{saveMsg && <div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>}
<button className="capture-submit" type="submit" disabled={saving}>
{saving ? 'Saving\u2026' : 'Save Settings'}
</button>
</form>
</div>
)
}

View 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>
);
}

View file

@ -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>
@ -7,7 +21,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
</select>
<nav className="nav" aria-label="Primary">
{['archive', 'runs', 'admin', 'tags'].map(name => (
{['archive', 'runs', 'admin', 'tags', 'collections', 'settings'].map(name => (
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
onClick={() => onViewChange(name)}>
{name.charAt(0).toUpperCase() + name.slice(1)}
@ -15,6 +29,15 @@ 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.display_name || currentUser.username}</span>
<button className="nav-link" onClick={() => onViewChange('settings')} style={{ color: '#d7cdbf', fontSize: 13 }}>Settings</button>
<button onClick={handleLogout} disabled={loggingOut} className="logout-btn">
{loggingOut ? 'Logging out\u2026' : 'Log out'}
</button>
</div>
)}
</header>
)
);
}

View file

@ -640,3 +640,291 @@ select {
opacity: 0.45;
cursor: default;
}
/* ── Collections view ─────────────────────────────────────────────────────── */
.collections-view {
padding: 24px;
}
.collections-heading {
margin: 0 0 16px;
font-size: 22px;
font-family: Georgia, "Times New Roman", serif;
}
.collections-error {
color: var(--accent);
font-size: 13px;
margin-bottom: 8px;
}
.coll-dismiss {
background: none;
border: none;
cursor: pointer;
color: var(--accent);
font-size: 16px;
line-height: 1;
padding: 0 4px;
}
.collections-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 0;
border: 1px solid var(--line);
min-height: 340px;
}
.collections-sidebar {
border-right: 1px solid var(--line);
overflow-y: auto;
}
.coll-row {
display: flex;
flex-direction: column;
width: 100%;
padding: 10px 14px;
border: none;
border-bottom: 1px solid var(--line-soft);
background: none;
cursor: pointer;
text-align: left;
gap: 2px;
}
.coll-row:hover {
background: var(--paper-2);
}
.coll-row.is-active {
background: var(--paper-2);
border-left: 3px solid var(--accent);
}
.coll-row-name {
font-size: 14px;
font-weight: 600;
color: var(--ink);
}
.coll-row-meta {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.coll-detail {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.coll-detail--empty {
justify-content: center;
align-items: center;
}
.coll-detail-header {
display: flex;
align-items: center;
gap: 12px;
}
.coll-detail-name {
margin: 0;
font-size: 18px;
font-family: Georgia, "Times New Roman", serif;
flex: 1;
}
.coll-detail-name--editable {
cursor: pointer;
}
.coll-detail-name--editable:hover {
color: var(--link);
}
.coll-edit-hint {
font-size: 14px;
opacity: 0.5;
margin-left: 4px;
}
.coll-rename-input {
flex: 1;
font-size: 16px;
font-family: Georgia, "Times New Roman", serif;
border: 2px solid var(--ink);
padding: 4px 8px;
background: var(--paper-3);
color: var(--ink);
}
.coll-delete-btn {
border: 1px solid var(--accent);
background: none;
color: var(--accent);
padding: 5px 12px;
font-size: 13px;
cursor: pointer;
}
.coll-delete-btn:hover {
background: var(--accent);
color: var(--paper);
}
.coll-detail-vis {
display: flex;
align-items: center;
gap: 10px;
}
.coll-vis-label {
font-size: 13px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.coll-vis-select {
border: 1px solid var(--line);
background: var(--paper-3);
color: var(--ink);
padding: 4px 8px;
font-size: 13px;
}
.coll-section-heading {
font-size: 11px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 8px;
}
.coll-entries-section {
flex: 1;
}
.coll-entries-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.coll-entry-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid var(--line-soft);
}
.coll-entry-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.coll-entry-title {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.coll-entry-kind {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.coll-entry-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.coll-entry-vis-select {
border: 1px solid var(--line);
background: var(--paper-3);
color: var(--ink);
padding: 3px 6px;
font-size: 12px;
}
.coll-entry-remove {
border: none;
background: none;
cursor: pointer;
color: var(--muted);
font-size: 18px;
line-height: 1;
padding: 0 4px;
}
.coll-entry-remove:hover {
color: var(--accent);
}
.coll-add-entry-form {
border-top: 1px solid var(--line-soft);
padding-top: 12px;
}
.coll-add-entry-row {
display: flex;
gap: 8px;
align-items: center;
}
.coll-add-entry-input {
flex: 1;
border: 1px solid var(--line);
background: var(--paper-3);
color: var(--ink);
padding: 6px 10px;
font-size: 13px;
min-width: 0;
}
.coll-add-btn {
border: none;
background: var(--ink);
color: var(--paper);
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.coll-add-btn:hover {
opacity: 0.85;
}
.coll-add-btn:disabled {
opacity: 0.45;
cursor: default;
}
.coll-create-details summary {
font-weight: 600;
cursor: pointer;
}