diff --git a/crates/archivr-cli/src/main.rs b/crates/archivr-cli/src/main.rs index 4cf56a4..22606be 100644 --- a/crates/archivr-cli/src/main.rs +++ b/crates/archivr-cli/src/main.rs @@ -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(()) } diff --git a/crates/archivr-core/src/capture.rs b/crates/archivr-core/src/capture.rs index d85ceaf..603faa6 100644 --- a/crates/archivr-core/src/capture.rs +++ b/crates/archivr-core/src/capture.rs @@ -675,7 +675,11 @@ fn fail_run( anyhow::anyhow!("{}", message) } -pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result { +pub fn perform_capture( + archive_paths: &ArchivePaths, + locator: &str, + archive_id: Option<&str>, +) -> Result { let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S%.3f").to_string(); let store_path = &archive_paths.store_path; @@ -790,13 +794,32 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result Result Result Result Option { + 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::*; diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 3441f5e..810ae64 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -71,6 +71,10 @@ pub fn app(registry: ServerRegistry) -> Router { "/api/archives/:archive_id/entries/:entry_uid/favicon", get(serve_entry_favicon), ) + .route( + "/api/archives/:archive_id/blobs/:sha256", + get(serve_blob), + ) .route("/api/archives/:archive_id/runs", get(list_runs)) .route("/api/archives/:archive_id/captures", post(capture_handler)) .route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler)) @@ -188,6 +192,38 @@ async fn serve_entry_favicon( .unwrap() .into_response()) } +async fn serve_blob( + State(state): State, + Path((archive_id, sha256)): Path<(String, String)>, + 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 blob = database::get_blob_by_sha256(&conn, &sha256)? + .ok_or(ApiError::not_found("blob not found"))?; + + let file_path = paths.store_path.join(&blob.raw_relpath); + + // Path-traversal guard: resolved path must stay inside store_path. + let canonical_file = file_path + .canonicalize() + .map_err(|_| ApiError::not_found("blob file not found"))?; + let canonical_store = paths + .store_path + .canonicalize() + .map_err(|_| ApiError::internal("invalid store path"))?; + if !canonical_file.starts_with(&canonical_store) { + return Err(ApiError::not_found("blob not found")); + } + + Ok(ServeFile::new(&canonical_file) + .oneshot(req) + .await + .unwrap() + .into_response()) +} #[derive(Debug, serde::Deserialize)] struct CreateTagBody { @@ -279,7 +315,7 @@ async fn capture_handler( let mounted = mounted_archive(&state, &archive_id)?; let archive_paths = archive::read_archive_paths(&mounted.archive_path) .map_err(ApiError::from)?; - let result = capture::perform_capture(&archive_paths, &body.locator) + let result = capture::perform_capture(&archive_paths, &body.locator, Some(&archive_id)) .map_err(ApiError::from)?; Ok(Json(result)) } @@ -316,6 +352,13 @@ impl ApiError { message: message.to_string(), } } + + fn internal(message: &str) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: message.to_string(), + } + } } impl From for ApiError @@ -1004,4 +1047,36 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } + + #[tokio::test] + async fn get_blob_returns_404_for_unknown_sha256() { + 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, + }], + bind: None, + }; + let response = app(registry) + .oneshot( + Request::builder() + .uri("/api/archives/test/blobs/0000000000000000000000000000000000000000000000000000000000000000") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + }