1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

Compare commits

..

14 commits

Author SHA1 Message Date
ab353f9209
fix(singlefile): improve font capture and eliminate ES module viewer errors
Font fidelity:
- --browser-wait-delay=2000: Cloudflare Fonts injects @font-face CSS after
  HTML parse; the font hook needs extra time to see it after networkidle2
- --remove-unused-fonts=false: preserve @font-face rules even when fonts
  haven't rendered yet (font-display:swap, off-screen text)
- --remove-alternative-fonts=false: preserve unicode-range subsets instead
  of stripping them as 'alternatives'

ES module viewer error fix:
- Write a user script (sf-strip-scripts.js) that listens for
  single-file-on-before-capture-start and removes all <script> elements
  (except application/ld+json) from the live DOM before serialization.
  Scripts still execute during capture for CSS fidelity; none end up in
  the saved file, so no data:-URL base ES module resolution errors.
2026-06-24 21:30:14 +02:00
852cc45956
fix(singlefile): disable CSS stripping and script blocking for full render fidelity
Defaults that were destroying CSS fidelity:
- --remove-unused-styles=true: strips CSS nesting rules (site uses & selector)
  and any rule targeting JS-applied classes
- --remove-alternative-medias=true: deletes @media blocks that don't match
  the capture viewport, breaking responsive layout
- --block-scripts=true: prevents JS from applying classes before CSS snapshot

All three now set to false.
2026-06-24 21:15:36 +02:00
fbb96fbd05
build(frontend): update bundled assets after favicon+rendering changes 2026-06-24 21:05:35 +02:00
1239ad9f34
feat(ui): show website favicon in entry list for webpage entries 2026-06-24 19:16:21 +02:00
1206d7103d
feat(server): add GET /entries/:uid/favicon endpoint 2026-06-24 19:16:21 +02:00
cc2dbf4ac2
feat(archive): add has_favicon to EntrySummary 2026-06-24 19:16:21 +02:00
653efa9705
feat(capture): store favicon artifact for WebPage entries 2026-06-24 19:16:21 +02:00
ee697625fb
feat(singlefile): add SaveResult, favicon extraction, wait for networkidle2 2026-06-24 19:16:21 +02:00
ad1c4f8ee5
fix(nix): guard pkgs.chromium behind isLinux for aarch64-darwin 2026-06-24 18:46:11 +02:00
e70c6b37cd
docs: document single-file env vars, check off web page archiving milestone 2026-06-24 18:39:12 +02:00
c9ce0ee00b
feat(nix): add single-file-cli and chromium to both wrappers 2026-06-24 18:39:05 +02:00
fe9ff2dafe
feat(capture): add Source::WebPage, route HTML URLs to singlefile downloader 2026-06-24 18:39:02 +02:00
fd06632073
feat(singlefile): add single-file-cli downloader module 2026-06-24 18:38:57 +02:00
b7ab12898e
feat(http): add UrlKind enum and probe_url_kind HEAD probe 2026-06-24 18:38:52 +02:00
16 changed files with 594 additions and 20 deletions

2
Cargo.lock generated
View file

@ -106,6 +106,7 @@ name = "archivr-core"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"chrono",
"hex",
"regex",
@ -114,6 +115,7 @@ dependencies = [
"serde",
"serde_json",
"sha3",
"tempfile",
"uuid",
]

View file

@ -28,3 +28,4 @@ tower = "0.5.1"
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"

View file

@ -14,3 +14,7 @@ serde_json.workspace = true
sha3.workspace = true
uuid.workspace = true
reqwest = { workspace = true }
base64.workspace = true
[dev-dependencies]
tempfile = "3"

View file

@ -26,6 +26,8 @@ pub struct EntrySummary {
pub artifact_count: i64,
pub total_artifact_bytes: i64,
pub parent_entry_uid: Option<String>,
/// True if a `favicon` artifact exists for this entry.
pub has_favicon: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
@ -183,7 +185,8 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary
si.canonical_url,
COUNT(ea.id) AS artifact_count,
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes,
NULL AS parent_entry_uid
NULL AS parent_entry_uid,
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon
FROM archived_entries e
JOIN source_identities si ON si.id = e.source_identity_id
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id
@ -206,6 +209,7 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary
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<_>>>()?;
@ -383,7 +387,8 @@ const ENTRY_SELECT_COLS: &str =
"SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \
e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, \
parent.entry_uid AS parent_entry_uid";
parent.entry_uid AS parent_entry_uid, \
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon";
const ENTRY_FROM_JOINS: &str =
"FROM archived_entries e \
@ -483,6 +488,7 @@ pub fn search_entries(
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<_>>>()?;
@ -643,7 +649,8 @@ pub fn entries_for_tag(
SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title,
e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count,
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes,
parent.entry_uid AS parent_entry_uid
parent.entry_uid AS parent_entry_uid,
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon
FROM archived_entries e
JOIN source_identities si ON si.id = e.source_identity_id
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id
@ -667,6 +674,7 @@ pub fn entries_for_tag(
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<_>>>()?;

View file

@ -23,6 +23,7 @@ pub enum Source {
Snapchat,
Local,
Url,
WebPage,
Other,
}
@ -88,6 +89,7 @@ fn generate_entry_title(source: Source, meta: &PlatformMetadata) -> String {
Source::Snapchat => format!("Snap by {}", meta.author.as_deref().unwrap_or("unknown")),
Source::Local => meta.title.clone().unwrap_or_else(|| "Local File".to_string()),
Source::Url => meta.title.clone().unwrap_or_else(|| "Downloaded File".to_string()),
Source::WebPage => meta.title.clone().unwrap_or_else(|| "Archived Web Page".to_string()),
Source::Other => "Archived Content".to_string(),
}
}
@ -416,6 +418,7 @@ fn source_metadata(source: Source) -> (&'static str, &'static str, &'static str)
Source::Snapchat => ("snapchat", "story", "video"),
Source::Local => ("local", "file", "file"),
Source::Url => ("web", "file", "file"),
Source::WebPage => ("web", "page", "webpage"),
Source::Other => ("other", "unknown", "unknown"),
}
}
@ -679,7 +682,23 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<Ca
let conn = database::open_or_initialize(&archive_paths.archive_path)?;
let user_id = database::ensure_default_user(&conn)?;
let source = determine_source(locator);
let mut source = determine_source(locator);
// For generic http/https URLs, probe Content-Type to distinguish a plain
// file download from an HTML page that needs single-file archiving.
// The probe runs before creating DB records so source_kind is set correctly.
//
// Note: probe failures return early without a DB run record. This is
// intentional — a failed network probe means we haven't started archiving
// and there is nothing meaningful to record.
if source == Source::Url {
match downloader::http::probe_url_kind(locator) {
Ok(downloader::http::UrlKind::Html) => source = Source::WebPage,
Ok(downloader::http::UrlKind::File) => {}
Err(e) => return Err(anyhow::anyhow!("Failed to probe URL: {e}")),
}
}
let (source_kind, entity_kind, _) = source_metadata(source);
let run = database::create_archive_run(&conn, user_id, 1)?;
@ -762,6 +781,101 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result<Ca
}
}
// Source: web page — archive as a self-contained HTML snapshot via single-file-cli
if source == Source::WebPage {
match downloader::singlefile::save(locator, store_path, &timestamp) {
Ok(result) => {
let file_extension = ".html".to_string();
let temp_html = store_path
.join("temp")
.join(&timestamp)
.join(format!("{timestamp}{file_extension}"));
let byte_size = fs::metadata(&temp_html)
.with_context(|| format!("failed to stat staged file {}", temp_html.display()))?
.len() as i64;
// 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)?;
}
// 2. Process favicon while the temp dir still exists.
// Errors here are silenced — favicon is supplementary.
let favicon_info: Option<(String, i64)> = (|| -> Option<(String, i64)> {
let fav_hash = result.favicon_hash.as_deref()?;
let fav_ext = result.favicon_ext.as_deref()?;
let fav_temp = store_path
.join("temp").join(&timestamp)
.join(format!("{timestamp}.favicon{fav_ext}"));
if !fav_temp.exists() { return None; }
let fav_size = fs::metadata(&fav_temp).ok()?.len() as i64;
let fav_raw = raw_relative_path_from_hash(fav_hash, fav_ext).ok()?;
if !hash_exists(fav_hash, fav_ext, store_path).ok()? {
move_temp_to_raw(&fav_temp, fav_hash, store_path).ok()?;
}
let fav_blob = database::BlobRecord {
sha256: fav_hash.to_string(),
byte_size: fav_size,
mime_type: None,
extension: extension_without_dot(fav_ext),
raw_relpath: path_to_store_string(&fav_raw),
};
let fav_blob_id = database::upsert_blob(&conn, &fav_blob).ok()?;
Some((fav_blob.raw_relpath, fav_blob_id))
})();
// 3. Remove the temp directory.
let _ = fs::remove_dir_all(store_path.join("temp").join(&timestamp));
// 4. Create the entry + primary_media artifact.
let entry = record_media_entry(
&conn,
store_path,
user_id,
&run,
&item,
locator,
locator,
source,
&result.html_hash,
&file_extension,
byte_size,
result.title,
)?;
// 5. Add favicon artifact if we captured one.
if let Some((fav_relpath, fav_blob_id)) = favicon_info {
let _ = database::add_entry_artifact(
&conn,
&database::NewArtifact {
entry_id: entry.id,
artifact_role: "favicon".to_string(),
storage_area: "raw".to_string(),
relpath: fav_relpath,
blob_id: Some(fav_blob_id),
logical_path: None,
metadata_json: None,
},
);
}
database::finish_archive_run(&conn, run.id)?;
return Ok(CaptureResult {
run_uid: run.run_uid.clone(),
status: "completed".to_string(),
});
}
Err(e) => {
return Err(fail_run(
&conn,
&run,
&item,
&format!("Failed to archive web page: {e}"),
));
}
}
}
// Sources: Tweets or Twitter Threads
if matches!(source, Source::Tweet | Source::TweetThread) {
let tweet_id = match tweet_id_from_archive_path(locator) {
@ -1542,4 +1656,31 @@ mod tests {
assert_eq!(meta.caption, Some("Hello Rust world, this is a test tweet".to_string()));
}
}
#[test]
fn source_metadata_webpage() {
let (kind, entity, _) = source_metadata(Source::WebPage);
assert_eq!(kind, "web");
assert_eq!(entity, "page");
}
#[test]
fn generate_entry_title_webpage_with_title() {
let mut meta = PlatformMetadata::default();
meta.title = Some("Paul Graham \u{2014} Great Work".to_string());
assert_eq!(
generate_entry_title(Source::WebPage, &meta),
"Paul Graham \u{2014} Great Work"
);
}
#[test]
fn generate_entry_title_webpage_fallback() {
let meta = PlatformMetadata::default();
assert_eq!(
generate_entry_title(Source::WebPage, &meta),
"Archived Web Page"
);
}
}

View file

@ -3,6 +3,69 @@ use std::path::Path;
use crate::hash::hash_file;
/// Whether a URL resolves to an HTML document or a downloadable file.
#[derive(Debug, PartialEq, Eq)]
pub enum UrlKind {
Html,
File,
}
/// Probes `url` with a HEAD request and inspects the `Content-Type` header.
/// Falls back to a GET request (body not read) if the server returns 405.
///
/// Returns `Err` if the probe fails (network error, non-2xx/405 status).
/// Redirects (3xx) are followed automatically by reqwest; only the final
/// response status is checked.
pub fn probe_url_kind(url: &str) -> Result<UrlKind> {
let client = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::limited(10))
.user_agent("archivr/0.1")
.build()
.context("failed to build HTTP client")?;
// Prefer HEAD: no body transfer.
let head = client
.head(url)
.send()
.with_context(|| format!("failed to probe {url}"))?;
if head.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED {
// Server rejected HEAD — do a GET but only inspect headers.
let get = client
.get(url)
.send()
.with_context(|| format!("failed to probe {url}"))?;
if !get.status().is_success() {
bail!("HTTP {} probing {url}", get.status());
}
let ct = get
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
return Ok(if content_type_is_html(ct) {
UrlKind::Html
} else {
UrlKind::File
});
}
if !head.status().is_success() {
bail!("HTTP {} probing {url}", head.status());
}
let ct = head
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
Ok(if content_type_is_html(ct) {
UrlKind::Html
} else {
UrlKind::File
})
}
/// Returns `(sha256_hex, extension_with_leading_dot, title_hint)` on success.
/// `title_hint` is derived from the `Content-Disposition` filename header, or the
/// last path segment of the final URL after redirects.
@ -319,4 +382,22 @@ mod tests {
fn percent_decode_plus_is_space() {
assert_eq!(percent_decode("hello+world"), "hello world");
}
#[test]
fn url_kind_html_variants() {
// content_type_is_html is already tested; verify UrlKind is wired correctly
// by checking the enum values exist and are distinct.
assert_ne!(UrlKind::Html, UrlKind::File);
}
#[test]
fn probe_url_kind_fails_on_unreachable_host() {
// 127.0.0.1:1 is guaranteed to refuse connections.
let err = probe_url_kind("http://127.0.0.1:1/").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("probe") || msg.contains("connect") || msg.contains("refused"),
"unexpected error message: {msg}"
);
}
}

View file

@ -4,3 +4,4 @@ pub mod tweets;
pub mod ytdlp;
pub mod metadata;
pub mod http;
pub mod singlefile;

View file

@ -0,0 +1,273 @@
use anyhow::{Context, Result, bail};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use std::{env, io::Read, path::Path, process::Command};
use crate::hash::hash_file;
/// Result of archiving a web page with single-file.
#[derive(Debug)]
pub struct SaveResult {
/// SHA-256 hex of the archived `.html` file.
pub html_hash: String,
/// Page title from `<title>` tag, if present.
pub title: Option<String>,
/// SHA-256 hex of the extracted favicon, if present.
pub favicon_hash: Option<String>,
/// File extension for the favicon (e.g. `".ico"`, `".png"`), if present.
pub favicon_ext: Option<String>,
}
/// Archives `url` as a self-contained HTML snapshot.
///
/// Returns `(sha256_hex, title_hint)` on success.
/// - `sha256_hex`: hash of the saved `.html` file, used as the blob key.
/// - `title_hint`: page title extracted from the `<title>` tag, if present.
///
/// Reads two env vars:
/// - `ARCHIVR_SINGLE_FILE`: path to the `single-file` binary (default: `"single-file"`).
/// - `ARCHIVR_CHROME`: path to the Chromium/Chrome binary (default: `"chromium"`).
pub fn save(url: &str, store_path: &Path, timestamp: &str) -> Result<SaveResult> {
let single_file =
env::var("ARCHIVR_SINGLE_FILE").unwrap_or_else(|_| "single-file".to_string());
let chrome = env::var("ARCHIVR_CHROME").unwrap_or_else(|_| "chromium".to_string());
save_with(url, store_path, timestamp, &single_file, &chrome)
}
/// Inner implementation; takes binary paths explicitly so tests can inject them
/// without mutating process-global environment variables.
fn save_with(
url: &str,
store_path: &Path,
timestamp: &str,
single_file: &str,
chrome: &str,
) -> Result<SaveResult> {
let temp_dir = store_path.join("temp").join(timestamp);
std::fs::create_dir_all(&temp_dir).context("failed to create temp dir")?;
let out_file = temp_dir.join(format!("{timestamp}.html"));
// Write a user script that strips <script> elements from the live DOM
// just before SingleFile serializes it. This lets scripts execute during
// capture (so JS-applied CSS is present) without leaving data:-URL ES
// modules in the saved file that would cause "base scheme isn't
// hierarchical" errors in the viewer. JSON-LD structured data is kept.
let user_script_path = temp_dir.join("sf-strip-scripts.js");
std::fs::write(
&user_script_path,
"addEventListener('single-file-on-before-capture-start',()=>{\
document.querySelectorAll('script:not([type=\"application/ld+json\"])')\
.forEach(el=>el.remove());\
});",
)
.context("failed to write single-file user script")?;
let out = Command::new(single_file)
.arg(url)
.arg(&out_file)
.arg(format!("--browser-executable-path={chrome}"))
.arg("--browser-headless")
.arg("--browser-wait-until=networkidle2")
// 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")
// 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).
.arg("--remove-unused-styles=false")
.arg("--remove-alternative-medias=false")
// Allow scripts to run during capture so JS-applied classes exist in
// the DOM when CSS is evaluated. The user script above strips <script>
// elements before serialization so no broken module imports end up in
// the saved file.
.arg("--block-scripts=false")
.arg(format!("--browser-script={}", user_script_path.display()))
// Preserve fonts: defaults strip @font-face rules deemed "unused" or
// "alternative" (unicode-range subsets), losing CDN-served fonts.
.arg("--remove-unused-fonts=false")
.arg("--remove-alternative-fonts=false")
.output()
.with_context(|| format!("failed to spawn {single_file} process"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
bail!("single-file failed: {stderr}");
}
if !out_file.exists() {
bail!(
"single-file exited successfully but produced no output file at {}",
out_file.display()
);
}
let title = extract_html_title(&out_file);
let html_hash = hash_file(&out_file)?;
let (favicon_hash, favicon_ext) = extract_and_save_favicon(&out_file, &temp_dir, timestamp)
.map(|(h, e)| (Some(h), Some(e)))
.unwrap_or((None, None));
Ok(SaveResult { html_hash, title, favicon_hash, favicon_ext })
}
/// Reads the first 8 KiB of `path` and extracts the content of the first
/// `<title>…</title>` element. Returns `None` if absent or empty.
///
/// Uses `to_ascii_lowercase` for case-insensitive tag matching. ASCII-only
/// lowercasing is byte-length-preserving, so byte offsets derived from the
/// lowercased buffer are valid indices into the original buffer.
fn extract_html_title(path: &Path) -> Option<String> {
let mut buf = [0u8; 8192];
let n = std::fs::File::open(path).ok()?.read(&mut buf).ok()?;
// Recover a valid UTF-8 prefix if the 8 KiB boundary falls mid-character.
let snippet = match std::str::from_utf8(&buf[..n]) {
Ok(s) => s,
Err(e) => std::str::from_utf8(&buf[..e.valid_up_to()]).ok()?,
};
// ASCII-only lowercase: A-Z -> a-z, all other bytes unchanged.
// Byte lengths are identical to the original, so offsets are safe to reuse.
let lower = snippet.to_ascii_lowercase();
let tag_start = lower.find("<title>")?;
let content_start = tag_start + 7; // len("<title>") == 7
let content_end = content_start + lower[content_start..].find("</title>")?;
let title = snippet[content_start..content_end].trim();
if title.is_empty() { None } else { Some(title.to_string()) }
}
/// Extracts the favicon embedded in a single-file HTML archive.
///
/// Scans for a `<link rel="…icon…">` tag whose `href` is a `data:image/…;base64,…` URL.
/// Decodes the base64 payload, writes it to `{temp_dir}/{timestamp}.favicon.{ext}`,
/// hashes the file, and returns `(sha256_hex, ".ext")`.
/// All failures are silent (returns `None`) — a missing favicon is non-fatal.
fn extract_and_save_favicon(
html_path: &Path,
temp_dir: &Path,
timestamp: &str,
) -> Option<(String, String)> {
let html = std::fs::read_to_string(html_path).ok()?;
let lower = html.to_ascii_lowercase();
// Find a <link> tag that has rel="...icon..." AND href="data:image/..."
let link_start = {
let mut found = None;
let mut search = 0;
while search < lower.len() {
let off = lower[search..].find("<link")?;
let abs = search + off;
// Find end of this tag, respecting quoted attribute values so that
// a '>' inside a data URL does not terminate the tag prematurely.
let tag_slice = &lower[abs..];
let mut in_q = false;
let mut tag_end = None;
for (i, c) in tag_slice.char_indices() {
match c {
'"' => in_q = !in_q,
'>' if !in_q => { tag_end = Some(i); break; }
_ => {}
}
}
let tag_end = match tag_end { Some(e) => e, None => break };
let tag_s = &lower[abs..abs + tag_end];
if tag_s.contains("rel=") && tag_s.contains("icon") && tag_s.contains("href=\"data:image") {
found = Some(abs);
break;
}
search = abs + tag_end + 1;
}
found?
};
// Extract href value from the original HTML (byte positions match because
// to_ascii_lowercase is byte-length-preserving).
let tag_lower = &lower[link_start..];
let href_off = tag_lower.find("href=\"")?;
let value_start = link_start + href_off + 6; // past href="
let value_end = html[value_start..].find('"')?;
let data_url = &html[value_start..value_start + value_end];
// Parse data:<mime>;base64,<payload>
let rest = data_url.strip_prefix("data:")?;
let comma = rest.find(',')?;
let meta = &rest[..comma];
let b64 = &rest[comma + 1..];
if !meta.to_ascii_lowercase().contains("base64") {
return None;
}
let mime = meta.split(';').next()?.trim().to_ascii_lowercase();
let ext = mime_to_favicon_ext(&mime)?;
let bytes = B64.decode(b64.trim()).ok()?;
let out = temp_dir.join(format!("{timestamp}.favicon{ext}"));
std::fs::write(&out, &bytes).ok()?;
hash_file(&out).ok().map(|h| (h, ext.to_string()))
}
fn mime_to_favicon_ext(mime: &str) -> Option<&'static str> {
match mime {
"image/x-icon" | "image/vnd.microsoft.icon" => Some(".ico"),
"image/png" => Some(".png"),
"image/svg+xml" => Some(".svg"),
"image/jpeg" => Some(".jpg"),
"image/gif" => Some(".gif"),
"image/webp" => Some(".webp"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn extract_html_title_finds_title() {
let mut f = NamedTempFile::new().unwrap();
write!(f, "<html><head><title>Paul Graham — Great Work</title></head></html>").unwrap();
assert_eq!(
extract_html_title(f.path()),
Some("Paul Graham — Great Work".to_string())
);
}
#[test]
fn extract_html_title_case_insensitive() {
let mut f = NamedTempFile::new().unwrap();
write!(f, "<HTML><HEAD><TITLE>My Page</TITLE></HEAD></HTML>").unwrap();
assert_eq!(extract_html_title(f.path()), Some("My Page".to_string()));
}
#[test]
fn extract_html_title_empty_tag_returns_none() {
let mut f = NamedTempFile::new().unwrap();
write!(f, "<html><head><title> </title></head></html>").unwrap();
assert_eq!(extract_html_title(f.path()), None);
}
#[test]
fn extract_html_title_no_title_tag_returns_none() {
let mut f = NamedTempFile::new().unwrap();
write!(f, "<html><head></head><body>no title here</body></html>").unwrap();
assert_eq!(extract_html_title(f.path()), None);
}
#[test]
fn save_with_missing_binary_returns_clear_error() {
// Calls save_with directly — no env mutation, safe in parallel test runs.
let tmp = tempfile::tempdir().unwrap();
let result = save_with(
"https://example.com",
tmp.path(),
"test-ts",
"/nonexistent/single-file",
"chromium",
);
let err = result.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("spawn") || msg.contains("nonexistent") || msg.contains("No such"),
"unexpected error: {msg}"
);
}
}

View file

@ -67,6 +67,10 @@ pub fn app(registry: ServerRegistry) -> Router {
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
get(serve_artifact),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/favicon",
get(serve_entry_favicon),
)
.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))
@ -162,6 +166,29 @@ async fn serve_artifact(
.into_response())
}
async fn serve_entry_favicon(
State(state): State<AppState>,
Path((archive_id, entry_uid)): 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 detail = archive::get_entry_detail(&conn, &entry_uid)?
.ok_or(ApiError::not_found("entry not found"))?;
let artifact = detail
.artifacts
.iter()
.find(|a| a.artifact_role == "favicon")
.ok_or(ApiError::not_found("no favicon for this entry"))?;
let file_path = archive::resolve_artifact_path(&paths.store_path, artifact)?;
Ok(ServeFile::new(&file_path)
.oneshot(req)
.await
.unwrap()
.into_response())
}
#[derive(Debug, serde::Deserialize)]
struct CreateTagBody {
path: String,

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-XKDv7X2o.js"></script>
<script type="module" crossorigin src="/assets/index-CcezQ0kg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJpQthbx.css">
</head>
<body>

View file

@ -22,7 +22,7 @@ An open-source self-hosted archiving tool. Work in progress.
- [ ] Dropbox
- [ ] OneDrive
- (Some of these could be postponed for later.)
- [ ] Archive web pages (HTML, CSS, JS, images)
- [x] Archive web pages (HTML, CSS, JS, images)
- [ ] Archiving emails (???)
- [ ] Gmail
- [ ] Outlook
@ -168,6 +168,12 @@ Auth and session handling will be designed when remote or public hosting becomes
- `ARCHIVR_YT_DLP`
- Optional.
- Overrides the `yt-dlp` binary used for YouTube, X media posts, Instagram, Facebook, TikTok, Reddit, and Snapchat downloads.
- `ARCHIVR_SINGLE_FILE`
- Optional.
- Overrides the `single-file` binary used for web page archiving. When installed through Nix, this is set automatically to the Nixpkgs `single-file-cli` binary.
- `ARCHIVR_CHROME`
- Optional.
- Overrides the Chromium/Chrome executable passed to `single-file` via `--browser-executable-path`. When installed through Nix, this is set automatically to the Nixpkgs `chromium` binary. Default: `chromium`.
- `ARCHIVR_TWITTER_CREDENTIALS_FILE`
- Required for tweet/thread scraping inputs such as `tweet:ID` and `x:thread:ID`.
- Must point to a cookies file for the vendored scraper.
@ -180,7 +186,7 @@ Auth and session handling will be designed when remote or public hosting becomes
### Current Limitations
- Arbitrary `http://` or `https://` pages are not archived yet unless they match one of the currently supported platforms above.
- Arbitrary `http://` or `https://` URLs that return HTML are archived as self-contained single-file HTML snapshots via `single-file-cli` (requires Chromium). Plain file URLs (PDFs, images, zips, etc.) are downloaded directly. Requires `single-file` and a Chromium binary on PATH, or the `ARCHIVR_SINGLE_FILE` / `ARCHIVR_CHROME` env vars set.
- Local files currently need to be passed as `file://...` paths.
## License

View file

@ -105,8 +105,9 @@
nativeBuildInputs = [ pkgs.makeWrapper ];
buildInputs = [
pkgs.yt-dlp
pkgs.single-file-cli
tweetPython
];
] ++ lib.optionals pkgs.stdenv.isLinux [ pkgs.chromium ];
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out/bin $out/libexec/archivr
@ -115,13 +116,16 @@
chmod +x $out/libexec/archivr/scrape_user_tweet_contents.py
makeWrapper $out/libexec/archivr/archivr $out/bin/archivr \
--set ARCHIVR_YT_DLP ${pkgs.yt-dlp}/bin/yt-dlp \
--set ARCHIVR_SINGLE_FILE ${pkgs.single-file-cli}/bin/single-file \
${lib.optionalString pkgs.stdenv.isLinux "--set ARCHIVR_CHROME ${pkgs.chromium}/bin/chromium"} \
--set ARCHIVR_TWEET_PYTHON ${tweetPython}/bin/python3 \
--set ARCHIVR_TWEET_SCRAPER $out/libexec/archivr/scrape_user_tweet_contents.py \
--prefix PATH : ${
lib.makeBinPath [
lib.makeBinPath ([
pkgs.yt-dlp
pkgs.single-file-cli
tweetPython
]
] ++ lib.optionals pkgs.stdenv.isLinux [ pkgs.chromium ])
}
'';
};
@ -129,7 +133,7 @@
pname = "archivr-server-wrapped";
inherit version;
nativeBuildInputs = [ pkgs.makeWrapper ];
buildInputs = [ tweetPython ];
buildInputs = [ tweetPython pkgs.single-file-cli ] ++ lib.optionals pkgs.stdenv.isLinux [ pkgs.chromium ];
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out/bin $out/libexec/archivr-server $out/share/archivr-server/static
@ -139,6 +143,8 @@
cp -r ${./crates/archivr-server/static}/* $out/share/archivr-server/static/
makeWrapper $out/libexec/archivr-server/archivr-server $out/bin/archivr-server \
--set ARCHIVR_STATIC_DIR $out/share/archivr-server/static \
--set ARCHIVR_SINGLE_FILE ${pkgs.single-file-cli}/bin/single-file \
${lib.optionalString pkgs.stdenv.isLinux "--set ARCHIVR_CHROME ${pkgs.chromium}/bin/chromium"} \
--set ARCHIVR_TWEET_PYTHON ${tweetPython}/bin/python3 \
--set ARCHIVR_TWEET_SCRAPER $out/libexec/archivr-server/scrape_user_tweet_contents.py
'';

View file

@ -164,6 +164,7 @@ export default function App() {
entries={entries}
selectedEntryUid={selectedEntryUid}
onSelectEntry={selectEntry}
archiveId={archiveId}
tagFilter={tagFilter}
onClearTagFilter={handleClearTagFilter}
searchQuery={searchQuery}

View file

@ -1,6 +1,6 @@
import EntryRow from './EntryRow';
export default function EntriesView({ entries, selectedEntryUid, onSelectEntry }) {
export default function EntriesView({ entries, selectedEntryUid, onSelectEntry, archiveId }) {
return (
<section id="archive-view" className="view is-active">
<div className="entry-table">
@ -16,6 +16,7 @@ export default function EntriesView({ entries, selectedEntryUid, onSelectEntry }
<EntryRow
key={entry.entry_uid}
entry={entry}
archiveId={archiveId}
isSelected={entry.entry_uid === selectedEntryUid}
onSelect={() => onSelectEntry(entry)}
/>

View file

@ -1,6 +1,28 @@
import { useState } from 'react';
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils';
export default function EntryRow({ entry, isSelected, onSelect }) {
export default function EntryRow({ entry, archiveId, isSelected, onSelect }) {
const [favFailed, setFavFailed] = useState(false);
const showFavicon =
entry.source_kind === 'web' &&
entry.entity_kind === 'page' &&
entry.has_favicon &&
archiveId &&
!favFailed;
const icon = showFavicon ? (
<img
src={`/api/archives/${archiveId}/entries/${entry.entry_uid}/favicon`}
width="16"
height="16"
alt=""
onError={() => setFavFailed(true)}
style={{ objectFit: 'contain' }}
/>
) : (
<span dangerouslySetInnerHTML={{ __html: sourceIconSvg(entry.source_kind) }} />
);
return (
<div
className={isSelected ? 'is-selected' : undefined}
@ -11,7 +33,7 @@ export default function EntryRow({ entry, isSelected, onSelect }) {
>
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
<div className="col-title">
<span className="source-icon" dangerouslySetInnerHTML={{ __html: sourceIconSvg(entry.source_kind) }} />
<span className="source-icon">{icon}</span>
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
</div>
<div className="col-type">