1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

Compare commits

..

No commits in common. "46ad816c4c055c5370ee694d3f883f5526b91833" and "ab353f9209f38578952b768b5bd78b396c7b0645" have entirely different histories.

38 changed files with 309 additions and 7855 deletions

2
.gitignore vendored
View file

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

190
Cargo.lock generated
View file

@ -125,13 +125,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"archivr-core",
"argon2",
"axum",
"axum-extra",
"base64",
"chrono",
"rand",
"rusqlite",
"serde",
"serde_json",
"tempfile",
@ -141,18 +135,6 @@ 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"
@ -231,57 +213,18 @@ 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"
@ -378,17 +321,6 @@ 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"
@ -434,12 +366,6 @@ 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"
@ -448,7 +374,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@ -1054,23 +979,6 @@ 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"
@ -1088,12 +996,6 @@ 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"
@ -1158,17 +1060,6 @@ 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"
@ -1196,21 +1087,6 @@ 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"
@ -1235,36 +1111,6 @@ 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"
@ -1567,12 +1413,6 @@ 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"
@ -1656,36 +1496,6 @@ 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,6 +29,3 @@ 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
View file

@ -1,225 +0,0 @@
# 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, None)?;
let result = archivr_core::capture::perform_capture(&archive_paths, path)?;
println!("Archived: run {}", result.run_uid);
Ok(())
}

View file

@ -60,17 +60,6 @@ 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,
@ -85,15 +74,6 @@ 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 {
@ -193,7 +173,7 @@ pub fn initialize_store_directories(store_path: &Path) -> Result<()> {
Ok(())
}
pub fn list_root_entries(conn: &rusqlite::Connection, caller_bits: u32) -> Result<Vec<EntrySummary>> {
pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary>> {
let mut stmt = conn.prepare(
"SELECT
e.entry_uid,
@ -212,20 +192,12 @@ pub fn list_root_entries(conn: &rusqlite::Connection, caller_bits: u32) -> Resul
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([caller_bits as i64], |row| {
.query_map([], |row| {
Ok(EntrySummary {
entry_uid: row.get(0)?,
archived_at: row.get(1)?,
@ -269,7 +241,7 @@ pub fn get_entry_detail(
return Ok(None);
};
let summary = list_root_entries(conn, u32::MAX)?
let summary = list_root_entries(conn)?
.into_iter()
.find(|entry| entry.entry_uid == entry_uid)
.context("entry disappeared while loading detail")?;
@ -327,110 +299,6 @@ 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`).
@ -456,7 +324,7 @@ pub fn resolve_artifact_path(
Ok(canonical_artifact)
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct SearchEntriesQuery {
/// Free-text term: LIKE-matched against title, canonical_url, entry_uid, source_kind, entity_kind, visibility
pub q: Option<String>,
@ -474,25 +342,6 @@ 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`].
@ -623,15 +472,6 @@ 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)?;
@ -983,7 +823,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, u32::MAX).unwrap();
let entries = list_root_entries(&conn).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].title.as_deref(), Some("Saved Article"));
assert_eq!(entries[0].artifact_count, 1);
@ -1154,7 +994,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, u32::MAX).unwrap();
let all = list_root_entries(&conn).unwrap();
let searched = search_entries(&conn, &SearchEntriesQuery::default()).unwrap();
assert_eq!(all.len(), searched.len());
}

View file

@ -332,6 +332,9 @@ 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())
}
@ -672,11 +675,7 @@ fn fail_run(
anyhow::anyhow!("{}", message)
}
pub fn perform_capture(
archive_paths: &ArchivePaths,
locator: &str,
archive_id: Option<&str>,
) -> Result<CaptureResult> {
pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<CaptureResult> {
let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S%.3f").to_string();
let store_path = &archive_paths.store_path;
@ -791,32 +790,13 @@ pub fn perform_capture(
.join("temp")
.join(&timestamp)
.join(format!("{timestamp}{file_extension}"));
// 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![])
};
let byte_size = fs::metadata(&temp_html)
.with_context(|| format!("failed to stat staged file {}", temp_html.display()))?
.len() as i64;
// 1. Move HTML to raw store (if this hash hasn't been seen before).
if !hash_exists(&html_hash, &file_extension, store_path)? {
move_temp_to_raw(&temp_html, &html_hash, store_path)?;
if !hash_exists(&result.html_hash, &file_extension, store_path)? {
move_temp_to_raw(&temp_html, &result.html_hash, store_path)?;
}
// 2. Process favicon while the temp dir still exists.
@ -857,7 +837,7 @@ pub fn perform_capture(
locator,
locator,
source,
&html_hash,
&result.html_hash,
&file_extension,
byte_size,
result.title,
@ -879,31 +859,6 @@ pub fn perform_capture(
);
}
// 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(),
@ -1064,16 +1019,6 @@ pub fn perform_capture(
})
}
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

@ -1,187 +0,0 @@
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,4 +5,3 @@ pub mod ytdlp;
pub mod metadata;
pub mod http;
pub mod singlefile;
pub mod font_extractor;

View file

@ -63,16 +63,6 @@ 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)
@ -82,11 +72,6 @@ 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,56 +1,17 @@
use anyhow::Result;
use sha3::{Digest, Sha3_256};
use std::path::Path;
use std::{fs::File, io::Read, path::Path};
/// 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(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))
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()))
}
/// 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);
}
pub fn hash(path: String) -> String {
let mut hasher = Sha3_256::new();
hasher.update(path.as_bytes());
let result = hasher.finalize();
hex::encode(result)
}

View file

@ -12,14 +12,8 @@ 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

@ -1,127 +0,0 @@
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,4 +1,3 @@
mod auth;
mod registry;
mod routes;
@ -13,45 +12,10 @@ 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());
// 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;
}
});
// Bind address priority: ARCHIVR_BIND env var > TOML bind field > default loopback.
let bind_str = std::env::var("ARCHIVR_BIND")
.ok()
.or_else(|| registry.bind.clone())
@ -61,6 +25,15 @@ 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,13 +16,10 @@ pub struct MountedArchive {
pub struct ServerRegistry {
#[serde(default)]
pub archives: Vec<MountedArchive>,
/// Optional bind address. Defaults to `127.0.0.1:8080`.
/// 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.
#[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> {
@ -46,16 +43,19 @@ pub fn save_registry(path: &Path, registry: &ServerRegistry) -> Result<()> {
}
pub fn validate_registry(registry: &ServerRegistry) -> Result<()> {
let mut seen_ids = std::collections::HashSet::new();
let mut ids = std::collections::HashSet::new();
for archive in &registry.archives {
if archive.id.trim().is_empty() {
bail!("archive id must not be empty");
}
if !seen_ids.insert(archive.id.clone()) {
if !ids.insert(archive.id.as_str()) {
bail!("duplicate archive id: {}", archive.id);
}
if archive.label.trim().is_empty() {
bail!("archive label must not be empty for id={}", archive.id);
if !archive.archive_path.ends_with(".archivr") {
bail!(
"mounted archive path must point at a .archivr directory: {}",
archive.archive_path.display()
);
}
}
Ok(())
@ -84,7 +84,6 @@ 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();
@ -110,7 +109,6 @@ mod tests {
},
],
bind: None,
auth_db_path: None,
};
let err = validate_registry(&registry).unwrap_err().to_string();
@ -138,7 +136,6 @@ 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-C6h0jcH3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D40QFUPh.css">
<script type="module" crossorigin src="/assets/index-CcezQ0kg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJpQthbx.css">
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load diff

View file

@ -1,403 +0,0 @@
# 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,7 +73,6 @@
cargoLock
nativeBuildInputs
;
buildInputs = [ pkgs.openssl ];
cargoBuildFlags = [
"-p"
"archivr-cli"
@ -91,7 +90,6 @@
cargoLock
nativeBuildInputs
;
buildInputs = [ pkgs.openssl ];
cargoBuildFlags = [
"-p"
"archivr-server"

View file

@ -1,41 +1,14 @@
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 { useState, useEffect, useCallback, useRef } from 'react'
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags } from './api'
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([])
@ -154,89 +127,77 @@ 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}
archiveId={archiveId}
onArchiveChange={handleArchiveChange}
view={view}
onViewChange={handleViewChange}
onCaptureClick={handleCaptureClick}
/>
<main className="app-shell">
<div className="workspace">
{view === 'archive' && (
<div className="search-row">
<input
className="search-input"
type="search"
aria-label="Search archive"
aria-busy={searchBusy}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
<div className="result-count">
{resultCount}
{tagFilter && (
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
× {tagFilter}
</button>
)}
</div>
<>
<Topbar
archives={archives}
archiveId={archiveId}
onArchiveChange={handleArchiveChange}
view={view}
onViewChange={handleViewChange}
onCaptureClick={handleCaptureClick}
/>
<main className="app-shell">
<div className="workspace">
{view === 'archive' && (
<div className="search-row">
<input
className="search-input"
type="search"
aria-label="Search archive"
aria-busy={searchBusy}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
<div className="result-count">
{resultCount}
{tagFilter && (
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
× {tagFilter}
</button>
)}
</div>
)}
{view === 'archive' && (
<EntriesView
entries={entries}
selectedEntryUid={selectedEntryUid}
onSelectEntry={selectEntry}
archiveId={archiveId}
tagFilter={tagFilter}
onClearTagFilter={handleClearTagFilter}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
resultCount={resultCount}
searchBusy={searchBusy}
/>
)}
{view === 'runs' && <RunsView runs={runs} />}
{view === 'admin' && <AdminView archives={archives} />}
{view === 'tags' && (
<TagsView
tagNodes={tagNodes}
tagFilter={tagFilter}
onTagFilterSet={handleTagFilterSet}
onViewChange={handleViewChange}
/>
)}
{view === 'collections' && (
<CollectionsView archiveId={archiveId} />
)}
{view === 'settings' && (
<SettingsView />
)}
</div>
<ContextRail
archiveId={archiveId}
selectedEntry={selectedEntry}
onTagFilterSet={handleTagFilterSet}
tagNodes={tagNodes}
onTagsRefresh={handleTagsRefresh}
/>
</main>
<CaptureDialog
open={captureDialogOpen}
</div>
)}
{view === 'archive' && (
<EntriesView
entries={entries}
selectedEntryUid={selectedEntryUid}
onSelectEntry={selectEntry}
archiveId={archiveId}
tagFilter={tagFilter}
onClearTagFilter={handleClearTagFilter}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
resultCount={resultCount}
searchBusy={searchBusy}
/>
)}
{view === 'runs' && <RunsView runs={runs} />}
{view === 'admin' && <AdminView archives={archives} />}
{view === 'tags' && (
<TagsView
tagNodes={tagNodes}
tagFilter={tagFilter}
onTagFilterSet={handleTagFilterSet}
onViewChange={handleViewChange}
/>
)}
</div>
<ContextRail
archiveId={archiveId}
onClose={handleCaptureClose}
onCaptured={handleCaptured}
selectedEntry={selectedEntry}
onTagFilterSet={handleTagFilterSet}
tagNodes={tagNodes}
onTagsRefresh={handleTagsRefresh}
/>
</>
</AuthContext.Provider>
</main>
<CaptureDialog
open={captureDialogOpen}
archiveId={archiveId}
onClose={handleCaptureClose}
onCaptured={handleCaptured}
/>
</>
)
}

View file

@ -64,250 +64,7 @@ export async function submitCapture(archiveId, locator) {
body: JSON.stringify({ locator }),
});
if (!res.ok) {
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);
const msg = await res.text();
throw new Error(msg || `HTTP ${res.status}`);
}
}
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,218 +1,15 @@
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>Admin</h1>
<p className="muted">You need admin privileges to access this panel.</p>
<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>
</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>
))}
<h1>Mounted Archives</h1>
<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>
</div>
</div>
)}
))}
</div>
</section>
)
}

View file

@ -1,21 +1,16 @@
import { useRef, useEffect, useState } from 'react'
import { submitCapture, pollCaptureJob } from '../api'
import { submitCapture } 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 = () => {
clearInterval(pollRef.current)
onClose()
}
const handleClose = () => onClose()
dialog.addEventListener('close', handleClose)
return () => dialog.removeEventListener('close', handleClose)
}, [onClose])
@ -26,9 +21,6 @@ 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()
@ -39,47 +31,17 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured })
if (!locator.trim()) { setError('Enter a locator.'); return }
setBusy(true)
setError(null)
setJobStatus(null)
try {
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)
await submitCapture(archiveId, locator.trim())
dialogRef.current?.close()
onCaptured()
} 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">
@ -94,7 +56,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}>
{buttonLabel()}
{busy ? 'Capturing\u2026' : 'Capture'}
</button>
</div>
</div>

View file

@ -1,366 +0,0 @@
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,12 +1,11 @@
import { useState, useEffect, useRef } from 'react'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections } from '../api'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag } 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)
@ -14,7 +13,6 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
if (!selectedEntry || !archiveId) {
setDetail(null)
setTags([])
setEntryCollections([])
return
}
const seq = ++selectSeqRef.current
@ -23,12 +21,10 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
Promise.all([
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
fetchEntryTags(archiveId, selectedEntry.entry_uid),
listEntryCollections(archiveId, selectedEntry.entry_uid),
]).then(([det, tgs, ecs]) => {
]).then(([det, tgs]) => {
if (seq !== selectSeqRef.current) return
setDetail(det)
setTags(tgs)
setEntryCollections(ecs)
}).catch(() => {})
}, [selectedEntry, archiveId])
@ -160,19 +156,6 @@ 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

@ -1,54 +0,0 @@
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

@ -1,271 +0,0 @@
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

@ -1,73 +0,0 @@
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,18 +1,4 @@
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>
@ -21,7 +7,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', 'collections', 'settings'].map(name => (
{['archive', 'runs', 'admin', 'tags'].map(name => (
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
onClick={() => onViewChange(name)}>
{name.charAt(0).toUpperCase() + name.slice(1)}
@ -29,15 +15,6 @@ 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,291 +640,3 @@ 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;
}