mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
Compare commits
78 commits
ab353f9209
...
46ad816c4c
| Author | SHA1 | Date | |
|---|---|---|---|
| 46ad816c4c | |||
| 91e73d85e7 | |||
| ae92448fa3 | |||
| 80dce80033 | |||
| c4ce2129bb | |||
| 383cec1f81 | |||
| 533e9d5d2e | |||
| 96eaf50609 | |||
| d558ce2a3f | |||
| ea0a8f80d1 | |||
| 44f18f4dcc | |||
| 78e763c377 | |||
| 677e5c439b | |||
| dabe014e7e | |||
| 69af2d2060 | |||
| 4d0daf2dc0 | |||
| 915878ff10 | |||
| 1b91e7ef5e | |||
| cb390dfaab | |||
| 763cb8e17f | |||
| dd56fc8c70 | |||
| 24c5321f6e | |||
| 02cab149fa | |||
| ab054f6256 | |||
| 2c3d3eb86e | |||
| b3b0c8ec8d | |||
| dcbced3590 | |||
| 2335776347 | |||
| 6357cc518e | |||
| ffecb72230 | |||
| 48da356eee | |||
| 2042752177 | |||
| ff3d20ae04 | |||
| 9f7b3c912a | |||
| 116b30e0e9 | |||
| 5e31a4188c | |||
| da2b58ef96 | |||
| cfd51778b1 | |||
| 27b37c3b6d | |||
| d86a4d92a8 | |||
| 9ac28459ed | |||
| f76f5438f2 | |||
| 7cebf06124 | |||
| 37fd057c1f | |||
| 7671049f55 | |||
| 8edd17d694 | |||
| a983b6090a | |||
| 59a37a2ce3 | |||
| 059196252b | |||
| 64706cb976 | |||
| 6fd5e8881e | |||
| c806b0bbaf | |||
| 0a3cde9005 | |||
| 98bb5f03b0 | |||
| 2e2ce4b6ac | |||
| 8ed79192eb | |||
| 1bbc0a108c | |||
| f9d2ac80e7 | |||
| cb376aa986 | |||
| 36c7d575e2 | |||
| affb8aadc0 | |||
| db28d038d9 | |||
| a4239ba5fc | |||
| 86e8049f1b | |||
| f482707b75 | |||
| 696a5e1ac7 | |||
| 7fcc207de7 | |||
| 0a5c899c5e | |||
| 57fc48d73c | |||
| decaef389b | |||
| 7584e309de | |||
| d81b8fd793 | |||
| 151835258b | |||
| 10aeebd878 | |||
| 5fba6987a3 | |||
| 351e74df09 | |||
| 22b0a55730 | |||
| f6d636fce2 |
38 changed files with 7859 additions and 313 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,7 +5,6 @@
|
|||
!docs/
|
||||
!docs/LICENSE
|
||||
!docs/README*
|
||||
|
||||
!crates
|
||||
!crates/**
|
||||
|
||||
|
|
@ -18,6 +17,7 @@
|
|||
!Cargo.toml
|
||||
!Cargo.lock
|
||||
!ARCHIVR-MENTAL-MODEL.md
|
||||
!NEXT.md
|
||||
|
||||
!frontend/
|
||||
!frontend/**
|
||||
|
|
|
|||
190
Cargo.lock
generated
190
Cargo.lock
generated
|
|
@ -125,7 +125,13 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"archivr-core",
|
||||
"argon2",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"base64",
|
||||
"chrono",
|
||||
"rand",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
|
@ -135,6 +141,18 @@ dependencies = [
|
|||
"tower-http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
|
|
@ -213,18 +231,57 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-extra"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"fastrand",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"multer",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
|
|
@ -321,6 +378,17 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
|
|
@ -366,6 +434,12 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
|
|
@ -374,6 +448,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -979,6 +1054,23 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
|
|
@ -996,6 +1088,12 @@ dependencies = [
|
|||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -1060,6 +1158,17 @@ dependencies = [
|
|||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
|
@ -1087,6 +1196,21 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.101"
|
||||
|
|
@ -1111,6 +1235,36 @@ version = "5.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.2"
|
||||
|
|
@ -1413,6 +1567,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
|
@ -1496,6 +1656,36 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.51"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
|
|
|
|||
|
|
@ -29,3 +29,6 @@ tower-http = { version = "0.6.2", features = ["fs", "trace"] }
|
|||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
base64 = "0.22"
|
||||
argon2 = { version = "0.5", features = ["std"] }
|
||||
rand = { version = "0.8", features = ["std"] }
|
||||
axum-extra = { version = "0.9", features = ["cookie"] }
|
||||
|
|
|
|||
225
NEXT.md
Normal file
225
NEXT.md
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
# Archivr Next Work Plan
|
||||
|
||||
> **For future agentic workers:** Read `ARCHIVR-MENTAL-MODEL.md` first, then this file.
|
||||
> All six tracks from the previous handoff are complete. This document is the current roadmap.
|
||||
|
||||
## Current State
|
||||
|
||||
All five product tracks from the prior session are done:
|
||||
|
||||
| Feature | Status |
|
||||
|---|---|
|
||||
| Entry detail + artifact serving | Done |
|
||||
| Server-side search with prefix filters | Done |
|
||||
| Hierarchical tags (CRUD, tree, entry assignment, filter) | Done |
|
||||
| Browser capture button and POST endpoint | Done |
|
||||
| Local-only auth boundary, bind address config, docs | Done |
|
||||
|
||||
The frontend is a React + Vite app (`frontend/`) that builds into `crates/archivr-server/static/`.
|
||||
|
||||
The remaining gaps come from `docs/README.md` milestones that were never in the prior tracks.
|
||||
|
||||
---
|
||||
|
||||
## Remaining Work (in order)
|
||||
|
||||
### ~~1. Generic URL capture — plain file download~~ ✅ Done
|
||||
|
||||
**Implemented:** `crates/archivr-core/src/downloader/http.rs` (`download` fn, extension helpers,
|
||||
HTML rejection), `Source::Url` variant in `capture.rs`, `determine_source` routes all unmatched
|
||||
`http://`/`https://` URLs to `Source::Url`, `perform_capture` arm calls the downloader and flows
|
||||
through the existing temp → hash → raw store pipeline. `reqwest 0.12` (blocking) added to workspace.
|
||||
`docs/README.md` "URLs" milestone checked off. 112 tests green.
|
||||
|
||||
---
|
||||
|
||||
### ~~2. Web page archiving — single-file HTML snapshots~~ ✅ Done
|
||||
|
||||
**Implemented:** `crates/archivr-core/src/downloader/singlefile.rs` (new module; shells out to
|
||||
`single-file-cli` via `ARCHIVR_SINGLE_FILE` env var; launches headless Chromium via
|
||||
`ARCHIVR_CHROME`; flags: `--browser-wait-until=networkidle2`, `--remove-unused-styles=false`,
|
||||
`--block-scripts=false`, `--remove-alternative-medias=false` for full CSS/font fidelity).
|
||||
`Source::WebPage` variant added to `capture.rs`; HEAD-first probe (`probe_url_kind` in `http.rs`)
|
||||
routes `text/html` responses to `Source::WebPage` and everything else to `Source::Url`.
|
||||
Font deduplication implemented as a follow-on: `font_extractor.rs` post-processes the saved HTML,
|
||||
extracts embedded `@font-face` base64 blobs, stores each as a deduplicated artifact in the raw
|
||||
store, and rewrites the HTML to reference them via `/api/archives/:id/blobs/:sha256`.
|
||||
`hash_bytes` added to `hash.rs`; `get_blob_by_sha256` added to `database.rs`;
|
||||
`GET /api/archives/:id/blobs/:sha256` route added to `routes.rs`.
|
||||
`flake.nix` updated: `pkgs.single-file-cli` and (Linux-only) `pkgs.chromium` added to
|
||||
`buildInputs`; `ARCHIVR_SINGLE_FILE` and `ARCHIVR_CHROME` env vars wired into both wrappers.
|
||||
`docs/README.md` "Archive web pages" milestone checked off. 143 tests green.
|
||||
|
||||
---
|
||||
|
||||
### 3. Async capture jobs
|
||||
|
||||
**What:** Capture currently runs synchronously on the HTTP request thread. A YouTube video,
|
||||
a large tweet thread, or a slow monolith page will stall the browser request. This track
|
||||
adds a job queue so `POST /api/archives/:id/captures` returns immediately and the UI polls
|
||||
for completion.
|
||||
|
||||
**Where to edit:**
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `crates/archivr-core/src/database.rs` | Add `capture_jobs` table to the schema: `(id, job_uid, run_uid, archive_id, status TEXT CHECK(status IN ('pending','running','completed','failed')), error_text, created_at, updated_at)`. Add `create_capture_job`, `update_capture_job_status`, `get_capture_job` helpers. |
|
||||
| `crates/archivr-core/src/archive.rs` | Add `CaptureJob` summary type and `get_capture_job` query. |
|
||||
| `crates/archivr-server/src/routes.rs` | `POST /api/archives/:id/captures` → insert job row, spawn `tokio::task::spawn_blocking` for the capture call, return `{ job_uid, status: "pending" }` immediately. Add `GET /api/archives/:id/capture_jobs/:job_uid` route. |
|
||||
| `frontend/src/api.js` | Add `pollCaptureJob(archiveId, jobUid)` |
|
||||
| `frontend/src/components/CaptureDialog.jsx` | After submit: enter polling loop (500 ms interval), show "Running…" state, handle `completed` (close + refresh) and `failed` (show error). |
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `POST /captures` returns within 50 ms regardless of capture duration.
|
||||
- UI shows "Running…" and updates when the job finishes.
|
||||
- Failed captures surface the error message in the dialog.
|
||||
- A server restart while a job is `running` leaves it stalled as `running`; on next startup,
|
||||
jobs stuck in `running` should be marked `failed` with a note ("interrupted by server restart").
|
||||
- Existing `cargo test` passes.
|
||||
|
||||
**Key risk:** `spawn_blocking` runs on a Tokio thread pool thread. A very long capture
|
||||
(15-minute YouTube video) will occupy one thread for the duration. For now that is acceptable.
|
||||
A proper queue (channel + worker task) can replace it later without changing the DB schema or API.
|
||||
|
||||
---
|
||||
|
||||
### ~~4. Auth foundation — session + role + setup~~ ✅ Done
|
||||
|
||||
**Implemented:** `crates/archivr-server/src/auth.rs` (new; `AuthUser` extractor, Argon2id password
|
||||
hashing, token generation, role bit constants). `crates/archivr-core/src/database.rs`:
|
||||
`initialize_auth_schema` (roles, user_roles, sessions, api_tokens, instance_settings tables);
|
||||
`open_auth_db` (separate server-level auth SQLite); `create_owner`, `compute_role_bits`,
|
||||
`create_session`, `get_session`, `create_api_token`, `get_user_for_token` and related helpers.
|
||||
`crates/archivr-server/src/routes.rs`: `AppState` gains `auth_db_path`; `setup_guard` middleware
|
||||
returns 503 until owner created; `POST /api/auth/login`, `POST /api/auth/logout`,
|
||||
`GET /api/auth/me`, `GET|POST /api/auth/setup`, `GET|POST|DELETE /api/auth/tokens` endpoints;
|
||||
WRITE routes (captures, tags) guarded by `ROLE_USER`. `crates/archivr-server/src/main.rs`:
|
||||
computes auth DB path from config directory; session cleanup background task.
|
||||
Frontend: `LoginPage.jsx`, `SetupPage.jsx`, `AuthContext` in `App.jsx`, user menu in `Topbar.jsx`.
|
||||
`docs/specs/2026-06-25-auth-foundation-design.md` has the full design. Roles use a bitmask:
|
||||
guest=1, user=2, admin=4, owner=8. All tests green.
|
||||
|
||||
---
|
||||
|
||||
### ~~5. User management~~ ✅ Done
|
||||
|
||||
**Implemented:** `crates/archivr-core/src/database.rs`: `UserSummary` and `RoleRecord` structs;
|
||||
10 new pub functions (`invalidate_user_sessions`, `get_user_id_by_uid`, `list_users`,
|
||||
`get_user_by_uid`, `create_user`, `set_user_status`, `assign_role`, `remove_role`, `list_roles`,
|
||||
`create_custom_role`). `crates/archivr-server/src/routes.rs`: 5 admin routes
|
||||
(`GET|POST /api/admin/users`, `PATCH /api/admin/users/:uid/status`,
|
||||
`POST|DELETE /api/admin/users/:uid/roles`, `GET|POST /api/admin/roles`); 7 handler functions;
|
||||
request body structs. `frontend/src/api.js`: 7 admin helpers (`listAdminUsers`,
|
||||
`createAdminUser`, `setUserStatus`, `assignRole`, `removeRole`, `listRoles`, `createRole`).
|
||||
`frontend/src/components/AdminView.jsx`: two-tab admin panel (Users / Roles) with ban/unban,
|
||||
create user form, create custom role form. Role bitmask: guest=1, user=2, admin=4, owner=8;
|
||||
custom roles get bit_position≥4. Ban invalidates all sessions. Only-owner guard on role removal.
|
||||
169 tests green.
|
||||
|
||||
---
|
||||
|
||||
### ~~6. Permissions & visibility — collection model~~ ✅ Done
|
||||
|
||||
**Implemented:** `database.rs`: `collections` + `collection_entries` tables; seed default collection ('All Entries');
|
||||
migration inserts existing entries into default collection with `visibility_bits` derived from legacy `visibility` string
|
||||
(`'public'`→3, `'unlisted'`→2, `'private'`→0). 8 new pub functions (`ensure_default_collection`, `create_collection`,
|
||||
`list_collections`, `get_collection_by_uid`, `add_entry_to_collection`, `update_collection_entry_visibility`,
|
||||
`remove_entry_from_collection`, `get_entry_collection_memberships`). `create_archived_entry` auto-enrolls new entries
|
||||
into the default collection. `archive.rs`: `list_root_entries` + `search_entries` accept `caller_bits: u32` and enforce
|
||||
collection visibility (`admin/owner bypass`; others filtered by `visibility_bits & caller_bits`). `list_entries_for_collection`,
|
||||
`get_entry_collections`, `EntryCollectionMembership` added. `routes.rs`: read routes extract auth and pass caller_bits;
|
||||
5 collection-entry route groups (`GET|POST /api/archives/:id/collections`, `GET /api/archives/:id/collections/:uid`,
|
||||
`POST|DELETE|PATCH /api/archives/:id/collections/:uid/entries(/:entry_uid)`,
|
||||
`GET /api/archives/:id/entries/:uid/collections`). Frontend: `CollectionsView.jsx` (list + detail + create form);
|
||||
`collections` nav item in Topbar; entry collection memberships + visibility shown in ContextRail. 169 tests green.
|
||||
Depends on Track 5.
|
||||
|
||||
---
|
||||
|
||||
### ~~7. Settings~~ ✅ Done
|
||||
|
||||
**Implemented:** `database.rs`: `display_name TEXT` column migration (ALTER TABLE, idempotent);
|
||||
`InstanceSettings` struct; 6 new pub fns (`get_instance_settings`, `update_instance_settings`,
|
||||
`update_user_display_name`, `update_user_password`, `get_user_password_hash`,
|
||||
`get_user_display_name`). `routes.rs`: `PATCH /api/auth/me` (display name update + current-password-
|
||||
verified password change); `GET|PATCH /api/admin/instance-settings` (ROLE_ADMIN);
|
||||
`auth_me` now returns `display_name`. `frontend/src/api.js`: 7 new helpers (`updateProfile`,
|
||||
`changePassword`, `listTokens`, `createToken`, `deleteToken`, `getInstanceSettings`,
|
||||
`updateInstanceSettings`). `frontend/src/components/SettingsView.jsx`: tabbed settings page
|
||||
(Profile — display name + password change; API Tokens — create/list/revoke with one-time reveal;
|
||||
Instance — public index, public content, open registration toggles + default visibility select).
|
||||
`Topbar.jsx`: settings nav button; user-menu shows display_name ?? username.
|
||||
`App.jsx`: renders SettingsView for `view === 'settings'`. 176 tests green. Depends on Track 5.
|
||||
|
||||
---
|
||||
|
||||
### 8. Collections UI
|
||||
|
||||
**What:** Full collection management — create, rename, delete, add/remove entries,
|
||||
set per-entry visibility within a collection, make collections public.
|
||||
Depends on Tracks 5–6.
|
||||
|
||||
---
|
||||
|
||||
### 9. Cloud backup — S3-compatible
|
||||
|
||||
**What:** A command or scheduled operation that syncs the archive store to an S3-compatible
|
||||
bucket (AWS S3, Cloudflare R2, Backblaze B2). Incremental: only uploads blobs not already
|
||||
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 1–4 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.
|
||||
```
|
||||
|
|
@ -63,7 +63,7 @@ fn main() -> Result<()> {
|
|||
}
|
||||
};
|
||||
let archive_paths = archive::read_archive_paths(&archive_path)?;
|
||||
let result = archivr_core::capture::perform_capture(&archive_paths, path)?;
|
||||
let result = archivr_core::capture::perform_capture(&archive_paths, path, None)?;
|
||||
println!("Archived: run {}", result.run_uid);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,17 @@ pub struct RunSummary {
|
|||
pub error_summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct CaptureJobSummary {
|
||||
pub job_uid: String,
|
||||
pub archive_id: String,
|
||||
pub run_uid: Option<String>,
|
||||
pub status: String,
|
||||
pub error_text: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct Tag {
|
||||
pub tag_uid: String,
|
||||
|
|
@ -74,6 +85,15 @@ pub struct TagNode {
|
|||
pub children: Vec<TagNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct CollectionSummary {
|
||||
pub collection_uid: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub default_visibility_bits: u32,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
pub fn find_archive_path_from(start: &Path) -> Result<Option<PathBuf>> {
|
||||
let mut dir = start.to_path_buf();
|
||||
loop {
|
||||
|
|
@ -173,7 +193,7 @@ pub fn initialize_store_directories(store_path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary>> {
|
||||
pub fn list_root_entries(conn: &rusqlite::Connection, caller_bits: u32) -> Result<Vec<EntrySummary>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
e.entry_uid,
|
||||
|
|
@ -192,12 +212,20 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary
|
|||
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id
|
||||
LEFT JOIN blobs b ON b.id = ea.blob_id
|
||||
WHERE e.parent_entry_id IS NULL
|
||||
AND (
|
||||
CAST(?1 AS INTEGER) & 12 != 0
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM collection_entries ce
|
||||
WHERE ce.entry_id = e.id
|
||||
AND ce.visibility_bits & CAST(?1 AS INTEGER) != 0
|
||||
)
|
||||
)
|
||||
GROUP BY e.id
|
||||
ORDER BY e.archived_at DESC, e.id DESC",
|
||||
)?;
|
||||
|
||||
let entries = stmt
|
||||
.query_map([], |row| {
|
||||
.query_map([caller_bits as i64], |row| {
|
||||
Ok(EntrySummary {
|
||||
entry_uid: row.get(0)?,
|
||||
archived_at: row.get(1)?,
|
||||
|
|
@ -241,7 +269,7 @@ pub fn get_entry_detail(
|
|||
return Ok(None);
|
||||
};
|
||||
|
||||
let summary = list_root_entries(conn)?
|
||||
let summary = list_root_entries(conn, u32::MAX)?
|
||||
.into_iter()
|
||||
.find(|entry| entry.entry_uid == entry_uid)
|
||||
.context("entry disappeared while loading detail")?;
|
||||
|
|
@ -299,6 +327,110 @@ pub fn list_runs(conn: &rusqlite::Connection) -> Result<Vec<RunSummary>> {
|
|||
Ok(runs)
|
||||
}
|
||||
|
||||
pub fn get_capture_job(
|
||||
conn: &rusqlite::Connection,
|
||||
job_uid: &str,
|
||||
) -> Result<Option<CaptureJobSummary>> {
|
||||
Ok(database::get_capture_job(conn, job_uid)?.map(|r| CaptureJobSummary {
|
||||
job_uid: r.job_uid,
|
||||
archive_id: r.archive_id,
|
||||
run_uid: r.run_uid,
|
||||
status: r.status,
|
||||
error_text: r.error_text,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Lists all collections in the archive.
|
||||
pub fn list_collections(conn: &rusqlite::Connection) -> Result<Vec<CollectionSummary>> {
|
||||
let records = database::list_collections(conn)?;
|
||||
Ok(records
|
||||
.into_iter()
|
||||
.map(|r| CollectionSummary {
|
||||
collection_uid: r.collection_uid,
|
||||
name: r.name,
|
||||
slug: r.slug,
|
||||
default_visibility_bits: r.default_visibility_bits,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Represents an entry's membership in a collection with its visibility bits.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct EntryCollectionMembership {
|
||||
pub collection_uid: String,
|
||||
pub visibility_bits: u32,
|
||||
}
|
||||
|
||||
/// Returns collection memberships for the given entry_uid.
|
||||
/// Returns Ok(None) if the entry_uid does not exist.
|
||||
pub fn get_entry_collections(
|
||||
conn: &rusqlite::Connection,
|
||||
entry_uid: &str,
|
||||
) -> Result<Option<Vec<EntryCollectionMembership>>> {
|
||||
let Some(entry_id) = conn
|
||||
.query_row(
|
||||
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
|
||||
[entry_uid],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.optional()?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let memberships = database::get_entry_collection_memberships(conn, entry_id)?;
|
||||
Ok(Some(
|
||||
memberships
|
||||
.into_iter()
|
||||
.map(|(_, uid, bits)| EntryCollectionMembership {
|
||||
collection_uid: uid,
|
||||
visibility_bits: bits,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Returns entries belonging to a collection, filtered by caller_bits visibility.
|
||||
/// Caller with admin/owner bits (4|8) sees all entries regardless of visibility_bits.
|
||||
pub fn list_entries_for_collection(
|
||||
conn: &rusqlite::Connection,
|
||||
collection_id: i64,
|
||||
caller_bits: u32,
|
||||
) -> Result<Vec<EntrySummary>> {
|
||||
let sql = format!(
|
||||
"{} {} \
|
||||
JOIN collection_entries ce ON ce.entry_id = e.id \
|
||||
WHERE ce.collection_id = ?1 \
|
||||
AND (CAST(?2 AS INTEGER) & 12 != 0 \
|
||||
OR (ce.visibility_bits & CAST(?2 AS INTEGER)) != 0) \
|
||||
GROUP BY e.id \
|
||||
ORDER BY e.archived_at DESC, e.id DESC",
|
||||
ENTRY_SELECT_COLS,
|
||||
ENTRY_FROM_JOINS,
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries = stmt
|
||||
.query_map([collection_id, caller_bits as i64], |row| {
|
||||
Ok(EntrySummary {
|
||||
entry_uid: row.get(0)?,
|
||||
archived_at: row.get(1)?,
|
||||
source_kind: row.get(2)?,
|
||||
entity_kind: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
visibility: row.get(5)?,
|
||||
original_url: row.get(6)?,
|
||||
artifact_count: row.get(7)?,
|
||||
total_artifact_bytes: row.get(8)?,
|
||||
parent_entry_uid: row.get(9)?,
|
||||
has_favicon: row.get::<_, i64>(10)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Resolves an artifact to its absolute on-disk path under `store_path`.
|
||||
///
|
||||
/// `artifact.relpath` is a store-relative path (e.g. `raw/a/b/abc.pdf`).
|
||||
|
|
@ -324,7 +456,7 @@ pub fn resolve_artifact_path(
|
|||
Ok(canonical_artifact)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchEntriesQuery {
|
||||
/// Free-text term: LIKE-matched against title, canonical_url, entry_uid, source_kind, entity_kind, visibility
|
||||
pub q: Option<String>,
|
||||
|
|
@ -342,6 +474,25 @@ pub struct SearchEntriesQuery {
|
|||
pub before: Option<String>,
|
||||
/// Tag full_path filter; includes all entries (root + child) matching the tag subtree
|
||||
pub tag: Option<String>,
|
||||
/// Role bits of the caller for visibility filtering. Admins (bits 4/8) bypass all filters.
|
||||
/// Pass `u32::MAX` internally to bypass all visibility. Pass 0 for unauthenticated guests only.
|
||||
pub caller_bits: u32,
|
||||
}
|
||||
|
||||
impl Default for SearchEntriesQuery {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
q: None,
|
||||
source_kind: None,
|
||||
entity_kind: None,
|
||||
url: None,
|
||||
title: None,
|
||||
after: None,
|
||||
before: None,
|
||||
tag: None,
|
||||
caller_bits: u32::MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a raw search string into a [`SearchEntriesQuery`].
|
||||
|
|
@ -472,6 +623,15 @@ pub fn search_entries(
|
|||
params.push(b.clone());
|
||||
}
|
||||
|
||||
// Visibility filter
|
||||
let n = params.len() + 1;
|
||||
sql.push_str(&format!(
|
||||
" AND (CAST(?{n} AS INTEGER) & 12 != 0 \
|
||||
OR EXISTS (SELECT 1 FROM collection_entries ce \
|
||||
WHERE ce.entry_id = e.id AND ce.visibility_bits & CAST(?{n} AS INTEGER) != 0))"
|
||||
));
|
||||
params.push(query.caller_bits.to_string());
|
||||
|
||||
sql.push_str(" GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC");
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
|
|
@ -823,7 +983,7 @@ mod tests {
|
|||
database::complete_archive_run_item(&conn, item.id, entry.id).unwrap();
|
||||
database::finish_archive_run(&conn, run.id).unwrap();
|
||||
|
||||
let entries = list_root_entries(&conn).unwrap();
|
||||
let entries = list_root_entries(&conn, u32::MAX).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].title.as_deref(), Some("Saved Article"));
|
||||
assert_eq!(entries[0].artifact_count, 1);
|
||||
|
|
@ -994,7 +1154,7 @@ mod tests {
|
|||
#[test]
|
||||
fn search_empty_query_returns_all_root_entries() {
|
||||
let conn = make_test_db_with_entries();
|
||||
let all = list_root_entries(&conn).unwrap();
|
||||
let all = list_root_entries(&conn, u32::MAX).unwrap();
|
||||
let searched = search_entries(&conn, &SearchEntriesQuery::default()).unwrap();
|
||||
assert_eq!(all.len(), searched.len());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -332,9 +332,6 @@ fn determine_source(path: &str) -> Source {
|
|||
|
||||
fn hash_exists(hash: &str, file_extension: &str, store_path: &Path) -> Result<bool> {
|
||||
let path = store_path.join(raw_relative_path_from_hash(hash, file_extension)?);
|
||||
|
||||
println!("Checking {}", path.display());
|
||||
|
||||
Ok(path.exists())
|
||||
}
|
||||
|
||||
|
|
@ -675,7 +672,11 @@ fn fail_run(
|
|||
anyhow::anyhow!("{}", message)
|
||||
}
|
||||
|
||||
pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<CaptureResult> {
|
||||
pub fn perform_capture(
|
||||
archive_paths: &ArchivePaths,
|
||||
locator: &str,
|
||||
archive_id: Option<&str>,
|
||||
) -> Result<CaptureResult> {
|
||||
let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S%.3f").to_string();
|
||||
let store_path = &archive_paths.store_path;
|
||||
|
||||
|
|
@ -790,13 +791,32 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<Ca
|
|||
.join("temp")
|
||||
.join(×tamp)
|
||||
.join(format!("{timestamp}{file_extension}"));
|
||||
let byte_size = fs::metadata(&temp_html)
|
||||
.with_context(|| format!("failed to stat staged file {}", temp_html.display()))?
|
||||
|
||||
// Font extraction: rewrite the HTML in-place before hashing.
|
||||
// Only runs when archive_id is known (server context). CLI passes
|
||||
// None and keeps fonts embedded — no behaviour change for CLI.
|
||||
let (html_hash, byte_size, extracted_fonts) =
|
||||
if let Some(aid) = archive_id {
|
||||
let content = fs::read_to_string(&temp_html)
|
||||
.with_context(|| format!("failed to read {}", temp_html.display()))?;
|
||||
let (rewritten, fonts) =
|
||||
downloader::font_extractor::extract_and_rewrite(&content, store_path, aid)
|
||||
.unwrap_or_else(|_| (content.clone(), vec![])); // non-fatal
|
||||
fs::write(&temp_html, rewritten.as_bytes())
|
||||
.with_context(|| "failed to write rewritten HTML")?;
|
||||
let size = rewritten.len() as i64;
|
||||
let new_hash = crate::hash::hash_bytes(rewritten.as_bytes());
|
||||
(new_hash, size, fonts)
|
||||
} else {
|
||||
let size = fs::metadata(&temp_html)
|
||||
.with_context(|| format!("failed to stat {}", temp_html.display()))?
|
||||
.len() as i64;
|
||||
(result.html_hash.clone(), size, vec![])
|
||||
};
|
||||
|
||||
// 1. Move HTML to raw store (if this hash hasn't been seen before).
|
||||
if !hash_exists(&result.html_hash, &file_extension, store_path)? {
|
||||
move_temp_to_raw(&temp_html, &result.html_hash, store_path)?;
|
||||
if !hash_exists(&html_hash, &file_extension, store_path)? {
|
||||
move_temp_to_raw(&temp_html, &html_hash, store_path)?;
|
||||
}
|
||||
|
||||
// 2. Process favicon while the temp dir still exists.
|
||||
|
|
@ -837,7 +857,7 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<Ca
|
|||
locator,
|
||||
locator,
|
||||
source,
|
||||
&result.html_hash,
|
||||
&html_hash,
|
||||
&file_extension,
|
||||
byte_size,
|
||||
result.title,
|
||||
|
|
@ -859,6 +879,31 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<Ca
|
|||
);
|
||||
}
|
||||
|
||||
// 6. Register each extracted font as a deduplicated blob + artifact.
|
||||
for font in extracted_fonts {
|
||||
let font_blob = database::BlobRecord {
|
||||
sha256: font.sha256.clone(),
|
||||
byte_size: font.byte_size,
|
||||
mime_type: mime_for_font_ext(&font.ext),
|
||||
extension: font.ext.strip_prefix('.').map(|s| s.to_string()),
|
||||
raw_relpath: font.raw_relpath.clone(),
|
||||
};
|
||||
if let Ok(blob_id) = database::upsert_blob(&conn, &font_blob) {
|
||||
let _ = database::add_entry_artifact(
|
||||
&conn,
|
||||
&database::NewArtifact {
|
||||
entry_id: entry.id,
|
||||
artifact_role: "font".to_string(),
|
||||
storage_area: "raw".to_string(),
|
||||
relpath: font.raw_relpath,
|
||||
blob_id: Some(blob_id),
|
||||
logical_path: None,
|
||||
metadata_json: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
database::finish_archive_run(&conn, run.id)?;
|
||||
return Ok(CaptureResult {
|
||||
run_uid: run.run_uid.clone(),
|
||||
|
|
@ -1019,6 +1064,16 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<Ca
|
|||
})
|
||||
}
|
||||
|
||||
fn mime_for_font_ext(ext: &str) -> Option<String> {
|
||||
match ext {
|
||||
".woff2" => Some("font/woff2".to_string()),
|
||||
".woff" => Some("font/woff".to_string()),
|
||||
".ttf" => Some("font/ttf".to_string()),
|
||||
".otf" => Some("font/otf".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
187
crates/archivr-core/src/downloader/font_extractor.rs
Normal file
187
crates/archivr-core/src/downloader/font_extractor.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
use anyhow::Result;
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine as _;
|
||||
use regex::Regex;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use crate::hash::hash_bytes;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExtractedFont {
|
||||
/// SHA3-256 hex of the raw font bytes.
|
||||
pub sha256: String,
|
||||
/// File extension including leading dot, e.g. `".woff2"`.
|
||||
pub ext: String,
|
||||
/// Size in bytes of the decoded font.
|
||||
pub byte_size: i64,
|
||||
/// Store-relative path, e.g. `"raw/a/b/abc...woff2"`.
|
||||
pub raw_relpath: String,
|
||||
}
|
||||
|
||||
/// Scans `html` for `@font-face` `src` attributes containing `data:font/...;base64,...`
|
||||
/// URLs, decodes each font, stores it in `{store_path}/raw/`, and rewrites the src
|
||||
/// to `/api/archives/{archive_id}/blobs/{sha256}`.
|
||||
///
|
||||
/// Returns `(rewritten_html, Vec<ExtractedFont>)`. Each occurrence is reported
|
||||
/// individually; the caller deduplicates via `upsert_blob`.
|
||||
pub fn extract_and_rewrite(
|
||||
html: &str,
|
||||
store_path: &Path,
|
||||
archive_id: &str,
|
||||
) -> Result<(String, Vec<ExtractedFont>)> {
|
||||
// Matches: url(data:font/MIME;base64,DATA) or url("data:font/MIME;base64,DATA")
|
||||
// Also matches data:application/font-... for older MIME types.
|
||||
// Group 2 captures the full MIME type (e.g. "font/woff2"), group 3 the base64 payload.
|
||||
let re = Regex::new(
|
||||
r#"url\("?(data:((?:font|application)/[^;]+);base64,([A-Za-z0-9+/=]+))"?\)"#,
|
||||
)?;
|
||||
|
||||
let mut fonts = Vec::new();
|
||||
let rewritten = re.replace_all(html, |caps: ®ex::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");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,3 +5,4 @@ pub mod ytdlp;
|
|||
pub mod metadata;
|
||||
pub mod http;
|
||||
pub mod singlefile;
|
||||
pub mod font_extractor;
|
||||
|
|
|
|||
|
|
@ -63,6 +63,16 @@ fn save_with(
|
|||
)
|
||||
.context("failed to write single-file user script")?;
|
||||
|
||||
// Chrome's user-data-dir for this capture. Required alongside
|
||||
// --disable-web-security — newer Chromium silently ignores that flag
|
||||
// without a writable user-data-dir. Using a subdirectory of temp_dir
|
||||
// keeps it isolated and it gets cleaned up with the rest of the temp dir.
|
||||
let chrome_data_dir = temp_dir.join("chrome-data");
|
||||
let browser_args = format!(
|
||||
"[\"--disable-web-security\",\"--user-data-dir={}\"]",
|
||||
chrome_data_dir.display()
|
||||
);
|
||||
|
||||
let out = Command::new(single_file)
|
||||
.arg(url)
|
||||
.arg(&out_file)
|
||||
|
|
@ -72,6 +82,11 @@ fn save_with(
|
|||
// Extra delay after networkidle2: Cloudflare Fonts injects @font-face
|
||||
// CSS after HTML parse, so the font hook needs more time to see it.
|
||||
.arg("--browser-wait-delay=2000")
|
||||
// Realistic UA: some origins block headless Chrome's default UA string.
|
||||
.arg("--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
|
||||
// Chrome-level flags: disable CORS so fonts from any CDN origin can be
|
||||
// read and inlined (e.g. fonts.gstatic.com without ACAO:*).
|
||||
.arg(format!("--browser-args={browser_args}"))
|
||||
// Preserve all CSS: single-file's defaults strip rules it considers
|
||||
// "unused" (breaks CSS nesting) and remove @media blocks that don't
|
||||
// match the capture viewport (breaks responsive layout).
|
||||
|
|
|
|||
|
|
@ -1,17 +1,56 @@
|
|||
use anyhow::Result;
|
||||
use sha3::{Digest, Sha3_256};
|
||||
use std::{fs::File, io::Read, path::Path};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn hash_file(path: &Path) -> Result<String> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)?;
|
||||
Ok(hash(String::from_utf8_lossy(&buf).to_string()))
|
||||
}
|
||||
|
||||
pub fn hash(path: String) -> String {
|
||||
/// Computes the SHA3-256 hex digest of `data` directly on raw bytes.
|
||||
pub fn hash_bytes(data: &[u8]) -> String {
|
||||
let mut hasher = Sha3_256::new();
|
||||
hasher.update(path.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
hex::encode(result)
|
||||
hasher.update(data);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Reads `path` fully and returns its SHA3-256 hex digest.
|
||||
/// Uses raw bytes — correct for both text and binary files.
|
||||
pub fn hash_file(path: &Path) -> Result<String> {
|
||||
let buf = std::fs::read(path)?;
|
||||
Ok(hash_bytes(&buf))
|
||||
}
|
||||
|
||||
/// Hashes an arbitrary string (used for legacy callers; prefer `hash_bytes`).
|
||||
pub fn hash(s: String) -> String {
|
||||
hash_bytes(s.as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_bytes_and_hash_file_agree_on_same_data() {
|
||||
let data = b"hello archivr fonts";
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::write(tmp.path(), data).unwrap();
|
||||
assert_eq!(hash_bytes(data), hash_file(tmp.path()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_bytes_is_stable() {
|
||||
let h = hash_bytes(b"archivr");
|
||||
assert_eq!(h.len(), 64);
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_bytes_differs_from_hash_of_lossy_utf8_for_binary_data() {
|
||||
let binary = b"\xff\xfe\xfd";
|
||||
let direct = hash_bytes(binary);
|
||||
let lossy = String::from_utf8_lossy(binary).to_string();
|
||||
let via_old_path = {
|
||||
use sha3::{Digest, Sha3_256};
|
||||
let mut h = Sha3_256::new();
|
||||
h.update(lossy.as_bytes());
|
||||
hex::encode(h.finalize())
|
||||
};
|
||||
assert_ne!(direct, via_old_path);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,14 @@ tokio.workspace = true
|
|||
toml.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
argon2.workspace = true
|
||||
rand.workspace = true
|
||||
axum-extra.workspace = true
|
||||
chrono.workspace = true
|
||||
base64.workspace = true
|
||||
serde_json.workspace = true
|
||||
rusqlite.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
tower.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
127
crates/archivr-server/src/auth.rs
Normal file
127
crates/archivr-server/src/auth.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
use anyhow::Result;
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use argon2::password_hash::{SaltString, rand_core::OsRng};
|
||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use rand::RngCore;
|
||||
|
||||
use crate::routes::{ApiError, AppState};
|
||||
use archivr_core::database;
|
||||
|
||||
// Role bit constants
|
||||
pub const ROLE_GUEST: u32 = 1; // bit 0
|
||||
pub const ROLE_USER: u32 = 2; // bit 1
|
||||
pub const ROLE_ADMIN: u32 = 4; // bit 2
|
||||
pub const ROLE_OWNER: u32 = 8; // bit 3
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AuthUser {
|
||||
Guest,
|
||||
Authenticated { user_id: i64, role_bits: u32 },
|
||||
}
|
||||
|
||||
impl AuthUser {
|
||||
pub fn require_auth(&self) -> Result<(i64, u32), ApiError> {
|
||||
match self {
|
||||
AuthUser::Authenticated { user_id, role_bits } => Ok((*user_id, *role_bits)),
|
||||
AuthUser::Guest => Err(ApiError::unauthorized("login required")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require_role(&self, bit: u32) -> Result<(), ApiError> {
|
||||
match self {
|
||||
AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0 => Ok(()),
|
||||
AuthUser::Authenticated { .. } => Err(ApiError::forbidden("insufficient permissions")),
|
||||
AuthUser::Guest => Err(ApiError::unauthorized("login required")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_role(&self, bit: u32) -> bool {
|
||||
matches!(self, AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, std::convert::Infallible> {
|
||||
let auth_db_path = state.auth_db_path.as_ref();
|
||||
|
||||
// 1. Try session cookie
|
||||
let jar = CookieJar::from_headers(&parts.headers);
|
||||
if let Some(cookie) = jar.get("session") {
|
||||
let session_uid = cookie.value().to_string();
|
||||
if let Ok(conn) = database::open_auth_db(auth_db_path) {
|
||||
if let Ok(Some(session)) = database::get_session(&conn, &session_uid) {
|
||||
// Conditional touch: only update if >60s since last_seen_at
|
||||
let should_touch = chrono::DateTime::parse_from_rfc3339(&session.last_seen_at)
|
||||
.map(|last| {
|
||||
chrono::Utc::now() - last.with_timezone(&chrono::Utc)
|
||||
> chrono::Duration::seconds(60)
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if should_touch {
|
||||
let _ = database::touch_session(&conn, &session_uid);
|
||||
}
|
||||
return Ok(AuthUser::Authenticated {
|
||||
user_id: session.user_id,
|
||||
role_bits: session.role_bits,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try Bearer token
|
||||
if let Some(auth_header) = parts.headers.get("Authorization") {
|
||||
if let Ok(header_str) = auth_header.to_str() {
|
||||
if let Some(raw_token) = header_str.strip_prefix("Bearer ") {
|
||||
let token_hash = hash_token(raw_token);
|
||||
if let Ok(conn) = database::open_auth_db(auth_db_path) {
|
||||
if let Ok(Some(user_id)) = database::get_user_for_token(&conn, &token_hash) {
|
||||
if let Ok(role_bits) = database::compute_role_bits(&conn, user_id) {
|
||||
return Ok(AuthUser::Authenticated { user_id, role_bits });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AuthUser::Guest)
|
||||
}
|
||||
}
|
||||
|
||||
// Password helpers
|
||||
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("password hashing failed: {e}"))?
|
||||
.to_string();
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||
let parsed = PasswordHash::new(hash)
|
||||
.map_err(|e| anyhow::anyhow!("invalid password hash: {e}"))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
// Token helpers
|
||||
|
||||
pub fn generate_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes)
|
||||
}
|
||||
|
||||
pub fn hash_token(raw: &str) -> String {
|
||||
archivr_core::hash::hash_bytes(raw.as_bytes())
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
mod auth;
|
||||
mod registry;
|
||||
mod routes;
|
||||
|
||||
|
|
@ -12,10 +13,45 @@ async fn main() -> Result<()> {
|
|||
.nth(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("archivr-server.toml"));
|
||||
let registry = registry::load_registry(&config_path)?;
|
||||
let app = routes::app(registry.clone());
|
||||
|
||||
// Bind address priority: ARCHIVR_BIND env var > TOML bind field > default loopback.
|
||||
let registry = registry::load_registry(&config_path)?;
|
||||
|
||||
// Auth DB lives next to the config file unless overridden in the TOML.
|
||||
let auth_db_path = registry.auth_db_path.clone().unwrap_or_else(|| {
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join("archivr-auth.sqlite")
|
||||
});
|
||||
|
||||
let app = routes::app(registry.clone(), auth_db_path.clone());
|
||||
|
||||
// On startup, mark any jobs that were 'running' when the server last stopped as 'failed'.
|
||||
for archive in ®istry.archives {
|
||||
if let Ok(conn) = archivr_core::database::open_or_initialize(&archive.archive_path) {
|
||||
match archivr_core::database::fail_stalled_capture_jobs(&conn) {
|
||||
Ok(n) if n > 0 => eprintln!("info: marked {n} stalled capture job(s) as failed in '{}'", archive.id),
|
||||
Err(e) => eprintln!("warn: stalled job cleanup failed for '{}': {e:#}", archive.id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn session cleanup: runs at startup and every 24h.
|
||||
let cleanup_auth_path = auth_db_path.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if let Ok(conn) = archivr_core::database::open_auth_db(&cleanup_auth_path) {
|
||||
match archivr_core::database::delete_expired_sessions(&conn) {
|
||||
Ok(n) if n > 0 => eprintln!("info: cleaned up {n} expired sessions"),
|
||||
Err(e) => eprintln!("warn: session cleanup failed: {e:#}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(24 * 60 * 60)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let bind_str = std::env::var("ARCHIVR_BIND")
|
||||
.ok()
|
||||
.or_else(|| registry.bind.clone())
|
||||
|
|
@ -25,15 +61,6 @@ async fn main() -> Result<()> {
|
|||
.parse()
|
||||
.with_context(|| format!("invalid bind address: {bind_str}"))?;
|
||||
|
||||
// Warn when the server is reachable beyond localhost — it has no authentication.
|
||||
if !addr.ip().is_loopback() {
|
||||
eprintln!(
|
||||
"warn: archivr-server is bound to {addr} — \
|
||||
this server has no authentication. \
|
||||
Only expose it on a trusted network."
|
||||
);
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
println!("archivr-server listening on http://{addr}");
|
||||
axum::serve(listener, app).await?;
|
||||
|
|
|
|||
|
|
@ -16,10 +16,13 @@ pub struct MountedArchive {
|
|||
pub struct ServerRegistry {
|
||||
#[serde(default)]
|
||||
pub archives: Vec<MountedArchive>,
|
||||
/// Optional bind address for the server. Defaults to `127.0.0.1:8080`.
|
||||
/// Set this to `0.0.0.0:8080` only on trusted networks — the server has no authentication.
|
||||
/// Optional bind address. Defaults to `127.0.0.1:8080`.
|
||||
#[serde(default)]
|
||||
pub bind: Option<String>,
|
||||
/// Path to the server-level auth database.
|
||||
/// Defaults to `archivr-auth.sqlite` in the same directory as the config file.
|
||||
#[serde(default)]
|
||||
pub auth_db_path: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
pub fn load_registry(path: &Path) -> Result<ServerRegistry> {
|
||||
|
|
@ -43,19 +46,16 @@ pub fn save_registry(path: &Path, registry: &ServerRegistry) -> Result<()> {
|
|||
}
|
||||
|
||||
pub fn validate_registry(registry: &ServerRegistry) -> Result<()> {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
for archive in ®istry.archives {
|
||||
if archive.id.trim().is_empty() {
|
||||
bail!("archive id must not be empty");
|
||||
}
|
||||
if !ids.insert(archive.id.as_str()) {
|
||||
if !seen_ids.insert(archive.id.clone()) {
|
||||
bail!("duplicate archive id: {}", archive.id);
|
||||
}
|
||||
if !archive.archive_path.ends_with(".archivr") {
|
||||
bail!(
|
||||
"mounted archive path must point at a .archivr directory: {}",
|
||||
archive.archive_path.display()
|
||||
);
|
||||
if archive.label.trim().is_empty() {
|
||||
bail!("archive label must not be empty for id={}", archive.id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -84,6 +84,7 @@ mod tests {
|
|||
archive_path: archive_path.clone(),
|
||||
}],
|
||||
bind: None,
|
||||
auth_db_path: None,
|
||||
};
|
||||
let path = temp.path().join("server.toml");
|
||||
save_registry(&path, ®istry).unwrap();
|
||||
|
|
@ -109,6 +110,7 @@ mod tests {
|
|||
},
|
||||
],
|
||||
bind: None,
|
||||
auth_db_path: None,
|
||||
};
|
||||
|
||||
let err = validate_registry(®istry).unwrap_err().to_string();
|
||||
|
|
@ -136,6 +138,7 @@ mod tests {
|
|||
let registry = ServerRegistry {
|
||||
archives: vec![],
|
||||
bind: Some("0.0.0.0:8080".to_string()),
|
||||
auth_db_path: None,
|
||||
};
|
||||
// validate_registry does not reject non-loopback bind — that's main's concern.
|
||||
assert!(validate_registry(®istry).is_ok());
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
40
crates/archivr-server/static/assets/index-BfwsUa2j.js
Normal file
40
crates/archivr-server/static/assets/index-BfwsUa2j.js
Normal file
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-C6h0jcH3.js
Normal file
40
crates/archivr-server/static/assets/index-C6h0jcH3.js
Normal file
File diff suppressed because one or more lines are too long
1
crates/archivr-server/static/assets/index-D40QFUPh.css
Normal file
1
crates/archivr-server/static/assets/index-D40QFUPh.css
Normal file
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-DRRKzKIq.js
Normal file
40
crates/archivr-server/static/assets/index-DRRKzKIq.js
Normal file
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-Davzo15o.js
Normal file
40
crates/archivr-server/static/assets/index-Davzo15o.js
Normal file
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-Dfnwsgsa.js
Normal file
40
crates/archivr-server/static/assets/index-Dfnwsgsa.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-CcezQ0kg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJpQthbx.css">
|
||||
<script type="module" crossorigin src="/assets/index-C6h0jcH3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D40QFUPh.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
1977
docs/superpowers/plans/2026-06-25-auth-foundation.md
Normal file
1977
docs/superpowers/plans/2026-06-25-auth-foundation.md
Normal file
File diff suppressed because it is too large
Load diff
403
docs/superpowers/specs/2026-06-25-auth-foundation-design.md
Normal file
403
docs/superpowers/specs/2026-06-25-auth-foundation-design.md
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
# Auth Foundation Design
|
||||
|
||||
**Track:** 4 of the roadmap (inserted after Track 3: Async capture jobs)
|
||||
**Date:** 2026-06-25
|
||||
**Status:** Approved for implementation
|
||||
|
||||
---
|
||||
|
||||
## Context & Roadmap Position
|
||||
|
||||
Archivr is evolving from a local-only tool (single hard-coded user, 127.0.0.1 binding) into a
|
||||
self-hosted multi-user platform — think ArchiveBox but with real accounts, roles, and
|
||||
public/private visibility. This track lays the foundation. All subsequent tracks depend on it.
|
||||
|
||||
**Full decomposition:**
|
||||
|
||||
| Track | Scope | Depends on |
|
||||
|---|---|---|
|
||||
| 4 (this) | Auth foundation | — |
|
||||
| 5 | User management — registration, custom roles, admin panel | Track 4 |
|
||||
| 6 | Permissions & visibility — collection model, per-membership visibility | Track 5 |
|
||||
| 7 | Settings — account profile, instance-wide toggles | Track 5 |
|
||||
| 8 | Collections UI | Tracks 5–6 |
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
|
|
@ -73,6 +73,7 @@
|
|||
cargoLock
|
||||
nativeBuildInputs
|
||||
;
|
||||
buildInputs = [ pkgs.openssl ];
|
||||
cargoBuildFlags = [
|
||||
"-p"
|
||||
"archivr-cli"
|
||||
|
|
@ -90,6 +91,7 @@
|
|||
cargoLock
|
||||
nativeBuildInputs
|
||||
;
|
||||
buildInputs = [ pkgs.openssl ];
|
||||
cargoBuildFlags = [
|
||||
"-p"
|
||||
"archivr-server"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,41 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags } from './api'
|
||||
import { useState, useEffect, useCallback, useRef, createContext } from 'react'
|
||||
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe } from './api'
|
||||
import LoginPage from './components/LoginPage.jsx'
|
||||
import SetupPage from './components/SetupPage.jsx'
|
||||
|
||||
import Topbar from './components/Topbar'
|
||||
import CaptureDialog from './components/CaptureDialog'
|
||||
import EntriesView from './components/EntriesView'
|
||||
import RunsView from './components/RunsView'
|
||||
import AdminView from './components/AdminView'
|
||||
import TagsView from './components/TagsView'
|
||||
import CollectionsView from './components/CollectionsView'
|
||||
import SettingsView from './components/SettingsView'
|
||||
import ContextRail from './components/ContextRail'
|
||||
|
||||
export const AuthContext = createContext(null);
|
||||
|
||||
export default function App() {
|
||||
const [authState, setAuthState] = useState('loading');
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const needsSetup = await checkSetup();
|
||||
if (needsSetup) { setAuthState('setup'); return; }
|
||||
const user = await fetchMe();
|
||||
if (!user) { setAuthState('login'); return; }
|
||||
setCurrentUser(user);
|
||||
setAuthState('authenticated');
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => { setCurrentUser(null); setAuthState('login'); };
|
||||
window.addEventListener('auth:expired', handler);
|
||||
return () => window.removeEventListener('auth:expired', handler);
|
||||
}, []);
|
||||
|
||||
const [archives, setArchives] = useState([])
|
||||
const [archiveId, setArchiveId] = useState(null)
|
||||
const [entries, setEntries] = useState([])
|
||||
|
|
@ -127,7 +154,12 @@ export default function App() {
|
|||
])
|
||||
}, [archiveId, searchQuery, tagFilter, loadEntries])
|
||||
|
||||
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;
|
||||
if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
|
||||
if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ currentUser, setCurrentUser }}>
|
||||
<>
|
||||
<Topbar
|
||||
archives={archives}
|
||||
|
|
@ -183,6 +215,12 @@ export default function App() {
|
|||
onViewChange={handleViewChange}
|
||||
/>
|
||||
)}
|
||||
{view === 'collections' && (
|
||||
<CollectionsView archiveId={archiveId} />
|
||||
)}
|
||||
{view === 'settings' && (
|
||||
<SettingsView />
|
||||
)}
|
||||
</div>
|
||||
<ContextRail
|
||||
archiveId={archiveId}
|
||||
|
|
@ -199,5 +237,6 @@ export default function App() {
|
|||
onCaptured={handleCaptured}
|
||||
/>
|
||||
</>
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,250 @@ export async function submitCapture(archiveId, locator) {
|
|||
body: JSON.stringify({ locator }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
throw new Error(msg || `HTTP ${res.status}`);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json(); // { job_uid, status: "pending" }
|
||||
}
|
||||
|
||||
export async function pollCaptureJob(archiveId, jobUid) {
|
||||
return getJson(`/api/archives/${archiveId}/capture_jobs/${jobUid}`);
|
||||
}
|
||||
|
||||
// ── Auth helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function checkSetup() {
|
||||
const r = await fetch('/api/auth/setup');
|
||||
const data = await r.json();
|
||||
return data.setup_required === true;
|
||||
}
|
||||
|
||||
export async function doSetup(username, password) {
|
||||
const r = await fetch('/api/auth/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).error || 'Setup failed');
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function login(username, password) {
|
||||
const r = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).error || 'Login failed');
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function fetchMe() {
|
||||
const r = await fetch('/api/auth/me');
|
||||
if (r.status === 401) return null;
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ── Profile & settings helpers ───────────────────────────────────────────────
|
||||
|
||||
export async function updateProfile(displayName) {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: displayName }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function changePassword(currentPassword, newPassword) {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = await res.text();
|
||||
try { msg = JSON.parse(msg).error ?? msg; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTokens() {
|
||||
return getJson('/api/auth/tokens');
|
||||
}
|
||||
|
||||
export async function createToken(name) {
|
||||
const res = await fetch('/api/auth/tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteToken(tokenUid) {
|
||||
const res = await fetch(`/api/auth/tokens/${tokenUid}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function getInstanceSettings() {
|
||||
return getJson('/api/admin/instance-settings');
|
||||
}
|
||||
|
||||
export async function updateInstanceSettings(patch) {
|
||||
const res = await fetch('/api/admin/instance-settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
// ── Admin helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listAdminUsers() {
|
||||
return getJson('/api/admin/users');
|
||||
}
|
||||
|
||||
export async function createAdminUser(username, password, email) {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, email: email || undefined }),
|
||||
});
|
||||
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function setUserStatus(userUid, status) {
|
||||
const res = await fetch(`/api/admin/users/${userUid}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function assignRole(userUid, roleSlug) {
|
||||
const res = await fetch(`/api/admin/users/${userUid}/roles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role_slug: roleSlug }),
|
||||
});
|
||||
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
|
||||
}
|
||||
|
||||
export async function removeRole(userUid, roleSlug) {
|
||||
const res = await fetch(`/api/admin/users/${userUid}/roles/${encodeURIComponent(roleSlug)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok && res.status !== 204) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
|
||||
}
|
||||
|
||||
export async function listRoles() {
|
||||
return getJson('/api/admin/roles');
|
||||
}
|
||||
|
||||
export async function createRole(slug, name) {
|
||||
const res = await fetch('/api/admin/roles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, name }),
|
||||
});
|
||||
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Collection helpers ─────────────────────────────────────────────────────
|
||||
|
||||
export async function listCollections(archiveId) {
|
||||
return getJson(`/api/archives/${archiveId}/collections`);
|
||||
}
|
||||
|
||||
export async function createCollection(archiveId, name, slug, defaultVisibilityBits = 2) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/collections`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name, slug, default_visibility_bits: defaultVisibilityBits }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getCollection(archiveId, collUid) {
|
||||
return getJson(`/api/archives/${archiveId}/collections/${collUid}`);
|
||||
}
|
||||
|
||||
export async function addEntryToCollection(archiveId, collUid, entryUid, visibilityBits = 2) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ entry_uid: entryUid, visibility_bits: visibilityBits }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeEntryFromCollection(archiveId, collUid, entryUid) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries/${entryUid}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEntryVisibility(archiveId, collUid, entryUid, visibilityBits) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries/${entryUid}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ visibility_bits: visibilityBits }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listEntryCollections(archiveId, entryUid) {
|
||||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/collections`);
|
||||
}
|
||||
|
||||
export async function updateCollection(archiveId, collUid, patch) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error ?? res.statusText) }
|
||||
}
|
||||
|
||||
export async function deleteCollection(archiveId, collUid) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}`, { method: 'DELETE' })
|
||||
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error ?? res.statusText) }
|
||||
}
|
||||
|
||||
// ── 401 interceptor ───────────────────────────────────────────────────────────
|
||||
const _origFetch = window.fetch;
|
||||
window.fetch = async (...args) => {
|
||||
const r = await _origFetch(...args);
|
||||
if (r.status === 401) {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '';
|
||||
if (!url.includes('/api/auth/')) {
|
||||
window.dispatchEvent(new CustomEvent('auth:expired'));
|
||||
}
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,218 @@
|
|||
import { useState, useEffect, useContext, useCallback } from 'react'
|
||||
import { AuthContext } from '../App.jsx'
|
||||
import {
|
||||
listAdminUsers, createAdminUser, setUserStatus, assignRole, removeRole,
|
||||
listRoles, createRole
|
||||
} from '../api.js'
|
||||
|
||||
const ROLE_ADMIN = 4
|
||||
|
||||
export default function AdminView({ archives }) {
|
||||
const { currentUser } = useContext(AuthContext) ?? {}
|
||||
const isAdmin = currentUser && (currentUser.role_bits & ROLE_ADMIN) !== 0
|
||||
|
||||
const [tab, setTab] = useState('users')
|
||||
const [users, setUsers] = useState([])
|
||||
const [roles, setRoles] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
// Create user form state
|
||||
const [newUsername, setNewUsername] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [newEmail, setNewEmail] = useState('')
|
||||
const [createError, setCreateError] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// Create role form state
|
||||
const [newRoleSlug, setNewRoleSlug] = useState('')
|
||||
const [newRoleName, setNewRoleName] = useState('')
|
||||
const [roleCreateError, setRoleCreateError] = useState(null)
|
||||
const [creatingRole, setCreatingRole] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!isAdmin) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [u, r] = await Promise.all([listAdminUsers(), listRoles()])
|
||||
setUsers(u)
|
||||
setRoles(r)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
async function handleToggleStatus(user) {
|
||||
const next = user.status === 'active' ? 'disabled' : 'active'
|
||||
try {
|
||||
await setUserStatus(user.user_uid, next)
|
||||
setUsers(us => us.map(u => u.user_uid === user.user_uid ? { ...u, status: next } : u))
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateUser(e) {
|
||||
e.preventDefault()
|
||||
if (!newUsername.trim() || !newPassword) { setCreateError('Username and password required'); return }
|
||||
setCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
await createAdminUser(newUsername.trim(), newPassword, newEmail.trim() || undefined)
|
||||
setNewUsername('')
|
||||
setNewPassword('')
|
||||
setNewEmail('')
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setCreateError(e.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRole(e) {
|
||||
e.preventDefault()
|
||||
if (!newRoleSlug.trim() || !newRoleName.trim()) { setRoleCreateError('Slug and name required'); return }
|
||||
setCreatingRole(true)
|
||||
setRoleCreateError(null)
|
||||
try {
|
||||
await createRole(newRoleSlug.trim(), newRoleName.trim())
|
||||
setNewRoleSlug('')
|
||||
setNewRoleName('')
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setRoleCreateError(e.message)
|
||||
} finally {
|
||||
setCreatingRole(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<section id="admin-view" className="view admin-view is-active">
|
||||
<h1>Mounted Archives</h1>
|
||||
<h1>Admin</h1>
|
||||
<p className="muted">You need admin privileges to access this panel.</p>
|
||||
<h2>Mounted Archives</h2>
|
||||
<div className="admin-list">
|
||||
{archives.map(archive => (
|
||||
<div key={archive.id} className="admin-archive">
|
||||
<strong>{archive.label}</strong>
|
||||
<div className="muted">{archive.archive_path}</div>
|
||||
{archives.map(a => (
|
||||
<div key={a.id} className="admin-archive">
|
||||
<strong>{a.label}</strong>
|
||||
<div className="muted">{a.archive_path}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="admin-view" className="view admin-view is-active">
|
||||
<h1>Admin</h1>
|
||||
|
||||
<div className="admin-tabs">
|
||||
<button className={`admin-tab${tab === 'users' ? ' is-active' : ''}`} onClick={() => setTab('users')}>Users</button>
|
||||
<button className={`admin-tab${tab === 'roles' ? ' is-active' : ''}`} onClick={() => setTab('roles')}>Roles</button>
|
||||
<button className={`admin-tab${tab === 'archives' ? ' is-active' : ''}`} onClick={() => setTab('archives')}>Archives</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="capture-error">{error}</div>}
|
||||
|
||||
{tab === 'users' && (
|
||||
<div className="admin-section">
|
||||
<h2>Users</h2>
|
||||
{loading ? <p className="muted">Loading…</p> : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr><th>Username</th><th>Email</th><th>Roles</th><th>Status</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.user_uid} className={u.status === 'disabled' ? 'admin-row-disabled' : ''}>
|
||||
<td>{u.username}</td>
|
||||
<td className="muted">{u.email || '—'}</td>
|
||||
<td>{u.role_slugs.join(', ') || '—'}</td>
|
||||
<td><span className={`status-badge status-${u.status}`}>{u.status}</span></td>
|
||||
<td>
|
||||
<button className="admin-action-btn" onClick={() => handleToggleStatus(u)}>
|
||||
{u.status === 'active' ? 'Ban' : 'Unban'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<h3>Create User</h3>
|
||||
<form className="admin-form" onSubmit={handleCreateUser}>
|
||||
<input className="admin-input" placeholder="Username" value={newUsername}
|
||||
onChange={e => setNewUsername(e.target.value)} required />
|
||||
<input className="admin-input" type="password" placeholder="Password (min 8 chars)"
|
||||
value={newPassword} onChange={e => setNewPassword(e.target.value)} required />
|
||||
<input className="admin-input" type="email" placeholder="Email (optional)"
|
||||
value={newEmail} onChange={e => setNewEmail(e.target.value)} />
|
||||
{createError && <div className="capture-error">{createError}</div>}
|
||||
<button className="capture-submit" type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create User'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'roles' && (
|
||||
<div className="admin-section">
|
||||
<h2>Roles</h2>
|
||||
{loading ? <p className="muted">Loading…</p> : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr><th>Slug</th><th>Name</th><th>Level</th><th>Bit</th><th>Built-in</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roles.map(r => (
|
||||
<tr key={r.role_uid}>
|
||||
<td><code>{r.slug}</code></td>
|
||||
<td>{r.name}</td>
|
||||
<td>{r.level}</td>
|
||||
<td>{r.bit_position}</td>
|
||||
<td>{r.is_builtin ? '✓' : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<h3>Create Custom Role</h3>
|
||||
<form className="admin-form" onSubmit={handleCreateRole}>
|
||||
<input className="admin-input" placeholder="Slug (e.g. moderator)" value={newRoleSlug}
|
||||
onChange={e => setNewRoleSlug(e.target.value)} required />
|
||||
<input className="admin-input" placeholder="Display Name (e.g. Moderator)"
|
||||
value={newRoleName} onChange={e => setNewRoleName(e.target.value)} required />
|
||||
{roleCreateError && <div className="capture-error">{roleCreateError}</div>}
|
||||
<button className="capture-submit" type="submit" disabled={creatingRole}>
|
||||
{creatingRole ? 'Creating…' : 'Create Role'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'archives' && (
|
||||
<div className="admin-section">
|
||||
<h2>Mounted Archives</h2>
|
||||
<div className="admin-list">
|
||||
{archives.map(a => (
|
||||
<div key={a.id} className="admin-archive">
|
||||
<strong>{a.label}</strong>
|
||||
<div className="muted">{a.archive_path}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
import { useRef, useEffect, useState } from 'react'
|
||||
import { submitCapture } from '../api'
|
||||
import { submitCapture, pollCaptureJob } from '../api'
|
||||
|
||||
export default function CaptureDialog({ open, archiveId, onClose, onCaptured }) {
|
||||
const dialogRef = useRef(null)
|
||||
const [locator, setLocator] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [jobStatus, setJobStatus] = useState(null) // null | 'running' | 'completed' | 'failed'
|
||||
const pollRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
const handleClose = () => onClose()
|
||||
const handleClose = () => {
|
||||
clearInterval(pollRef.current)
|
||||
onClose()
|
||||
}
|
||||
dialog.addEventListener('close', handleClose)
|
||||
return () => dialog.removeEventListener('close', handleClose)
|
||||
}, [onClose])
|
||||
|
|
@ -21,6 +26,9 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured })
|
|||
if (open) {
|
||||
setLocator('')
|
||||
setError(null)
|
||||
setJobStatus(null)
|
||||
setBusy(false)
|
||||
clearInterval(pollRef.current)
|
||||
if (!dialog.open) dialog.showModal()
|
||||
} else {
|
||||
if (dialog.open) dialog.close()
|
||||
|
|
@ -31,17 +39,47 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured })
|
|||
if (!locator.trim()) { setError('Enter a locator.'); return }
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setJobStatus(null)
|
||||
try {
|
||||
await submitCapture(archiveId, locator.trim())
|
||||
const job = await submitCapture(archiveId, locator.trim())
|
||||
setJobStatus('running')
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const updated = await pollCaptureJob(archiveId, job.job_uid)
|
||||
if (updated.status === 'completed') {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
setBusy(false)
|
||||
setJobStatus('completed')
|
||||
dialogRef.current?.close()
|
||||
onCaptured()
|
||||
} else if (updated.status === 'failed') {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
setBusy(false)
|
||||
setJobStatus('failed')
|
||||
setError(updated.error_text || 'Capture failed.')
|
||||
}
|
||||
// pending / running: keep polling
|
||||
} catch (pollErr) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
setBusy(false)
|
||||
setError(pollErr.message)
|
||||
}
|
||||
}, 500)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function buttonLabel() {
|
||||
if (!busy) return 'Capture'
|
||||
if (jobStatus === 'running') return 'Running\u2026'
|
||||
return 'Capturing\u2026'
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="capture-dialog">
|
||||
<div className="capture-dialog-inner">
|
||||
|
|
@ -56,7 +94,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured })
|
|||
<div className="capture-actions">
|
||||
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>Cancel</button>
|
||||
<button type="button" className="capture-submit" onClick={handleSubmit} disabled={busy}>
|
||||
{busy ? 'Capturing\u2026' : 'Capture'}
|
||||
{buttonLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
366
frontend/src/components/CollectionsView.jsx
Normal file
366
frontend/src/components/CollectionsView.jsx
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
listCollections, createCollection, getCollection,
|
||||
addEntryToCollection, removeEntryFromCollection,
|
||||
updateEntryVisibility, updateCollection, deleteCollection,
|
||||
} from '../api.js'
|
||||
|
||||
const VIS_OPTIONS = [
|
||||
{ value: 0, label: 'Private' },
|
||||
{ value: 2, label: 'Users only' },
|
||||
{ value: 3, label: 'Public' },
|
||||
]
|
||||
const VIS_LABEL = v => VIS_OPTIONS.find(o => o.value === v)?.label ?? String(v)
|
||||
|
||||
export default function CollectionsView({ archiveId }) {
|
||||
const [collections, setCollections] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [selectedUid, setSelectedUid] = useState(null)
|
||||
const [collDetail, setCollDetail] = useState(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [detailError, setDetailError] = useState(null)
|
||||
|
||||
// Create form
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newSlug, setNewSlug] = useState('')
|
||||
const [newVis, setNewVis] = useState(2)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState(null)
|
||||
|
||||
// Add-entry form
|
||||
const [addUid, setAddUid] = useState('')
|
||||
const [addVis, setAddVis] = useState(2)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addError, setAddError] = useState(null)
|
||||
|
||||
// Inline rename state
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [renameVal, setRenameVal] = useState('')
|
||||
const renameRef = useRef(null)
|
||||
|
||||
const selected = collections.find(c => c.collection_uid === selectedUid) ?? null
|
||||
const isDefault = selected?.slug === '_default_'
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
if (!archiveId) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const cols = await listCollections(archiveId)
|
||||
setCollections(cols)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [archiveId])
|
||||
|
||||
const refreshDetail = useCallback(async (uid) => {
|
||||
if (!uid) { setCollDetail(null); return }
|
||||
setDetailLoading(true)
|
||||
setDetailError(null)
|
||||
try {
|
||||
const d = await getCollection(archiveId, uid)
|
||||
setCollDetail(d)
|
||||
} catch (e) {
|
||||
setDetailError(e.message)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}, [archiveId])
|
||||
|
||||
useEffect(() => { refreshList() }, [refreshList])
|
||||
useEffect(() => { refreshDetail(selectedUid) }, [selectedUid, refreshDetail])
|
||||
|
||||
// Auto-focus rename input
|
||||
useEffect(() => { if (renaming && renameRef.current) renameRef.current.focus() }, [renaming])
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault()
|
||||
const name = newName.trim()
|
||||
const slug = newSlug.trim()
|
||||
if (!name || !slug) return
|
||||
setCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
const coll = await createCollection(archiveId, name, slug, newVis)
|
||||
setNewName('')
|
||||
setNewSlug('')
|
||||
setNewVis(2)
|
||||
await refreshList()
|
||||
setSelectedUid(coll.collection_uid)
|
||||
} catch (err) {
|
||||
setCreateError(err.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameCommit() {
|
||||
const name = renameVal.trim()
|
||||
if (!name || !selected) { setRenaming(false); return }
|
||||
try {
|
||||
await updateCollection(archiveId, selected.collection_uid, { name })
|
||||
await refreshList()
|
||||
setCollDetail(d => d ? { ...d, name } : d)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVisChange(newVisVal) {
|
||||
if (!selected) return
|
||||
try {
|
||||
await updateCollection(archiveId, selected.collection_uid, { default_visibility_bits: newVisVal })
|
||||
await refreshList()
|
||||
setCollDetail(d => d ? { ...d, default_visibility_bits: newVisVal } : d)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selected) return
|
||||
if (!window.confirm(`Delete collection "${selected.name}"? Entries will not be deleted.`)) return
|
||||
try {
|
||||
await deleteCollection(archiveId, selected.collection_uid)
|
||||
setSelectedUid(null)
|
||||
setCollDetail(null)
|
||||
await refreshList()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddEntry(e) {
|
||||
e.preventDefault()
|
||||
const uid = addUid.trim()
|
||||
if (!uid || !selected) return
|
||||
setAdding(true)
|
||||
setAddError(null)
|
||||
try {
|
||||
await addEntryToCollection(archiveId, selected.collection_uid, uid, addVis)
|
||||
setAddUid('')
|
||||
await refreshDetail(selected.collection_uid)
|
||||
} catch (err) {
|
||||
setAddError(err.message)
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveEntry(entryUid) {
|
||||
if (!selected) return
|
||||
try {
|
||||
await removeEntryFromCollection(archiveId, selected.collection_uid, entryUid)
|
||||
await refreshDetail(selected.collection_uid)
|
||||
} catch (e) {
|
||||
setDetailError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEntryVisChange(entryUid, vis) {
|
||||
if (!selected) return
|
||||
try {
|
||||
await updateEntryVisibility(archiveId, selected.collection_uid, entryUid, vis)
|
||||
setCollDetail(d => d ? {
|
||||
...d,
|
||||
entries: d.entries.map(en =>
|
||||
en.entry_uid === entryUid ? { ...en, collection_visibility_bits: vis } : en
|
||||
),
|
||||
} : d)
|
||||
} catch (e) {
|
||||
setDetailError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!archiveId) return <div className="view-placeholder">Select an archive.</div>
|
||||
|
||||
return (
|
||||
<div className="collections-view">
|
||||
<h2 className="collections-heading">Collections</h2>
|
||||
|
||||
{loading && <div className="muted">Loading…</div>}
|
||||
{error && <div className="collections-error">{error} <button onClick={() => setError(null)} className="coll-dismiss">×</button></div>}
|
||||
|
||||
<div className="collections-layout">
|
||||
{/* Sidebar */}
|
||||
<div className="collections-sidebar">
|
||||
{collections.map(c => (
|
||||
<button
|
||||
key={c.collection_uid}
|
||||
className={`coll-row${selectedUid === c.collection_uid ? ' is-active' : ''}`}
|
||||
onClick={() => setSelectedUid(c.collection_uid)}
|
||||
>
|
||||
<span className="coll-row-name">{c.name}</span>
|
||||
<span className="coll-row-meta">{VIS_LABEL(c.default_visibility_bits)}</span>
|
||||
</button>
|
||||
))}
|
||||
{collections.length === 0 && !loading && (
|
||||
<div className="muted" style={{ padding: '8px 12px' }}>No collections yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail pane */}
|
||||
{selected ? (
|
||||
<div className="coll-detail">
|
||||
{/* Header */}
|
||||
<div className="coll-detail-header">
|
||||
{renaming ? (
|
||||
<input
|
||||
ref={renameRef}
|
||||
className="coll-rename-input"
|
||||
value={renameVal}
|
||||
onChange={e => setRenameVal(e.target.value)}
|
||||
onBlur={handleRenameCommit}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleRenameCommit()
|
||||
if (e.key === 'Escape') setRenaming(false)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<h3
|
||||
className={`coll-detail-name${isDefault ? '' : ' coll-detail-name--editable'}`}
|
||||
title={isDefault ? undefined : 'Click to rename'}
|
||||
onClick={() => { if (!isDefault) { setRenameVal(selected.name); setRenaming(true) } }}
|
||||
>
|
||||
{collDetail?.name ?? selected.name}
|
||||
{!isDefault && <span className="coll-edit-hint"> ✎</span>}
|
||||
</h3>
|
||||
)}
|
||||
{!isDefault && (
|
||||
<button className="coll-delete-btn" onClick={handleDelete} title="Delete collection">Delete</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div className="coll-detail-vis">
|
||||
<span className="coll-vis-label">Default visibility</span>
|
||||
<select
|
||||
className="coll-vis-select"
|
||||
value={collDetail?.default_visibility_bits ?? selected.default_visibility_bits}
|
||||
onChange={e => handleVisChange(Number(e.target.value))}
|
||||
disabled={isDefault}
|
||||
>
|
||||
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Entries */}
|
||||
<div className="coll-entries-section">
|
||||
<div className="coll-section-heading">Entries</div>
|
||||
{detailLoading && <div className="muted">Loading…</div>}
|
||||
{detailError && <div className="collections-error">{detailError}</div>}
|
||||
{!detailLoading && collDetail && (
|
||||
collDetail.entries.length === 0 ? (
|
||||
<div className="muted">No entries in this collection.</div>
|
||||
) : (
|
||||
<ul className="coll-entries-list">
|
||||
{collDetail.entries.map(entry => (
|
||||
<li key={entry.entry_uid} className="coll-entry-row">
|
||||
<div className="coll-entry-info">
|
||||
<span className="coll-entry-title">{entry.title || entry.entry_uid}</span>
|
||||
<span className="coll-entry-kind muted">{entry.source_kind}</span>
|
||||
</div>
|
||||
<div className="coll-entry-actions">
|
||||
<select
|
||||
className="coll-entry-vis-select"
|
||||
value={entry.collection_visibility_bits}
|
||||
onChange={e => handleEntryVisChange(entry.entry_uid, Number(e.target.value))}
|
||||
>
|
||||
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
{!isDefault && (
|
||||
<button
|
||||
className="coll-entry-remove"
|
||||
onClick={() => handleRemoveEntry(entry.entry_uid)}
|
||||
title="Remove from collection"
|
||||
>×</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add entry (non-default only) */}
|
||||
{!isDefault && (
|
||||
<form className="coll-add-entry-form" onSubmit={handleAddEntry}>
|
||||
<div className="coll-section-heading">Add entry</div>
|
||||
<div className="coll-add-entry-row">
|
||||
<input
|
||||
className="coll-add-entry-input"
|
||||
type="text"
|
||||
value={addUid}
|
||||
onChange={e => setAddUid(e.target.value)}
|
||||
placeholder="entry_uid"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
className="coll-vis-select"
|
||||
value={addVis}
|
||||
onChange={e => setAddVis(Number(e.target.value))}
|
||||
>
|
||||
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<button className="coll-add-btn" type="submit" disabled={adding}>
|
||||
{adding ? '…' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
{addError && <div className="collections-error" style={{ marginTop: 4 }}>{addError}</div>}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="coll-detail coll-detail--empty">
|
||||
<div className="muted">Select a collection to view details.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
<details className="coll-create-details" style={{ marginTop: '1.5rem' }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Create collection</summary>
|
||||
<form onSubmit={handleCreate} style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: 400 }}>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
className="capture-input"
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={e => { setNewName(e.target.value); if (!newSlug) setNewSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) }}
|
||||
placeholder="My Collection"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Slug
|
||||
<input
|
||||
className="capture-input"
|
||||
type="text"
|
||||
value={newSlug}
|
||||
onChange={e => setNewSlug(e.target.value)}
|
||||
placeholder="my-collection"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Default visibility
|
||||
<select className="capture-input" style={{ height: 42 }} value={newVis} onChange={e => setNewVis(Number(e.target.value))}>
|
||||
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{createError && <div className="collections-error">{createError}</div>}
|
||||
<button className="capture-submit" type="submit" disabled={creating} style={{ alignSelf: 'flex-start', padding: '8px 20px' }}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag } from '../api'
|
||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections } from '../api'
|
||||
import { formatTimestamp, formatBytes, valueText } from '../utils'
|
||||
|
||||
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) {
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [tags, setTags] = useState([])
|
||||
const [assignInput, setAssignInput] = useState('')
|
||||
const [entryCollections, setEntryCollections] = useState([])
|
||||
const [assignError, setAssignError] = useState('')
|
||||
const selectSeqRef = useRef(0)
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
if (!selectedEntry || !archiveId) {
|
||||
setDetail(null)
|
||||
setTags([])
|
||||
setEntryCollections([])
|
||||
return
|
||||
}
|
||||
const seq = ++selectSeqRef.current
|
||||
|
|
@ -21,10 +23,12 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
Promise.all([
|
||||
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
|
||||
fetchEntryTags(archiveId, selectedEntry.entry_uid),
|
||||
]).then(([det, tgs]) => {
|
||||
listEntryCollections(archiveId, selectedEntry.entry_uid),
|
||||
]).then(([det, tgs, ecs]) => {
|
||||
if (seq !== selectSeqRef.current) return
|
||||
setDetail(det)
|
||||
setTags(tgs)
|
||||
setEntryCollections(ecs)
|
||||
}).catch(() => {})
|
||||
}, [selectedEntry, archiveId])
|
||||
|
||||
|
|
@ -156,6 +160,19 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedEntry && entryCollections.length > 0 && (
|
||||
<div className="rail-section">
|
||||
<div className="rail-section-heading">Collections</div>
|
||||
{entryCollections.map(c => (
|
||||
<div key={c.collection_uid} className="rail-item">
|
||||
<span className="rail-label">{c.collection_uid}</span>:{' '}
|
||||
<span className="muted">
|
||||
{{ 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }[c.visibility_bits] ?? `bits:${c.visibility_bits}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
|
|
|||
54
frontend/src/components/LoginPage.jsx
Normal file
54
frontend/src/components/LoginPage.jsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useState } from 'react';
|
||||
import { login } from '../api.js';
|
||||
|
||||
export default function LoginPage({ onLogin }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = await login(username, password);
|
||||
onLogin(user);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<h1>Archivr</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Logging in\u2026' : 'Log in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
frontend/src/components/SettingsView.jsx
Normal file
271
frontend/src/components/SettingsView.jsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { useState, useEffect, useContext, useCallback } from 'react'
|
||||
import { AuthContext } from '../App.jsx'
|
||||
import {
|
||||
updateProfile, changePassword,
|
||||
listTokens, createToken, deleteToken,
|
||||
getInstanceSettings, updateInstanceSettings,
|
||||
} from '../api.js'
|
||||
|
||||
const ROLE_ADMIN = 4
|
||||
const ROLE_OWNER = 8
|
||||
|
||||
export default function SettingsView() {
|
||||
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
|
||||
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
|
||||
const [tab, setTab] = useState('profile')
|
||||
|
||||
return (
|
||||
<section className="admin-view">
|
||||
<h1>Settings</h1>
|
||||
<div className="admin-tabs" style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
||||
{['profile', 'tokens', ...(isAdmin ? ['instance'] : [])].map(t => (
|
||||
<button key={t}
|
||||
className={`nav-link${tab === t ? ' is-active' : ''}`}
|
||||
style={{ borderBottom: tab === t ? '2px solid var(--accent)' : undefined, color: 'var(--ink)' }}
|
||||
onClick={() => setTab(t)}>
|
||||
{t === 'profile' ? 'Profile' : t === 'tokens' ? 'API Tokens' : 'Instance'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'profile' && <ProfileTab currentUser={currentUser} setCurrentUser={setCurrentUser} />}
|
||||
{tab === 'tokens' && <TokensTab />}
|
||||
{tab === 'instance' && isAdmin && <InstanceTab />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileTab({ currentUser, setCurrentUser }) {
|
||||
const [displayName, setDisplayName] = useState(currentUser?.display_name ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMsg, setSaveMsg] = useState(null)
|
||||
|
||||
const [curPw, setCurPw] = useState('')
|
||||
const [newPw, setNewPw] = useState('')
|
||||
const [confirmPw, setConfirmPw] = useState('')
|
||||
const [pwSaving, setPwSaving] = useState(false)
|
||||
const [pwMsg, setPwMsg] = useState(null)
|
||||
|
||||
async function handleSaveProfile(e) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setSaveMsg(null)
|
||||
try {
|
||||
await updateProfile(displayName)
|
||||
setCurrentUser(u => ({ ...u, display_name: displayName || null }))
|
||||
setSaveMsg({ ok: true, text: 'Saved.' })
|
||||
} catch (err) {
|
||||
setSaveMsg({ ok: false, text: err.message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(e) {
|
||||
e.preventDefault()
|
||||
if (newPw !== confirmPw) { setPwMsg({ ok: false, text: 'Passwords do not match.' }); return }
|
||||
setPwSaving(true)
|
||||
setPwMsg(null)
|
||||
try {
|
||||
await changePassword(curPw, newPw)
|
||||
setCurPw(''); setNewPw(''); setConfirmPw('')
|
||||
setPwMsg({ ok: true, text: 'Password changed.' })
|
||||
} catch (err) {
|
||||
setPwMsg({ ok: false, text: err.message })
|
||||
} finally {
|
||||
setPwSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 480 }}>
|
||||
<div className="admin-section">
|
||||
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Display Name</h2>
|
||||
<form className="admin-form" onSubmit={handleSaveProfile}>
|
||||
<input className="admin-input" placeholder={currentUser?.username ?? ''}
|
||||
value={displayName} onChange={e => setDisplayName(e.target.value)} />
|
||||
{saveMsg && (
|
||||
<div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>
|
||||
)}
|
||||
<button className="capture-submit" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving\u2026' : 'Save'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="admin-section" style={{ marginTop: 28 }}>
|
||||
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Change Password</h2>
|
||||
<form className="admin-form" onSubmit={handleChangePassword}>
|
||||
<input className="admin-input" type="password" placeholder="Current password"
|
||||
value={curPw} onChange={e => setCurPw(e.target.value)} required />
|
||||
<input className="admin-input" type="password" placeholder="New password"
|
||||
value={newPw} onChange={e => setNewPw(e.target.value)} required />
|
||||
<input className="admin-input" type="password" placeholder="Confirm new password"
|
||||
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} required />
|
||||
{pwMsg && (
|
||||
<div className={pwMsg.ok ? 'muted' : 'capture-error'}>{pwMsg.text}</div>
|
||||
)}
|
||||
<button className="capture-submit" type="submit" disabled={pwSaving}>
|
||||
{pwSaving ? 'Changing\u2026' : 'Change Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TokensTab() {
|
||||
const [tokens, setTokens] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newToken, setNewToken] = useState(null) // { raw_token, name }
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try { setTokens(await listTokens()) }
|
||||
catch (e) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault()
|
||||
if (!newName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const tok = await createToken(newName.trim())
|
||||
setNewToken(tok)
|
||||
setNewName('')
|
||||
refresh()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(tokenUid) {
|
||||
try {
|
||||
await deleteToken(tokenUid)
|
||||
setTokens(ts => ts.filter(t => t.token_uid !== tokenUid))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<h2 style={{ fontSize: 15, marginBottom: 12 }}>API Tokens</h2>
|
||||
{newToken && (
|
||||
<div style={{ background: '#e8f5e9', border: '1px solid #a5d6a7', padding: '12px 14px', marginBottom: 16, fontSize: 13 }}>
|
||||
<strong>Token created.</strong> Copy it now \u2014 it will not be shown again.<br />
|
||||
<code style={{ wordBreak: 'break-all', display: 'block', marginTop: 6, padding: '6px 8px', background: '#f5f5f5', border: '1px solid #ddd' }}>
|
||||
{newToken.raw_token}
|
||||
</code>
|
||||
<button style={{ marginTop: 8, fontSize: 12, border: '1px solid #aaa', background: 'none', cursor: 'pointer' }}
|
||||
onClick={() => setNewToken(null)}>Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
<form className="admin-form" onSubmit={handleCreate} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<input className="admin-input" placeholder="Token name" value={newName}
|
||||
onChange={e => setNewName(e.target.value)} style={{ flex: 1 }} required />
|
||||
<button className="capture-submit" type="submit" disabled={creating}>
|
||||
{creating ? 'Creating\u2026' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <div className="capture-error">{error}</div>}
|
||||
{loading ? <div className="muted">Loading\u2026</div> : (
|
||||
<div className="admin-list">
|
||||
{tokens.length === 0 && <div className="muted">No tokens yet.</div>}
|
||||
{tokens.map(tok => (
|
||||
<div key={tok.token_uid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
border: '1px solid var(--line)', background: 'var(--paper-3)', padding: '10px 12px' }}>
|
||||
<div>
|
||||
<strong style={{ fontSize: 14 }}>{tok.name}</strong>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||
Created {tok.created_at.slice(0, 10)}
|
||||
{tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => handleDelete(tok.token_uid)}
|
||||
style={{ border: '1px solid var(--line)', background: 'none', cursor: 'pointer',
|
||||
color: 'var(--accent)', fontSize: 12, padding: '4px 10px' }}>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InstanceTab() {
|
||||
const [settings, setSettings] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMsg, setSaveMsg] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { setSettings(await getInstanceSettings()) }
|
||||
catch (e) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
})()
|
||||
}, [])
|
||||
|
||||
async function handleSave(e) {
|
||||
e.preventDefault()
|
||||
setSaving(true); setSaveMsg(null)
|
||||
try {
|
||||
await updateInstanceSettings(settings)
|
||||
setSaveMsg({ ok: true, text: 'Saved.' })
|
||||
} catch (err) {
|
||||
setSaveMsg({ ok: false, text: err.message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="muted">Loading\u2026</div>
|
||||
if (error) return <div className="capture-error">{error}</div>
|
||||
if (!settings) return null
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 480 }}>
|
||||
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Instance Settings</h2>
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="admin-section">
|
||||
{[
|
||||
['public_index_enabled', 'Public index (unauthenticated browsing)'],
|
||||
['public_entry_content_enabled', 'Public entry content'],
|
||||
['open_registration_enabled', 'Open registration'],
|
||||
].map(([key, label]) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!settings[key]}
|
||||
onChange={e => setSettings(s => ({ ...s, [key]: e.target.checked }))} />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Default entry visibility</label>
|
||||
<select className="admin-input" value={settings.default_entry_visibility}
|
||||
onChange={e => setSettings(s => ({ ...s, default_entry_visibility: Number(e.target.value) }))}>
|
||||
<option value={0}>Private (0)</option>
|
||||
<option value={2}>Unlisted (2)</option>
|
||||
<option value={3}>Public (3)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{saveMsg && <div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>}
|
||||
<button className="capture-submit" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving\u2026' : 'Save Settings'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
frontend/src/components/SetupPage.jsx
Normal file
73
frontend/src/components/SetupPage.jsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { useState } from 'react';
|
||||
import { doSetup } from '../api.js';
|
||||
|
||||
export default function SetupPage({ onComplete }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (password !== confirm) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await doSetup(username, password);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="setup-page">
|
||||
<h1>Welcome to Archivr</h1>
|
||||
<p>Create your owner account to get started.</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Creating account\u2026' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,18 @@
|
|||
import { useContext, useState } from 'react';
|
||||
import { AuthContext } from '../App.jsx';
|
||||
import { logout as apiLogout } from '../api.js';
|
||||
|
||||
export default function Topbar({ archives, archiveId, onArchiveChange, view, onViewChange, onCaptureClick }) {
|
||||
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {};
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
|
||||
async function handleLogout() {
|
||||
setLoggingOut(true);
|
||||
await apiLogout();
|
||||
setCurrentUser(null);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="brand">Archivr</div>
|
||||
|
|
@ -7,7 +21,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
|
|||
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||
</select>
|
||||
<nav className="nav" aria-label="Primary">
|
||||
{['archive', 'runs', 'admin', 'tags'].map(name => (
|
||||
{['archive', 'runs', 'admin', 'tags', 'collections', 'settings'].map(name => (
|
||||
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
|
||||
onClick={() => onViewChange(name)}>
|
||||
{name.charAt(0).toUpperCase() + name.slice(1)}
|
||||
|
|
@ -15,6 +29,15 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
|
|||
))}
|
||||
</nav>
|
||||
<button className="capture-button" onClick={onCaptureClick}>+ Capture</button>
|
||||
{currentUser && (
|
||||
<div className="user-menu">
|
||||
<span className="username">{currentUser.display_name || currentUser.username}</span>
|
||||
<button className="nav-link" onClick={() => onViewChange('settings')} style={{ color: '#d7cdbf', fontSize: 13 }}>Settings</button>
|
||||
<button onClick={handleLogout} disabled={loggingOut} className="logout-btn">
|
||||
{loggingOut ? 'Logging out\u2026' : 'Log out'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -640,3 +640,291 @@ select {
|
|||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Collections view ─────────────────────────────────────────────────────── */
|
||||
|
||||
.collections-view {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.collections-heading {
|
||||
margin: 0 0 16px;
|
||||
font-size: 22px;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
.collections-error {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.coll-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.collections-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 0;
|
||||
border: 1px solid var(--line);
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.collections-sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.coll-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.coll-row:hover {
|
||||
background: var(--paper-2);
|
||||
}
|
||||
|
||||
.coll-row.is-active {
|
||||
background: var(--paper-2);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.coll-row-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.coll-row-meta {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.coll-detail {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.coll-detail--empty {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coll-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.coll-detail-name {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.coll-detail-name--editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.coll-detail-name--editable:hover {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.coll-edit-hint {
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.coll-rename-input {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
border: 2px solid var(--ink);
|
||||
padding: 4px 8px;
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.coll-delete-btn {
|
||||
border: 1px solid var(--accent);
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
padding: 5px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.coll-delete-btn:hover {
|
||||
background: var(--accent);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.coll-detail-vis {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.coll-vis-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coll-vis-select {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.coll-section-heading {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.coll-entries-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.coll-entries-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.coll-entry-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.coll-entry-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.coll-entry-title {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.coll-entry-kind {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.coll-entry-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.coll-entry-vis-select {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
padding: 3px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.coll-entry-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.coll-entry-remove:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.coll-add-entry-form {
|
||||
border-top: 1px solid var(--line-soft);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.coll-add-entry-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coll-add-entry-input {
|
||||
flex: 1;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.coll-add-btn {
|
||||
border: none;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coll-add-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.coll-add-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.coll-create-details summary {
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue