mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
Compare commits
14 commits
143f71bc17
...
ab353f9209
| Author | SHA1 | Date | |
|---|---|---|---|
| ab353f9209 | |||
| 852cc45956 | |||
| fbb96fbd05 | |||
| 1239ad9f34 | |||
| 1206d7103d | |||
| cc2dbf4ac2 | |||
| 653efa9705 | |||
| ee697625fb | |||
| ad1c4f8ee5 | |||
| e70c6b37cd | |||
| c9ce0ee00b | |||
| fe9ff2dafe | |||
| fd06632073 | |||
| b7ab12898e |
16 changed files with 594 additions and 20 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -14,3 +14,7 @@ serde_json.workspace = true
|
|||
sha3.workspace = true
|
||||
uuid.workspace = true
|
||||
reqwest = { workspace = true }
|
||||
base64.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -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<_>>>()?;
|
||||
|
|
|
|||
|
|
@ -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, ×tamp) {
|
||||
Ok(result) => {
|
||||
let file_extension = ".html".to_string();
|
||||
let temp_html = store_path
|
||||
.join("temp")
|
||||
.join(×tamp)
|
||||
.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(×tamp)
|
||||
.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(×tamp));
|
||||
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ pub mod tweets;
|
|||
pub mod ytdlp;
|
||||
pub mod metadata;
|
||||
pub mod http;
|
||||
pub mod singlefile;
|
||||
|
|
|
|||
273
crates/archivr-core/src/downloader/singlefile.rs
Normal file
273
crates/archivr-core/src/downloader/singlefile.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
flake.nix
14
flake.nix
|
|
@ -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
|
||||
'';
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ export default function App() {
|
|||
entries={entries}
|
||||
selectedEntryUid={selectedEntryUid}
|
||||
onSelectEntry={selectEntry}
|
||||
archiveId={archiveId}
|
||||
tagFilter={tagFilter}
|
||||
onClearTagFilter={handleClearTagFilter}
|
||||
searchQuery={searchQuery}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue