From 44c17eee3d6b6ac6a4e280577f6f2f84b86281cb Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:22:59 +0200 Subject: [PATCH 01/14] docs: document runtime entrypoints --- ARCHIVR-MENTAL-MODEL.md | 34 ++++++++++++++++++++++++++++++++ docs/README.md | 43 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/ARCHIVR-MENTAL-MODEL.md b/ARCHIVR-MENTAL-MODEL.md index 01f4387..3ce03f6 100644 --- a/ARCHIVR-MENTAL-MODEL.md +++ b/ARCHIVR-MENTAL-MODEL.md @@ -57,6 +57,40 @@ label = "Personal" archive_path = "/path/to/archive/.archivr" ``` +## How To Run It + +There are two user-facing binaries: + +| Binary | Purpose | +|---|---| +| `archivr` | CLI for initializing archives and capturing material into one archive | +| `archivr-server` | Web server for browsing one or more existing archives | + +The CLI writes archive data: + +```sh +nix run .#archivr -- init ./my-archive --name "My Archive" +nix run .#archivr -- archive file:///absolute/path/to/file.pdf +``` + +The server reads archive data: + +```sh +nix run .#archivr-server -- ./archivr-server.toml +``` + +If no config path is passed, the server reads `./archivr-server.toml`. +The config is a server registry, not archive data: + +```toml +[[archives]] +id = "personal" +label = "Personal" +archive_path = "/absolute/path/to/my-archive/.archivr" +``` + +The packaged Nix server wrapper sets `ARCHIVR_STATIC_DIR` so the server can find the installed web UI assets. Source-tree runs do not need that variable because they fall back to `crates/archivr-server/static`. + ## Write Data Flow When archiving something through the CLI: diff --git a/docs/README.md b/docs/README.md index c6d1eba..7c35eea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,6 +56,49 @@ This project aims to provide a reliable solution for archiving important data fr - Direct platform URLs - Platform shorthand inputs such as `tweet:...`, `yt:...`, or `instagram:...` +## Running Archivr + +Archivr currently ships as two binaries: + +- `archivr` + - The CLI for creating and writing to one archive. + - Use this for `init` and `archive`. +- `archivr-server` + - The web server for reading one or more existing archives through the browser UI. + - Use this after archives already exist. + +With Nix, run the CLI with: + +```sh +nix run .#archivr -- init ./my-archive --name "My Archive" +nix run .#archivr -- archive file:///absolute/path/to/file.pdf +``` + +Run the web server with: + +```sh +nix run .#archivr-server -- ./archivr-server.toml +``` + +The server expects a TOML registry file. If no path is passed, it reads `./archivr-server.toml`. + +Example: + +```toml +[[archives]] +id = "personal" +label = "Personal" +archive_path = "/absolute/path/to/my-archive/.archivr" +``` + +Then open: + +```text +http://127.0.0.1:8080 +``` + +When installed through Nix, `archivr-server` is wrapped so it can find the static web UI assets automatically. The wrapper sets `ARCHIVR_STATIC_DIR` to the installed static asset directory. Running from source with `cargo run -p archivr-server` falls back to `crates/archivr-server/static`. + ### Supported Platforms - Local files: `file:///absolute/path/to/file.ext` From 9337a822ad29f9ae2319d2600ba43fcb2a6b3642 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:17:51 +0200 Subject: [PATCH 02/14] docs: add next work handoff --- NEXT.md | 386 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 NEXT.md diff --git a/NEXT.md b/NEXT.md new file mode 100644 index 0000000..c8f44d2 --- /dev/null +++ b/NEXT.md @@ -0,0 +1,386 @@ +# Archivr Next Work Decision Handoff + +> **For future agentic workers:** Start by reading `ARCHIVR-MENTAL-MODEL.md`, then this file. If the user chooses one track below, create a task-level implementation plan before touching code. Use `superpowers:writing-plans` for the chosen track, then implement with `superpowers:subagent-driven-development` or `superpowers:executing-plans`. + +**Goal:** Capture the next plausible product and engineering directions after the database, workspace, Nix packaging, and initial multi-archive web UI foundation. + +**Current Architecture:** Archivr is a Rust workspace. `archivr-core` owns archive behavior and SQLite access. `archivr-cli` writes archives. `archivr-server` reads mounted archive databases and serves the static browser UI. + +**Tech Stack:** Rust, SQLite via `rusqlite`, Axum, static HTML/CSS/JavaScript, Nix flakes. + +--- + +## Current State + +The project now has three crates: + +| Crate | Role | +|---|---| +| `crates/archivr-core` | Archive/domain logic, database schema, archive queries, downloader helpers | +| `crates/archivr-cli` | Terminal interface for `archivr init` and `archivr archive` | +| `crates/archivr-server` | Web server, mounted archive registry, JSON API, static web UI | + +The browser UI currently does these things: + +- Mounts one or more archives through `archivr-server.toml`. +- Lists archives. +- Lists root archive entries in a table. +- Supports simple client-side text filtering. +- Fetches entry detail for a selected row. +- Lists archive runs. +- Shows a minimal admin/mounted-archives view. + +The browser UI does not yet do these things: + +- Open stored artifacts. +- Serve archived files through stable URLs. +- Show rich entry details. +- Search on the server or through SQLite FTS. +- Manage hierarchical tags. +- Capture new material from the browser. +- Provide production auth/session behavior. + +## Product Direction Already Decided + +These preferences came from the design discussion: + +- The first screen should be the archive table, not a stats dashboard. +- Capture should exist as a button/dialog, not as the main landing workflow. +- The UI should remain table-forward and practical. +- The table should use sans-serif typography. +- The right sidebar/detail rail idea is good. +- Search should become a major, powerful feature. +- Do not use "categories" as product language. +- Use "tags" or "hierarchical tags" for the tree-shaped organization model. +- Archives remain self-contained directories with their own `.archivr` database. +- The server can mount many archive directories, but it has its own registry config. + +## Recommended Order + +1. Make entry details and artifact access useful. +2. Define and implement search v1. +3. Rename/complete hierarchical tags. +4. Add browser capture. +5. Decide auth/session boundaries. + +This order makes the UI increasingly useful without forcing organization work or auth decisions too early. + +--- + +# Track 1: Entry Detail And Artifact Access Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:writing-plans` to expand this track before implementation. Steps should use checkbox syntax for tracking. + +**Goal:** Let a user select an entry and inspect/open the files Archivr saved for it. + +**Architecture:** Add stable artifact-serving routes to `archivr-server`, backed by archive metadata from `archivr-core`. Keep the static UI simple: selected row opens a right-side detail rail with metadata, original URL, structured root, and artifact links. + +**Tech Stack:** Rust, Axum, `tower_http::services::ServeFile` or explicit file responses, static JavaScript. + +## Files + +| File | Responsibility | +|---|---| +| `crates/archivr-core/src/archive.rs` | Resolve an entry artifact to a safe on-disk path under the archive store | +| `crates/archivr-core/src/database.rs` | Add query helpers only if `archive.rs` cannot use existing schema cleanly | +| `crates/archivr-server/src/routes.rs` | Add artifact-serving API route | +| `crates/archivr-server/static/app.js` | Render selected-entry detail and artifact links | +| `crates/archivr-server/static/styles.css` | Make the detail rail readable and table selection obvious | +| `crates/archivr-server/src/routes.rs` tests | Cover missing archive, missing entry, missing artifact, and valid artifact response | + +## Proposed API + +```text +GET /api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index +``` + +`artifact_index` is the zero-based index from `EntryDetail.artifacts`. This avoids exposing arbitrary relative paths in URLs for v1. + +## Acceptance Criteria + +- Selecting a row shows title, source kind, entity kind, original URL, visibility, archive timestamp, structured root, and artifacts. +- Artifact links open in a new tab. +- Invalid archive IDs return `404`. +- Invalid entry UIDs return `404`. +- Invalid artifact indexes return `404`. +- Resolved paths cannot escape the configured archive store directory. +- Existing `cargo test` passes. +- A browser smoke test can select a row and see at least one artifact link for an archive with artifacts. + +## Key Risk + +Path safety matters. Do not concatenate request path strings into filesystem paths. Resolve artifact paths from database rows, join them against the trusted store path, canonicalize where possible, and reject paths outside the store. + +--- + +# Track 2: Search V1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:brainstorming` first if changing query language behavior. Then use `superpowers:writing-plans` before implementation. + +**Goal:** Replace simple client-side filtering with a real search API that can become Archivr's "OP search" foundation. + +**Architecture:** Start with server-side structured filtering over existing SQLite columns. Keep query parsing small and explicit. Defer full SQLite FTS until the fields and syntax feel right. + +**Tech Stack:** Rust, Axum query extractors, SQLite queries with bound parameters, static JavaScript. + +## Files + +| File | Responsibility | +|---|---| +| `crates/archivr-core/src/archive.rs` | Add `SearchEntriesQuery` and `search_entries` | +| `crates/archivr-server/src/routes.rs` | Add query parameters to entries endpoint or add `/search` endpoint | +| `crates/archivr-server/static/app.js` | Debounce search input and call server | +| `crates/archivr-server/static/index.html` | Keep one prominent search bar | +| `crates/archivr-server/static/styles.css` | Style search states without making the UI feel like a dashboard | + +## Recommended API + +```text +GET /api/archives/:archive_id/entries/search?q=...&source_kind=...&entity_kind=...&from=...&to=... +``` + +Keep `/entries` as the default unfiltered archive table. + +## Query Language V1 + +Support plain text first: + +```text +polymarket +``` + +Then add explicit prefixes: + +```text +source:x +type:tweet +url:medium.com +title:"resume templates" +after:2026-01-01 +before:2026-04-01 +``` + +Do not implement fuzzy search, stemming, ranking, or boolean logic in v1. + +## Acceptance Criteria + +- Empty search returns the same rows as `/entries`. +- Plain text searches title, original URL, entry UID, source kind, entity kind, and visibility. +- Prefix filters are parsed deterministically. +- Unknown prefixes return `400` with a helpful message. +- Search uses SQL parameters, not string interpolation. +- The UI clearly distinguishes loading, no results, and error states. +- Existing `cargo test` passes. + +## Key Risk + +Search can sprawl. Keep v1 boring and correct, then iterate toward FTS/ranking after the query language feels right. + +--- + +# Track 3: Hierarchical Tags Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:writing-plans` before implementation. + +**Goal:** Make the existing tree organization model use product language: tags and hierarchical tags, not taxonomy/categories. + +**Architecture:** Rename remaining domain types and APIs from taxonomy language to tag language where doing so does not break migrations unnecessarily. Keep the database table rename decision explicit: either preserve existing table names internally for compatibility, or migrate to `tags` and `entry_tag_assignments`. + +**Tech Stack:** Rust, SQLite migrations/schema initialization, static JavaScript. + +## Files + +| File | Responsibility | +|---|---| +| `crates/archivr-core/src/database.rs` | Rename schema helpers/types/tests from taxonomy to tags | +| `crates/archivr-core/src/archive.rs` | Expose tag-tree and entry-tag APIs | +| `crates/archivr-server/src/routes.rs` | Add tag tree and entry assignment endpoints | +| `crates/archivr-server/static/app.js` | Render tag filters in the sidebar/detail rail | +| `crates/archivr-server/static/styles.css` | Style tag tree compactly | +| `PLAN.md` | Update old design language so future agents do not reintroduce "taxonomy" product language | + +## Naming Decision + +Preferred product names: + +- `Tag` +- `TagNode` if a code type needs to emphasize tree structure +- `EntryTagAssignment` +- `full_path`, such as `/sciences/computer-science/compilers` + +Avoid product-visible names: + +- category +- taxonomy +- collection + +## Acceptance Criteria + +- Public/user-facing docs say "tags" or "hierarchical tags". +- No UI text says "taxonomy" or "categories". +- A tag can have a parent. +- An entry can be assigned to the most specific tag. +- Filtering by an ancestor tag can include descendant tags. +- Existing database tests continue to pass. +- New tests cover parent, child, and ancestor query behavior. + +## Key Risk + +Do not turn tags into first-screen triage work. The archive table remains the first screen; tags support filtering and detail context. + +--- + +# Track 4: Browser Capture Button Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:writing-plans` before implementation. + +**Goal:** Add a small browser capture workflow that starts archive runs from the web UI without making capture the landing page. + +**Architecture:** Add a POST endpoint in `archivr-server` that delegates to `archivr-core` capture/archive logic. The UI gets a compact Capture button and dialog. Runs appear in the Runs tab. + +**Tech Stack:** Rust, Axum POST routes, Serde JSON, existing downloader code, static JavaScript. + +## Files + +| File | Responsibility | +|---|---| +| `crates/archivr-core/src/archive.rs` | Factor CLI archive operation into reusable core function if needed | +| `crates/archivr-cli/src/main.rs` | Keep CLI as a thin adapter over core capture logic | +| `crates/archivr-server/src/routes.rs` | Add `POST /api/archives/:archive_id/captures` | +| `crates/archivr-server/static/index.html` | Add Capture button and dialog markup | +| `crates/archivr-server/static/app.js` | Submit capture request and refresh entries/runs | +| `crates/archivr-server/static/styles.css` | Style dialog and disabled/loading states | + +## Proposed API + +```http +POST /api/archives/:archive_id/captures +Content-Type: application/json + +{ + "locator": "tweet:1234567890" +} +``` + +Successful v1 response: + +```json +{ + "run_uid": "run_...", + "status": "completed" +} +``` + +## Acceptance Criteria + +- Capture is a button, not the primary page. +- Empty locator returns `400`. +- Unsupported locator returns a useful error, not a panic. +- Successful capture creates a run and at least one entry. +- After success, the UI refreshes entries and runs. +- The server route reuses core archive logic rather than duplicating CLI behavior. +- Existing `cargo test` passes. + +## Key Risk + +Long-running captures will eventually need async job tracking. For v1, it is acceptable for the request to run synchronously if the UI clearly shows loading and errors. + +--- + +# Track 5: Local Auth And Session Boundary Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:brainstorming` before implementation because this affects product/security boundaries. + +**Goal:** Decide and document what "local development auth" means before adding public/admin behavior. + +**Architecture:** Prefer a local-first model until remote/public hosting is real. Avoid half-building production auth. Document which endpoints are trusted-local and which are public-safe. + +**Tech Stack:** Rust, Axum middleware later if needed, browser local UI. + +## Files + +| File | Responsibility | +|---|---| +| `ARCHIVR-MENTAL-MODEL.md` | Document local-only assumption or chosen auth boundary | +| `docs/README.md` | Tell users whether to expose `archivr-server` to a network | +| `crates/archivr-server/src/main.rs` | Bind address configuration if needed | +| `crates/archivr-server/src/routes.rs` | Add middleware only after the model is chosen | + +## Recommended Decision For Now + +Keep `archivr-server` local-only: + +```text +127.0.0.1:8080 +``` + +Do not advertise it as safe to expose publicly. + +## Acceptance Criteria + +- Docs clearly say whether the server is local-only. +- The default bind address remains loopback. +- If bind address becomes configurable, docs explain the risk of using `0.0.0.0`. +- No public-sharing feature is implemented before auth/session requirements are chosen. + +## Key Risk + +Public archive visibility exists in the database model, but that is not the same thing as a secure public web server. + +--- + +# Track 6: Plan Cleanup Implementation Plan + +> **For agentic workers:** This track can be done without product brainstorming. Use `superpowers:executing-plans` if implementing. + +**Goal:** Remove stale planning ambiguity so future threads do not confuse old database plans with current roadmap. + +**Architecture:** Keep root docs small and explicit. `PLAN.md` is currently an older database design plan; either rename it or add a status note at the top. + +**Tech Stack:** Markdown only. + +## Files + +| File | Responsibility | +|---|---| +| `PLAN.md` | Mark as historical database design plan, or rename to `DATABASE-DESIGN-PLAN.md` | +| `ARCHIVR-MENTAL-MODEL.md` | Link to this handoff and any retained historical plan | +| `docs/README.md` | Link to current docs only if useful for users | + +## Acceptance Criteria + +- A future agent can tell which document is current architecture, which is historical design, and which is next-work planning. +- No recreated `docs/superpowers/` directory. +- No `.gitignore` exception is added for planning docs unless the user explicitly asks. + +## Key Risk + +Do not delete useful database context. The old plan contains schema rationale that may still be valuable. + +--- + +## Suggested First New Thread Prompt + +Use this in the next thread if the goal is to continue product development: + +```text +Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 1: Entry Detail And Artifact Access. Create a task-level implementation plan first, then wait for approval. +``` + +If the goal is search: + +```text +Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to design Search V1. Start with brainstorming the query language and API boundary, then write an implementation plan. +``` + +If the goal is tags: + +```text +Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement hierarchical tags and remove taxonomy/category language. Write the implementation plan first. +``` + +## Self-Review + +- Spec coverage: This handoff covers the next UI usefulness work, search, hierarchical tags, web capture, auth boundary, and plan cleanup. +- Placeholder scan: No unresolved implementation slots are used as required work. +- Type consistency: The document uses current crate names and current API concepts from `ARCHIVR-MENTAL-MODEL.md`. From dcf9e127bba6492ad754cac65e011cac13a943dc Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:42:23 +0200 Subject: [PATCH 03/14] feat: entry detail view and artifact file serving --- crates/archivr-core/src/archive.rs | 59 ++ crates/archivr-server/Cargo.toml | 1 + crates/archivr-server/src/routes.rs | 106 +++- crates/archivr-server/static/app.js | 79 ++- crates/archivr-server/static/styles.css | 58 ++ ...-21-track1-entry-detail-artifact-access.md | 575 ++++++++++++++++++ 6 files changed, 868 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-21-track1-entry-detail-artifact-access.md diff --git a/crates/archivr-core/src/archive.rs b/crates/archivr-core/src/archive.rs index 0d507c0..0e52846 100644 --- a/crates/archivr-core/src/archive.rs +++ b/crates/archivr-core/src/archive.rs @@ -278,6 +278,31 @@ pub fn list_runs(conn: &rusqlite::Connection) -> Result> { Ok(runs) } +/// 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`). +/// The returned path is canonicalized. Returns an error if the resolved path +/// escapes `store_path` (path traversal protection) or if the file does not exist. +pub fn resolve_artifact_path( + store_path: &Path, + artifact: &EntryArtifactSummary, +) -> Result { + let joined = store_path.join(&artifact.relpath); + let canonical_store = store_path + .canonicalize() + .with_context(|| format!("failed to canonicalize store path: {}", store_path.display()))?; + let canonical_artifact = joined + .canonicalize() + .with_context(|| format!("artifact path does not exist: {}", joined.display()))?; + if !canonical_artifact.starts_with(&canonical_store) { + bail!( + "artifact path escapes store: {}", + canonical_artifact.display() + ); + } + Ok(canonical_artifact) +} + #[cfg(test)] mod tests { use super::*; @@ -435,4 +460,38 @@ mod tests { assert_eq!(runs.len(), 1); assert_eq!(runs[0].status, "completed"); } + + #[test] + fn resolve_artifact_path_returns_absolute_path_within_store() { + let root = unique_path("archivr-resolve-artifact"); + let store_path = root.join("store"); + fs::create_dir_all(store_path.join("raw/a/b")).unwrap(); + let artifact_file = store_path.join("raw/a/b/abc.pdf"); + fs::write(&artifact_file, b"data").unwrap(); + + let artifact = EntryArtifactSummary { + artifact_role: "primary".to_string(), + storage_area: "raw".to_string(), + relpath: "raw/a/b/abc.pdf".to_string(), + byte_size: Some(4), + }; + let resolved = resolve_artifact_path(&store_path, &artifact).unwrap(); + assert_eq!(resolved, artifact_file.canonicalize().unwrap()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn resolve_artifact_path_rejects_traversal() { + let root = unique_path("archivr-resolve-traversal"); + let store_path = root.join("store"); + fs::create_dir_all(&store_path).unwrap(); + let artifact = EntryArtifactSummary { + artifact_role: "primary".to_string(), + storage_area: "raw".to_string(), + relpath: "../escaped.txt".to_string(), + byte_size: None, + }; + assert!(resolve_artifact_path(&store_path, &artifact).is_err()); + let _ = fs::remove_dir_all(&root); + } } diff --git a/crates/archivr-server/Cargo.toml b/crates/archivr-server/Cargo.toml index b90f678..083d1d6 100644 --- a/crates/archivr-server/Cargo.toml +++ b/crates/archivr-server/Cargo.toml @@ -11,6 +11,7 @@ serde.workspace = true tokio.workspace = true toml.workspace = true tower-http.workspace = true +tower.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index dff563b..e991890 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -3,12 +3,14 @@ use std::{path::PathBuf, sync::Arc}; use archivr_core::{archive, database}; use axum::{ Json, Router, - extract::{Path, State}, + body::Body, + extract::{Path, Request, State}, http::StatusCode, response::{IntoResponse, Response}, routing::get, }; use tower_http::services::{ServeDir, ServeFile}; +use tower::ServiceExt; use crate::registry::{MountedArchive, ServerRegistry}; @@ -31,6 +33,10 @@ pub fn app(registry: ServerRegistry) -> Router { "/api/archives/:archive_id/entries/:entry_uid", get(entry_detail), ) + .route( + "/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index", + get(serve_artifact), + ) .route("/api/archives/:archive_id/runs", get(list_runs)) .nest_service("/assets", ServeDir::new(&static_dir)) .fallback_service(ServeFile::new(static_dir.join("index.html"))) @@ -76,6 +82,30 @@ async fn list_runs( Ok(Json(archive::list_runs(&conn)?)) } +async fn serve_artifact( + State(state): State, + Path((archive_id, entry_uid, artifact_index)): Path<(String, String, usize)>, + req: Request, +) -> Result { + let mounted = mounted_archive(&state, &archive_id)?; + let paths = archive::read_archive_paths(&mounted.archive_path)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + let detail = archive::get_entry_detail(&conn, &entry_uid)? + .ok_or(ApiError::not_found("entry not found"))?; + let artifact = detail + .artifacts + .get(artifact_index) + .ok_or(ApiError::not_found("artifact index out of range"))?; + let file_path = archive::resolve_artifact_path(&paths.store_path, artifact)?; + // ServeFile streams the file, handles Range requests (video seeking), + // sets Content-Type/ETag/Last-Modified. Error type is Infallible. + Ok(ServeFile::new(&file_path) + .oneshot(req) + .await + .unwrap() + .into_response()) +} + fn mounted_archive<'a>( state: &'a AppState, archive_id: &str, @@ -165,4 +195,78 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND); } + + #[tokio::test] + async fn artifact_missing_archive_returns_404() { + let response = app(ServerRegistry::default()) + .oneshot( + Request::builder() + .uri("/api/archives/nope/entries/entry_abc/artifacts/0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn artifact_missing_entry_returns_404() { + let dir = tempfile::tempdir().unwrap(); + archivr_core::archive::initialize_archive( + dir.path(), + &dir.path().join("store"), + "test", + false, + ) + .unwrap(); + let archive_path = dir.path().join(".archivr"); + let registry = ServerRegistry { + archives: vec![MountedArchive { + id: "test".to_string(), + label: "Test".to_string(), + archive_path, + }], + }; + let response = app(registry) + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/entry_doesnotexist/artifacts/0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn artifact_out_of_range_index_returns_404() { + let dir = tempfile::tempdir().unwrap(); + archivr_core::archive::initialize_archive( + dir.path(), + &dir.path().join("store"), + "test", + false, + ) + .unwrap(); + let archive_path = dir.path().join(".archivr"); + let registry = ServerRegistry { + archives: vec![MountedArchive { + id: "test".to_string(), + label: "Test".to_string(), + archive_path, + }], + }; + let response = app(registry) + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/entry_doesnotexist/artifacts/99") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } } diff --git a/crates/archivr-server/static/app.js b/crates/archivr-server/static/app.js index 68b1616..cab3dd8 100644 --- a/crates/archivr-server/static/app.js +++ b/crates/archivr-server/static/app.js @@ -132,22 +132,83 @@ function renderEntries() { function renderContextDetail(detail) { contextBody.innerHTML = ""; - const title = document.createElement("strong"); - title.textContent = valueText(detail.summary.title) || valueText(detail.summary.entry_uid); - contextBody.append(title); - const items = [ + // Title + const titleEl = document.createElement("strong"); + titleEl.className = "rail-entry-title"; + titleEl.textContent = + valueText(detail.summary.title) || valueText(detail.summary.entry_uid); + contextBody.append(titleEl); + + // Metadata section + const metaSection = document.createElement("div"); + metaSection.className = "rail-section"; + + if (detail.summary.original_url) { + const urlRow = document.createElement("div"); + urlRow.className = "rail-item"; + const urlLabel = document.createElement("span"); + urlLabel.className = "rail-label"; + urlLabel.textContent = "Original URL"; + const urlLink = document.createElement("a"); + urlLink.href = detail.summary.original_url; + urlLink.target = "_blank"; + urlLink.rel = "noopener noreferrer"; + urlLink.className = "rail-url-link"; + urlLink.textContent = detail.summary.original_url; + urlRow.append(urlLabel, document.createTextNode(": "), urlLink); + metaSection.append(urlRow); + } + + const metaFields = [ + ["Added", detail.summary.archived_at], + ["Source", detail.summary.source_kind], ["Type", detail.summary.entity_kind], ["Visibility", detail.summary.visibility], - ["Artifacts", detail.artifacts.length], ["Structured root", detail.structured_root_relpath], ]; - - for (const [label, value] of items) { + for (const [label, value] of metaFields) { const item = document.createElement("div"); item.className = "rail-item"; - item.textContent = `${label}: ${valueText(value)}`; - contextBody.append(item); + const labelEl = document.createElement("span"); + labelEl.className = "rail-label"; + labelEl.textContent = label; + item.append(labelEl, document.createTextNode(`: ${valueText(value)}`)); + metaSection.append(item); + } + contextBody.append(metaSection); + + // Artifacts section + if (detail.artifacts.length > 0) { + const artifactsSection = document.createElement("div"); + artifactsSection.className = "rail-section"; + const artifactsHeading = document.createElement("div"); + artifactsHeading.className = "rail-section-heading"; + artifactsHeading.textContent = `Artifacts (${detail.artifacts.length})`; + artifactsSection.append(artifactsHeading); + const list = document.createElement("ul"); + list.className = "artifact-list"; + detail.artifacts.forEach((artifact, index) => { + const li = document.createElement("li"); + const a = document.createElement("a"); + a.href = `/api/archives/${state.archiveId}/entries/${detail.summary.entry_uid}/artifacts/${index}`; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.className = "artifact-link"; + const roleName = artifact.artifact_role.replace(/_/g, " "); + const size = + artifact.byte_size != null ? ` (${formatBytes(artifact.byte_size)})` : ""; + a.textContent = `${roleName}${size}`; + li.append(a); + list.append(li); + }); + artifactsSection.append(list); + contextBody.append(artifactsSection); + } else { + const noArtifacts = document.createElement("div"); + noArtifacts.className = "rail-item muted"; + noArtifacts.textContent = "No artifacts."; + contextBody.append(noArtifacts); } } diff --git a/crates/archivr-server/static/styles.css b/crates/archivr-server/static/styles.css index c0e9216..8e338e6 100644 --- a/crates/archivr-server/static/styles.css +++ b/crates/archivr-server/static/styles.css @@ -312,3 +312,61 @@ select { min-width: 860px; } } + +.rail-entry-title { + display: block; + font-size: 15px; + font-weight: 700; + color: var(--ink); + margin-bottom: 12px; + line-height: 1.4; +} + +.rail-section { + margin-bottom: 18px; +} + +.rail-section-heading { + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent); + margin-bottom: 6px; +} + +.rail-label { + font-weight: 600; + color: var(--ink); +} + +.rail-url-link { + color: var(--accent); + word-break: break-all; + font-size: 13px; +} + +.artifact-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.artifact-link { + display: block; + padding: 6px 8px; + background: var(--paper-3); + border: 1px solid var(--line); + color: var(--accent); + text-decoration: none; + font-size: 13px; + border-radius: 3px; +} + +.artifact-link:hover { + background: var(--line); + text-decoration: underline; +} diff --git a/docs/superpowers/plans/2026-06-21-track1-entry-detail-artifact-access.md b/docs/superpowers/plans/2026-06-21-track1-entry-detail-artifact-access.md new file mode 100644 index 0000000..e0845dc --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-track1-entry-detail-artifact-access.md @@ -0,0 +1,575 @@ +# Track 1: Entry Detail And Artifact Access — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a user select an archive entry and inspect/open the files Archivr saved for it, served through a safe stable URL. + +**Architecture:** Add `resolve_artifact_path` to `archivr-core` for safe path resolution, add a `serve_artifact` handler in `archivr-server` that streams the resolved file via `tower_http::services::ServeFile` (handles HTTP Range requests, ETags, and streaming — no full-file buffering), and expand the static UI's context rail to render all entry metadata and clickable artifact links. + +**Tech Stack:** Rust, Axum 0.7, `tower_http::services::ServeFile`, static HTML/CSS/JavaScript. No new crate dependencies — `tower` moves from dev-dep to dep within the workspace. + +--- + +## Files + +| File | Change | +|---|---| +| `crates/archivr-server/Cargo.toml` | Move `tower` from `[dev-dependencies]` to `[dependencies]` | +| `crates/archivr-core/src/archive.rs` | Add `resolve_artifact_path(store_path, artifact)` returning a safe `PathBuf` | +| `crates/archivr-server/src/routes.rs` | Add `serve_artifact` handler, register route, add 3 tests | +| `crates/archivr-server/static/app.js` | Expand `renderContextDetail` to show all fields + artifact links | +| `crates/archivr-server/static/styles.css` | Add `.artifact-list`, `.artifact-link`, `.rail-section` styles | + +--- + +### Task 1: `resolve_artifact_path` in archivr-core + +**Files:** +- Modify: `crates/archivr-core/src/archive.rs` + +- [ ] **Step 1: Write the failing tests in the existing `mod tests` block** + + Add to the `mod tests` block at the bottom of `crates/archivr-core/src/archive.rs`: + + ```rust + #[test] + fn resolve_artifact_path_returns_absolute_path_within_store() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path(); + std::fs::create_dir_all(store_path.join("raw/a/b")).unwrap(); + let artifact_file = store_path.join("raw/a/b/abc.pdf"); + std::fs::write(&artifact_file, b"data").unwrap(); + + let artifact = EntryArtifactSummary { + artifact_role: "primary".to_string(), + storage_area: "raw".to_string(), + relpath: "raw/a/b/abc.pdf".to_string(), + byte_size: Some(4), + }; + let resolved = resolve_artifact_path(store_path, &artifact).unwrap(); + assert_eq!(resolved, artifact_file.canonicalize().unwrap()); + } + + #[test] + fn resolve_artifact_path_rejects_traversal() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path(); + let artifact = EntryArtifactSummary { + artifact_role: "primary".to_string(), + storage_area: "raw".to_string(), + relpath: "../escaped.txt".to_string(), + byte_size: None, + }; + assert!(resolve_artifact_path(store_path, &artifact).is_err()); + } + ``` + + Note: `tempfile` is already a dev-dependency in `archivr-core`. Check that `use super::*;` is already at the top of the `mod tests` block (it is). + +- [ ] **Step 2: Run the tests to see them fail** + + ```bash + cargo test -p archivr-core resolve_artifact 2>&1 + ``` + + Expected: compile error — `resolve_artifact_path` not defined yet. + +- [ ] **Step 3: Implement `resolve_artifact_path`** + + Add this function to `crates/archivr-core/src/archive.rs`, after `list_runs` and before `#[cfg(test)]`: + + ```rust + /// 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`). + /// The returned path is canonicalized. Returns an error if the resolved path + /// escapes `store_path` (path traversal protection) or if the file does not exist. + pub fn resolve_artifact_path( + store_path: &Path, + artifact: &EntryArtifactSummary, + ) -> Result { + let joined = store_path.join(&artifact.relpath); + let canonical_store = store_path + .canonicalize() + .with_context(|| format!("failed to canonicalize store path: {}", store_path.display()))?; + let canonical_artifact = joined + .canonicalize() + .with_context(|| format!("artifact path does not exist: {}", joined.display()))?; + if !canonical_artifact.starts_with(&canonical_store) { + bail!( + "artifact path escapes store: {}", + canonical_artifact.display() + ); + } + Ok(canonical_artifact) + } + ``` + +- [ ] **Step 4: Run the tests to see them pass** + + ```bash + cargo test -p archivr-core resolve_artifact 2>&1 + ``` + + Expected: both tests PASS. + +- [ ] **Step 5: Run full core test suite to confirm no regressions** + + ```bash + cargo test -p archivr-core 2>&1 + ``` + + Expected: all tests PASS. + +- [ ] **Step 6: Commit** + + ```bash + git add crates/archivr-core/src/archive.rs + git commit -m "feat(core): add resolve_artifact_path with path traversal protection" + ``` + +--- + +### Task 2: `serve_artifact` route in archivr-server + +**Files:** +- Modify: `crates/archivr-server/Cargo.toml` +- Modify: `crates/archivr-server/src/routes.rs` + +Route: `GET /api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index` + +`artifact_index` is the zero-based index into `EntryDetail.artifacts`. This avoids any raw filesystem path in the URL. `ServeFile` streams the file and handles HTTP Range requests — browsers can seek in `