mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +02:00
feat(fonts): wire font extraction into capture pipeline + blob serving route
- capture.rs: add archive_id: Option<&str> to perform_capture; when Some, call font_extractor::extract_and_rewrite before hashing HTML, register each font as a deduplicated blob + 'font' artifact - main.rs: pass None as archive_id (CLI keeps fonts embedded) - routes.rs: add GET /api/archives/:id/blobs/:sha256 (serve_blob handler), pass Some(&archive_id) to perform_capture in capture_handler, add ApiError::internal constructor
This commit is contained in:
parent
22b0a55730
commit
351e74df09
3 changed files with 142 additions and 9 deletions
|
|
@ -63,7 +63,7 @@ fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let archive_paths = archive::read_archive_paths(&archive_path)?;
|
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);
|
println!("Archived: run {}", result.run_uid);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -675,7 +675,11 @@ fn fail_run(
|
||||||
anyhow::anyhow!("{}", message)
|
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 timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S%.3f").to_string();
|
||||||
let store_path = &archive_paths.store_path;
|
let store_path = &archive_paths.store_path;
|
||||||
|
|
||||||
|
|
@ -790,13 +794,32 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<Ca
|
||||||
.join("temp")
|
.join("temp")
|
||||||
.join(×tamp)
|
.join(×tamp)
|
||||||
.join(format!("{timestamp}{file_extension}"));
|
.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;
|
.len() as i64;
|
||||||
|
(result.html_hash.clone(), size, vec![])
|
||||||
|
};
|
||||||
|
|
||||||
// 1. Move HTML to raw store (if this hash hasn't been seen before).
|
// 1. Move HTML to raw store (if this hash hasn't been seen before).
|
||||||
if !hash_exists(&result.html_hash, &file_extension, store_path)? {
|
if !hash_exists(&html_hash, &file_extension, store_path)? {
|
||||||
move_temp_to_raw(&temp_html, &result.html_hash, store_path)?;
|
move_temp_to_raw(&temp_html, &html_hash, store_path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Process favicon while the temp dir still exists.
|
// 2. Process favicon while the temp dir still exists.
|
||||||
|
|
@ -837,7 +860,7 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<Ca
|
||||||
locator,
|
locator,
|
||||||
locator,
|
locator,
|
||||||
source,
|
source,
|
||||||
&result.html_hash,
|
&html_hash,
|
||||||
&file_extension,
|
&file_extension,
|
||||||
byte_size,
|
byte_size,
|
||||||
result.title,
|
result.title,
|
||||||
|
|
@ -859,6 +882,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)?;
|
database::finish_archive_run(&conn, run.id)?;
|
||||||
return Ok(CaptureResult {
|
return Ok(CaptureResult {
|
||||||
run_uid: run.run_uid.clone(),
|
run_uid: run.run_uid.clone(),
|
||||||
|
|
@ -1019,6 +1067,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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@ pub fn app(registry: ServerRegistry) -> Router {
|
||||||
"/api/archives/:archive_id/entries/:entry_uid/favicon",
|
"/api/archives/:archive_id/entries/:entry_uid/favicon",
|
||||||
get(serve_entry_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/runs", get(list_runs))
|
||||||
.route("/api/archives/:archive_id/captures", post(capture_handler))
|
.route("/api/archives/:archive_id/captures", post(capture_handler))
|
||||||
.route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler))
|
.route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler))
|
||||||
|
|
@ -188,6 +192,38 @@ async fn serve_entry_favicon(
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
async fn serve_blob(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((archive_id, sha256)): Path<(String, String)>,
|
||||||
|
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 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)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct CreateTagBody {
|
struct CreateTagBody {
|
||||||
|
|
@ -279,7 +315,7 @@ async fn capture_handler(
|
||||||
let mounted = mounted_archive(&state, &archive_id)?;
|
let mounted = mounted_archive(&state, &archive_id)?;
|
||||||
let archive_paths = archive::read_archive_paths(&mounted.archive_path)
|
let archive_paths = archive::read_archive_paths(&mounted.archive_path)
|
||||||
.map_err(ApiError::from)?;
|
.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)?;
|
.map_err(ApiError::from)?;
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
@ -316,6 +352,13 @@ impl ApiError {
|
||||||
message: message.to_string(),
|
message: message.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn internal(message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
message: message.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E> From<E> for ApiError
|
impl<E> From<E> for ApiError
|
||||||
|
|
@ -1004,4 +1047,36 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue