mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat: entry detail view and artifact file serving
This commit is contained in:
parent
9337a822ad
commit
dcf9e127bb
6 changed files with 868 additions and 10 deletions
|
|
@ -278,6 +278,31 @@ pub fn list_runs(conn: &rusqlite::Connection) -> Result<Vec<RunSummary>> {
|
|||
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<PathBuf> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<AppState>,
|
||||
Path((archive_id, entry_uid, artifact_index)): Path<(String, String, usize)>,
|
||||
req: Request,
|
||||
) -> Result<Response, ApiError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue