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

feat: uBlock Origin Lite + cookie consent extension + reader mode + ad placeholder cleanup (#21)

* feat: uBlock Origin Lite integration for ad-blocking during WebPage captures

- singlefile.rs: when ARCHIVR_UBLOCK=true and ARCHIVR_UBLOCK_EXT is set,
  archivr owns Chrome's lifecycle (--headless=new, --remote-debugging-port,
  --load-extension); single-file connects via --browser-server instead of
  launching its own Chrome. Falls back to old behaviour with ublock_skipped=true
  when the ext path is missing or invalid.
- capture.rs: thread ublock_skipped through CaptureResult
- database.rs: add notes_json TEXT column to capture_jobs (DDL + idempotent
  ALTER TABLE migration); update_capture_job_status gains notes_json param
- archive.rs: expose notes_json in CaptureJobSummary
- routes.rs: store {"ublock_skipped":true} in notes_json on completed captures
- ToastStack.jsx: warning toast variant (toast--warning) with Details expander
  and Ignore button
- CaptureDialog.jsx: fire warning toast when poll result has ublock_skipped
- App.jsx: sessionStorage-backed Ignore suppression for ublock warnings
- styles.css: .toast--warning (amber left border) + .toast-warning-detail
- flake.nix: ublockLite derivation fetches uBOLite_2026.705.2152.chromium.zip
  (pinned SHA256) from uBlockOrigin/uBOL-home; sets ARCHIVR_UBLOCK_EXT in both
  archivr and archivr-server wrappers

Env vars:
  ARCHIVR_UBLOCK=true (default) — enable uBlock during WebPage captures
  ARCHIVR_UBLOCK_EXT — path to unpacked uBOL extension dir (set by Nix)

* feat: Extensions settings tab + capture dialog redesign with Advanced options

Settings/Extensions tab (admin-only):
- New 'Extensions' tab between Cookies and Storage
- ExtensionsTab component: shows uBlock Origin Lite card with pill toggle
- Reads ublock_enabled from instance settings; patch via existing PATCH endpoint
- Shows ublock_ext_available status from server (whether ARCHIVR_UBLOCK_EXT is set)

Instance settings:
- Add ublock_enabled BOOLEAN (default true) to instance_settings auth DB table
- Idempotent ALTER TABLE migration in initialize_auth_schema()
- get/update_instance_settings include ublock_enabled
- GET /api/admin/instance-settings now also returns ublock_ext_available (computed
  from ARCHIVR_UBLOCK_EXT env var at request time)
- PATCH /api/admin/instance-settings accepts ublock_enabled

Per-capture override:
- CaptureBody gains ublock_enabled: Option<bool>
- CaptureConfig gains ublock_enabled: Option<bool>
- singlefile::save() gains ublock_enabled_override: Option<bool> param
- Capture handler resolves: body override > global instance setting > env var
- submitCapture(aid, loc, qual, extensions) in api.js passes ublock_enabled

Capture dialog redesign:
- Archive button: full-width, 13px padding, min-width 220px, primary CTA
- Cancel: full-width but text-style, below Archive
- ‹Advanced options› chevron toggle (rotates on open)
- Expanded panel shows uBlock toggle for this capture session
- Loads global ublock_enabled default from instance settings on mount

Styles:
- .ext-toggle pill switch (44×24 and 36×20 small variant)
- .ext-card for Settings Extensions tab
- .capture-advanced + .capture-advanced-panel + .capture-chevron
- .capture-ext-row / .capture-ext-label / .capture-ext-name / .capture-ext-desc
- .form-hint utility class

* fix: remove ublock_enabled from INSERT OR IGNORE in DDL batch

The INSERT ran before the ALTER TABLE migration added the column,
causing 'table instance_settings has no column named ublock_enabled'
on existing databases. The INSERT OR IGNORE for the default row only
needs the original columns; the migration's DEFAULT 1 handles the
new column for existing and new rows alike.

* feat: Reader mode via Mozilla Readability.js

Adds an opt-in 'Reader mode' advanced option to the capture dialog.
When enabled, Readability.js is injected as a browser script during
SingleFile capture; it fires on single-file-on-before-capture-start,
replaces the page body with the distilled article content, injects a
clean typographic stylesheet, and adds a header with title/byline/site.
Falls back silently if Readability fails (e.g. non-article pages).

- vendor/readability/Readability.js  Apache 2.0, Mozilla, v0.6.0
- singlefile.rs: embed READABILITY_JS + READER_MODE_WRAPPER_JS via
  include_str!; write both to temp dir when reader_mode is true;
  base_single_file_cmd now accepts &[&Path] for multiple --browser-script
- capture.rs: CaptureConfig.reader_mode: bool
- routes.rs: CaptureBody.reader_mode: Option<bool> (defaults false)
- api.js: submitCapture passes reader_mode in payload
- CaptureDialog.jsx: Reader mode toggle in Advanced options (off by default)

* fix: diagnose single-file no-output-file error + prevent stdout dumping

- Add --dump-content=false to every single-file invocation to prevent
  the Docker-detection heuristic from routing HTML to stdout instead of
  the output file (the heuristic can trigger in some macOS environments)
- Improve the no-output-file error message to include: temp dir contents,
  stderr, and first 200 chars of stdout — this gives enough context to
  diagnose any remaining cause without re-running

* fix: switch uBlock loading from --browser-server to --browser-args

The --browser-server (CDP) path caused 'Unexpected server response: 404'
on macOS Chrome because simple-cdp's WebSocket upgrade to the debugger
endpoint failed after Chrome started — likely a version-specific CDP
endpoint shape mismatch.

New approach: single-file always manages Chrome. When ARCHIVR_UBLOCK_EXT
is set, --headless=new, --load-extension, and --disable-extensions-except
are injected via --browser-args. single-file's browser.js prefix-strips
its own conflicting flags before appending ours, so --headless=new
overrides the default --headless (enabling extension support in headless).

Removes allocate_free_port, wait_for_chrome_ready, run_single_file_with_server
(all dead code now). Docblock updated to reflect actual behaviour and notes
the --single-process caveat: uBOL's declarativeNetRequest static rulesets
are expected to work (network-stack level, not service-worker), but this
has not been mechanically verified under --single-process.

Smoke tested on macOS (this machine): capture with --load-extension + all
three browser-scripts (strip, Readability, reader-mode wrapper) produces
output file correctly. Ad-blocking verification deferred to manual test
with a tracker-heavy URL.

* fix: use correct single-file hook event (single-file-on-before-capture-request)

Prior scripts listened on 'single-file-on-before-capture-start' which
does not exist in single-file-core 1.1.49.  The real hook is:

  single-file-on-before-capture-request  (dispatched by initUserScriptHandler
  after receiving single-file-user-script-init; userScriptEnabled defaults
  to true in args.js so it always fires when --browser-script is passed)

Changes:
- strip-scripts: -start -> -request (no preventDefault needed; synchronous)
- READER_MODE_SCRIPT: -start -> -request; add 'installed' meta marker at
  script-evaluation time so artifact inspection can distinguish 'script
  not injected' / 'hook never fired' / 'Readability parse failed'

* fix: correct singlefile.rs docstring (scripts.js concatenates, not isolates)

* fix: dispatch single-file-user-script-init so request hook fires

single-file's initUserScriptHandler (in single-file-bootstrap.js) listens
for 'single-file-user-script-init' and only then installs
_singleFile_waitForUserScript.  Without that dispatch our scripts'
'single-file-on-before-capture-request' listeners were never reached,
so neither strip-scripts nor reader-mode Readability applied.

Dispatch the init event at the top of strip-scripts (always present) and
redundantly in READER_MODE_SCRIPT.  Verified end-to-end: artifact for
run_b3181d6d276e4e56a1a6c356ef9bbe8f has
  meta content="applied", max-width:680px CSS, 0 script tags.

* feat: cookie consent extension support (ARCHIVR_COOKIE_EXT)

Mirrors the uBlock Origin Lite integration exactly:

Backend:
- singlefile.rs: resolve_cookie_ext_config() reads ARCHIVR_COOKIE_CONSENT
  (default true) + ARCHIVR_COOKIE_EXT path; extension paths comma-joined
  into --load-extension / --disable-extensions-except so uBlock and cookie
  ext can coexist; SaveResult.cookie_ext_skipped tracks miss
- database.rs: cookie_ext_enabled column on instance_settings (DEFAULT 1);
  idempotent ALTER TABLE migration; get/update wired through
- capture.rs: CaptureConfig.cookie_ext_enabled: Option<bool>; threaded to
  singlefile::save(); cookie_ext_skipped surfaced in CaptureResult
- routes.rs: CaptureBody + UpdateInstanceSettingsBody get cookie_ext_enabled;
  capture handler resolves effective value (body overrides global); notes_json
  only includes skipped fields that are true; GET instance-settings includes
  cookie_ext_available from env path check

Frontend:
- api.js: submitCapture forwards cookie_ext_enabled
- SettingsView.jsx: 'I Still Don't Care About Cookies' card in Extensions
  tab; always-active toggle (user can disable even when ext not installed);
  amber 'Not configured' hint + ARCHIVR_COOKIE_EXT guidance when unavailable
- CaptureDialog.jsx: 'Block cookie banners' toggle in Advanced options;
  always shown with amber hint when ext not configured; defaults from
  global setting

Operator setup: download + unzip the extension from GitHub releases, set
ARCHIVR_COOKIE_EXT=/path/to/unpacked/ext. No Node daemon needed.

* fix: surface cookie_ext_skipped warning toast in CaptureDialog

* feat: package istilldontcareaboutcookies in flake, wire ARCHIVR_COOKIE_EXT

Add isdcac derivation mirroring ublockLite:
- Fetches ISDCAC-chrome-source.zip v1.1.9 from GitHub releases
- Validates manifest.json at extension root before install (guard against
  nested-folder zip regressions in future releases)
- Sets ARCHIVR_COOKIE_EXT in both archivr and archivr_server wrappers

Verified: nix build .#archivr-server and .#archivr both succeed;
wrapper scripts export correct store paths; manifest.json present at root.

* fix: gate consent-overlay cleanup on cookie_ext; reset overflow; narrow selectors

- Strip overflow:hidden from body/html only when cookie_ext is active for
  the capture — prevents mutating legitimate pages when the feature is off
- Remove .fc-dialog (Google Funding Choices), .qc-cmp2-*, .sp-message-container,
  #sp-cc, #usercentrics-root as fallback for CMPs the extension misses
- Removed overbroad [class^="uc-"] and [id^="usercentrics"] selectors
  that could match real page content

* fix: remove ad placeholders when uBlock active; kept height causes blank gap

uBlock Origin Lite blocks ad network requests but first-party placeholder
elements (ins.adsbygoogle, #aswift_* iframe hosts) retain their computed
height (e.g. 280px for a top banner), leaving a large blank space at the
top of captured pages.

Gate cleanup on ublock_ext.is_some(): remove ins.adsbygoogle, aswift_*
iframes, and google_ads_* iframes before SingleFile serialises. Also
collapse the parent container if it becomes empty after removal.

* fix: walk up to .top-ad/.google-auto-placed ancestor before removing ad slot

Removing only the inner ins.adsbygoogle left the outer .container.top-ad
wrapper (with pb-4 padding) in the layout, preserving the blank gap.
Now walk up via closest() to the nearest ad-slot container class before
removal so the whole slot including padding collapses.
This commit is contained in:
TheGeneralist 2026-07-08 23:26:48 +02:00 committed by GitHub
parent dae61e585d
commit 2e8820a0da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 4003 additions and 266 deletions

View file

@ -69,6 +69,7 @@ pub struct CaptureJobSummary {
pub run_uid: Option<String>,
pub status: String,
pub error_text: Option<String>,
pub notes_json: Option<String>,
pub created_at: String,
pub updated_at: String,
}
@ -341,6 +342,7 @@ pub fn get_capture_job(
run_uid: r.run_uid,
status: r.status,
error_text: r.error_text,
notes_json: r.notes_json,
created_at: r.created_at,
updated_at: r.updated_at,
}))

View file

@ -37,6 +37,11 @@ pub enum Source {
pub struct CaptureResult {
pub run_uid: String,
pub status: String,
/// `true` when uBlock was requested but the extension path was not found.
/// The capture succeeded without ad-blocking; the UI should warn the user.
pub ublock_skipped: bool,
/// `true` when cookie-consent extension was requested but the path was not found.
pub cookie_ext_skipped: bool,
}
#[derive(Debug, Clone, Default)]
@ -74,6 +79,12 @@ impl PlatformMetadata {
#[derive(Debug, Clone, Default)]
pub struct CaptureConfig {
pub cookie_rules: Vec<database::CookieRule>,
/// Override for uBlock Origin Lite during WebPage captures.
pub ublock_enabled: Option<bool>,
/// Override for cookie-consent extension during WebPage captures.
pub cookie_ext_enabled: Option<bool>,
/// Apply Mozilla Readability to distil the page to article content before archiving.
pub reader_mode: bool,
}
/// Resolves which cookies apply to `url` by evaluating all rules in ordinal order.
@ -991,6 +1002,8 @@ pub fn perform_capture(
return Ok(CaptureResult {
run_uid: run.run_uid.clone(),
status: "completed".to_string(),
ublock_skipped: false,
cookie_ext_skipped: false,
});
}
Err(e) => {
@ -1006,7 +1019,7 @@ pub fn perform_capture(
// 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, &cookies) {
match downloader::singlefile::save(locator, store_path, &timestamp, &cookies, config.ublock_enabled, config.cookie_ext_enabled, config.reader_mode) {
Ok(result) => {
let file_extension = ".html".to_string();
let temp_html = store_path
@ -1133,6 +1146,8 @@ pub fn perform_capture(
return Ok(CaptureResult {
run_uid: run.run_uid.clone(),
status: "completed".to_string(),
ublock_skipped: result.ublock_skipped,
cookie_ext_skipped: result.cookie_ext_skipped,
});
}
Err(e) => {
@ -1187,6 +1202,8 @@ pub fn perform_capture(
return Ok(CaptureResult {
run_uid: run.run_uid.clone(),
status: "completed".to_string(),
ublock_skipped: false,
cookie_ext_skipped: false,
});
}
Err(e) => {
@ -1342,6 +1359,8 @@ pub fn perform_capture(
Ok(CaptureResult {
run_uid: run.run_uid.clone(),
status: "completed".to_string(),
ublock_skipped: false,
cookie_ext_skipped: false,
})
}

View file

@ -105,6 +105,7 @@ pub struct CaptureJobRecord {
pub run_uid: Option<String>,
pub status: String,
pub error_text: Option<String>,
pub notes_json: Option<String>,
pub created_at: String,
pub updated_at: String,
}
@ -136,6 +137,11 @@ pub struct InstanceSettings {
pub public_entry_content_enabled: bool,
pub open_registration_enabled: bool, // maps to public_archive_submission_enabled column
pub default_entry_visibility: u32,
/// Global default for ad-blocking via uBlock Origin Lite during WebPage captures.
/// Per-capture requests can override this.
pub ublock_enabled: bool,
/// Global default for cookie-consent banner dismissal via extension during WebPage captures.
pub cookie_ext_enabled: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -194,7 +200,8 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
id INTEGER PRIMARY KEY CHECK (id = 1),
public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)),
public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)),
public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1))
public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)),
cookie_ext_enabled INTEGER NOT NULL DEFAULT 1 CHECK (cookie_ext_enabled IN (0, 1))
);
INSERT OR IGNORE INTO instance_settings (
@ -308,6 +315,7 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
run_uid TEXT,
status TEXT NOT NULL CHECK (status IN ('pending','running','completed','failed')) DEFAULT 'pending',
error_text TEXT,
notes_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@ -393,6 +401,10 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
)?;
}
// Migration: add notes_json column to existing capture_jobs tables.
// Silently ignored when the column already exists (idempotent).
let _ = conn.execute("ALTER TABLE capture_jobs ADD COLUMN notes_json TEXT", []);
Ok(())
}
@ -454,7 +466,9 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)),
public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)),
public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)),
default_entry_visibility INTEGER NOT NULL DEFAULT 2
default_entry_visibility INTEGER NOT NULL DEFAULT 2,
ublock_enabled INTEGER NOT NULL DEFAULT 1 CHECK (ublock_enabled IN (0, 1)),
cookie_ext_enabled INTEGER NOT NULL DEFAULT 1 CHECK (cookie_ext_enabled IN (0, 1))
);
INSERT OR IGNORE INTO instance_settings
@ -494,6 +508,17 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
[],
);
// Add ublock_enabled column to instance_settings if not present (idempotent migration)
let _ = conn.execute(
"ALTER TABLE instance_settings ADD COLUMN ublock_enabled INTEGER NOT NULL DEFAULT 1",
[],
);
// Add cookie_ext_enabled column to instance_settings if not present (idempotent migration)
let _ = conn.execute(
"ALTER TABLE instance_settings ADD COLUMN cookie_ext_enabled INTEGER NOT NULL DEFAULT 1",
[],
);
Ok(())
}
@ -718,7 +743,9 @@ pub fn list_user_tokens(conn: &Connection, user_id: i64) -> Result<Vec<ApiTokenR
pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
conn.query_row(
"SELECT public_index_enabled, public_entry_content_enabled,
public_archive_submission_enabled, default_entry_visibility
public_archive_submission_enabled, default_entry_visibility,
COALESCE(ublock_enabled, 1),
COALESCE(cookie_ext_enabled, 1)
FROM instance_settings WHERE id = 1",
[],
|row| {
@ -727,6 +754,8 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
public_entry_content_enabled: row.get::<_, i64>(1)? != 0,
open_registration_enabled: row.get::<_, i64>(2)? != 0,
default_entry_visibility: row.get::<_, i64>(3)? as u32,
ublock_enabled: row.get::<_, i64>(4)? != 0,
cookie_ext_enabled: row.get::<_, i64>(5)? != 0,
})
},
)
@ -739,13 +768,17 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
SET public_index_enabled = ?1,
public_entry_content_enabled = ?2,
public_archive_submission_enabled = ?3,
default_entry_visibility = ?4
default_entry_visibility = ?4,
ublock_enabled = ?5,
cookie_ext_enabled = ?6
WHERE id = 1",
params![
settings.public_index_enabled as i64,
settings.public_entry_content_enabled as i64,
settings.open_registration_enabled as i64,
settings.default_entry_visibility as i64,
settings.ublock_enabled as i64,
settings.cookie_ext_enabled as i64,
],
)?;
Ok(())
@ -1142,19 +1175,21 @@ pub fn create_capture_job(conn: &Connection, archive_id: &str) -> Result<String>
Ok(job_uid)
}
/// Updates the status (and optionally run_uid / error_text) of a capture job.
/// Updates the status (and optionally run_uid / error_text / notes_json) of a capture job.
pub fn update_capture_job_status(
conn: &Connection,
job_uid: &str,
status: &str,
run_uid: Option<&str>,
error_text: Option<&str>,
notes_json: Option<&str>,
) -> Result<()> {
let now = now_timestamp();
conn.execute(
"UPDATE capture_jobs SET status = ?1, run_uid = COALESCE(?2, run_uid),
error_text = ?3, updated_at = ?4 WHERE job_uid = ?5",
rusqlite::params![status, run_uid, error_text, now, job_uid],
error_text = ?3, notes_json = COALESCE(?4, notes_json), updated_at = ?5
WHERE job_uid = ?6",
rusqlite::params![status, run_uid, error_text, notes_json, now, job_uid],
)?;
Ok(())
}
@ -1162,7 +1197,7 @@ pub fn update_capture_job_status(
/// Returns a capture job by uid.
pub fn get_capture_job(conn: &Connection, job_uid: &str) -> Result<Option<CaptureJobRecord>> {
conn.query_row(
"SELECT job_uid, archive_id, run_uid, status, error_text, created_at, updated_at
"SELECT job_uid, archive_id, run_uid, status, error_text, notes_json, created_at, updated_at
FROM capture_jobs WHERE job_uid = ?1",
[job_uid],
|row| {
@ -1172,8 +1207,9 @@ pub fn get_capture_job(conn: &Connection, job_uid: &str) -> Result<Option<Captur
run_uid: row.get(2)?,
status: row.get(3)?,
error_text: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
notes_json: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
},
)
@ -2806,8 +2842,8 @@ mod tests {
fn capture_job_status_transitions() {
let conn = conn();
let job_uid = create_capture_job(&conn, "test").unwrap();
update_capture_job_status(&conn, &job_uid, "running", None, None).unwrap();
update_capture_job_status(&conn, &job_uid, "completed", Some("run_abc"), None).unwrap();
update_capture_job_status(&conn, &job_uid, "running", None, None, None).unwrap();
update_capture_job_status(&conn, &job_uid, "completed", Some("run_abc"), None, None).unwrap();
let job = get_capture_job(&conn, &job_uid).unwrap().unwrap();
assert_eq!(job.status, "completed");
assert_eq!(job.run_uid.as_deref(), Some("run_abc"));
@ -2819,7 +2855,7 @@ mod tests {
// Simulate an in-progress capture_job (run_uid still NULL — common crash case).
let uid = create_capture_job(&conn, "test").unwrap();
update_capture_job_status(&conn, &uid, "running", None, None).unwrap();
update_capture_job_status(&conn, &uid, "running", None, None, None).unwrap();
// Simulate an in-progress archive_run and item with no associated capture_job
// (covers the case where run_uid was never written back before the crash).
@ -3177,7 +3213,7 @@ mod tests {
fn has_active_capture_jobs_true_for_running() {
let conn = conn();
let uid = create_capture_job(&conn, "test").unwrap();
update_capture_job_status(&conn, &uid, "running", None, None).unwrap();
update_capture_job_status(&conn, &uid, "running", None, None, None).unwrap();
assert!(has_active_capture_jobs(&conn).unwrap());
}
@ -3185,7 +3221,7 @@ mod tests {
fn has_active_capture_jobs_false_for_completed() {
let conn = conn();
let uid = create_capture_job(&conn, "test").unwrap();
update_capture_job_status(&conn, &uid, "completed", Some("run_x"), None).unwrap();
update_capture_job_status(&conn, &uid, "completed", Some("run_x"), None, None).unwrap();
assert!(!has_active_capture_jobs(&conn).unwrap());
}

View file

@ -1,11 +1,99 @@
use anyhow::{Context, Result, bail};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use std::{collections::HashMap, env, io::Read, path::Path, process::Command};
use std::{
collections::HashMap,
env,
io::Read,
path::{Path, PathBuf},
process::Command,
};
use crate::downloader::cookies::{domain_from_url, write_netscape_cookie_file};
use crate::hash::hash_file;
/// Combined reader-mode script: Readability.js (Apache 2.0) bundled with the
/// archivr wrapper in a single IIFE. single-file-cli concatenates all
/// `--browser-script` files into one string before injection (scripts.js:84),
/// so scope sharing is guaranteed; the combined file is kept for clarity.
///
/// Emits `<meta name="archivr-reader-mode" content="applied|failed:REASON">`
/// so the outcome is observable in the saved HTML.
const READER_MODE_SCRIPT: &str = concat!(
// Readability.js is injected verbatim first so `Readability` is in scope.
include_str!("../../../../vendor/readability/Readability.js"),
// Wrapper IIFE — runs on single-file-on-before-capture-request.
// Sets 'installed' immediately at script-evaluation time so a missing meta
// means the browser-script was never injected at all.
r#"
;(function() {
function _archivrReaderMark(content) {
try {
var m = document.querySelector('meta[name="archivr-reader-mode"]');
if (!m) {
m = document.createElement('meta');
m.name = 'archivr-reader-mode';
(document.head || document.documentElement).appendChild(m);
}
m.content = content;
} catch(_) {}
}
// Mark immediately: if this meta is absent in the artifact the script
// was never injected (separate from the hook never firing).
_archivrReaderMark('installed');
function _archivrApplyReader() {
try {
if (typeof Readability === 'undefined') {
_archivrReaderMark('failed:no-readability');
return;
}
var article = new Readability(document.cloneNode(true)).parse();
if (!article || !article.content || article.content.length < 100) {
_archivrReaderMark('failed:no-article');
return;
}
document.body.innerHTML = article.content;
if (article.title) document.title = article.title;
var hdr = document.createElement('header');
hdr.innerHTML =
'<h1 style="margin:0 0 .3em;font-family:-apple-system,sans-serif">' +
(article.title || '') + '</h1>' +
(article.byline
? '<p style="margin:0;color:#666;font-size:14px">' + article.byline + '</p>'
: '') +
(article.siteName
? '<p style="margin:.2em 0 0;color:#999;font-size:12px">' + article.siteName + '</p>'
: '');
hdr.style.cssText = 'margin-bottom:2em;padding-bottom:1em;border-bottom:1px solid #ddd';
document.body.insertBefore(hdr, document.body.firstChild);
var style = document.createElement('style');
style.textContent = [
'body{max-width:680px;margin:40px auto;padding:0 24px;',
'font-family:Georgia,"Times New Roman",serif;font-size:18px;',
'line-height:1.75;color:#1a1a1a;background:#fafaf8}',
'h1,h2,h3,h4,h5,h6{font-family:-apple-system,BlinkMacSystemFont,sans-serif;',
'line-height:1.3;margin-top:1.5em}',
'img,figure,video{max-width:100%;height:auto;display:block;margin:1em 0}',
'a{color:#0055cc}',
'pre{background:#f4f4f4;padding:1em;border-radius:4px;overflow-x:auto;font-size:14px}',
'code{background:#f4f4f4;padding:.1em .3em;border-radius:3px;font-size:14px}',
'blockquote{border-left:3px solid #ccc;margin:1em 0;padding-left:1.2em;color:#555}',
].join('');
document.head.appendChild(style);
_archivrReaderMark('applied');
} catch (e) {
_archivrReaderMark('failed:exception:' + (e && e.message ? e.message : String(e)));
}
}
// Ensure _singleFile_waitForUserScript is installed (strip-scripts also does
// this, but be explicit here in case reader-mode ever runs without it).
dispatchEvent(new CustomEvent('single-file-user-script-init'));
// Synchronous work — no preventDefault()/response dispatch needed.
addEventListener('single-file-on-before-capture-request', _archivrApplyReader);
})();
"#
);
/// Result of archiving a web page with single-file.
#[derive(Debug)]
pub struct SaveResult {
@ -17,26 +105,146 @@ pub struct SaveResult {
pub favicon_hash: Option<String>,
/// File extension for the favicon (e.g. `".ico"`, `".png"`), if present.
pub favicon_ext: Option<String>,
/// `true` when `ARCHIVR_UBLOCK=true` (the default) but the extension path
/// was missing or invalid. The capture succeeded but ran without ad-blocking.
pub ublock_skipped: bool,
/// `true` when `ARCHIVR_COOKIE_CONSENT=true` (the default) but the extension path
/// was missing or invalid. The capture succeeded but ran without cookie-consent blocking.
pub cookie_ext_skipped: bool,
}
/// 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:
/// 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, cookies: &HashMap<String, String>) -> Result<SaveResult> {
/// - `ARCHIVR_UBLOCK`: enable uBlock Origin Lite extension (default: `"true"`).
/// - `ARCHIVR_UBLOCK_EXT`: path to the unpacked uBlock Origin Lite extension directory.
/// - `ARCHIVR_CHROME_ARGS`: space-separated extra Chrome flags (e.g. `"--no-sandbox"`).
pub fn save(
url: &str,
store_path: &Path,
timestamp: &str,
cookies: &HashMap<String, String>,
ublock_enabled_override: Option<bool>,
cookie_ext_enabled: Option<bool>,
reader_mode: bool,
) -> 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, cookies)
let (ublock_ext, ublock_skipped) = resolve_ublock_config(ublock_enabled_override);
let (cookie_ext, cookie_ext_skipped) = resolve_cookie_ext_config(cookie_ext_enabled);
let mut result = save_with(
url,
store_path,
timestamp,
&single_file,
&chrome,
cookies,
ublock_ext.as_deref(),
cookie_ext.as_deref(),
reader_mode,
)?;
result.ublock_skipped = ublock_skipped;
result.cookie_ext_skipped = cookie_ext_skipped;
Ok(result)
}
/// Inner implementation; takes binary paths explicitly so tests can inject them
/// without mutating process-global environment variables.
/// Resolves uBlock configuration from env vars, optionally overridden by the caller.
///
/// Returns:
/// - `(Some(path), false)` — uBlock is enabled and the extension dir is valid.
/// - `(None, true)` — uBlock is enabled but the extension dir is missing/invalid
/// (warns to stderr; the capture proceeds without ad-blocking).
/// - `(None, false)` — uBlock is disabled (`ARCHIVR_UBLOCK=false` or overridden).
fn resolve_ublock_config(enabled_override: Option<bool>) -> (Option<PathBuf>, bool) {
// The override (from instance settings or per-capture body) takes precedence over env.
let want_ublock = enabled_override.unwrap_or_else(|| {
let env_val = env::var("ARCHIVR_UBLOCK").unwrap_or_else(|_| "true".to_string());
!env_val.eq_ignore_ascii_case("false") && env_val != "0"
});
if !want_ublock {
return (None, false);
}
match env::var("ARCHIVR_UBLOCK_EXT").ok().filter(|s| !s.is_empty()) {
None => {
eprintln!(
"warn: uBlock: ARCHIVR_UBLOCK_EXT is not set; \
capturing without ad-blocking"
);
(None, true)
}
Some(ext_path_str) => {
let path = PathBuf::from(&ext_path_str);
if path.is_dir() {
(Some(path), false)
} else {
eprintln!(
"warn: uBlock: ARCHIVR_UBLOCK_EXT={ext_path_str:?} is not a directory; \
capturing without ad-blocking"
);
(None, true)
}
}
}
}
/// Resolves cookie-consent extension configuration from env vars, optionally overridden by the caller.
///
/// Returns:
/// - `(Some(path), false)` — cookie-consent ext is enabled and the extension dir is valid.
/// - `(None, true)` — cookie-consent ext is enabled but the extension dir is missing/invalid
/// (warns to stderr; the capture proceeds without cookie-consent blocking).
/// - `(None, false)` — cookie-consent ext is disabled (`ARCHIVR_COOKIE_CONSENT=false` or overridden).
fn resolve_cookie_ext_config(enabled_override: Option<bool>) -> (Option<PathBuf>, bool) {
let want_cookie_ext = enabled_override.unwrap_or_else(|| {
let env_val = env::var("ARCHIVR_COOKIE_CONSENT").unwrap_or_else(|_| "true".to_string());
!env_val.eq_ignore_ascii_case("false") && env_val != "0"
});
if !want_cookie_ext {
return (None, false);
}
match env::var("ARCHIVR_COOKIE_EXT").ok().filter(|s| !s.is_empty()) {
None => {
eprintln!(
"warn: cookie-consent: ARCHIVR_COOKIE_EXT is not set; \
capturing without cookie-consent blocking"
);
(None, true)
}
Some(ext_path_str) => {
let path = PathBuf::from(&ext_path_str);
if path.is_dir() {
(Some(path), false)
} else {
eprintln!(
"warn: cookie-consent: ARCHIVR_COOKIE_EXT={ext_path_str:?} is not a directory; \
capturing without cookie-consent blocking"
);
(None, true)
}
}
}
}
/// Inner implementation. Takes binary paths and an optional uBlock extension
/// directory explicitly so tests can inject them without touching env vars.
///
/// single-file always manages Chrome. When `ublock_ext` is `Some(path)`, the
/// extension is loaded by passing `--headless=new`, `--load-extension`, and
/// `--disable-extensions-except` inside the `--browser-args` JSON array.
/// single-file's `browser.js` prefix-strips its own conflicting flags before
/// appending ours, so `--headless=new` overrides its default `--headless`.
///
/// Note: single-file always adds `--single-process` to Chrome. uBOL's
/// `declarativeNetRequest` **static** rulesets are registered by Chrome's
/// network stack at extension load time (not by a service worker), so they are
/// expected to apply even in single-process mode. Extension service-worker
/// initialisation may fail silently; this does not affect the static filter
/// lists. Ad-blocking has not been mechanically verified under `--single-process`
/// — if a future test confirms otherwise, consider owning Chrome's lifecycle and
/// using a dedicated `--remote-debugging-port` without `--single-process`.
fn save_with(
url: &str,
store_path: &Path,
@ -44,59 +252,127 @@ fn save_with(
single_file: &str,
chrome: &str,
cookies: &HashMap<String, String>,
ublock_ext: Option<&Path>,
cookie_ext: Option<&Path>,
reader_mode: bool,
) -> 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")?;
// Mandatory user script: strips <script> elements before SingleFile
// serialises so JS-applied CSS is captured without broken module imports.
// When cookie_ext is active, also resets overflow lockout and removes
// consent overlays the extension may have missed.
let strip_scripts_path = temp_dir.join("sf-strip-scripts.js");
let mut strip_scripts = String::from(
// Dispatch single-file-user-script-init so single-file installs
// _singleFile_waitForUserScript, which gates the -request hooks.
"dispatchEvent(new CustomEvent('single-file-user-script-init'));\
addEventListener('single-file-on-before-capture-request',()=>{\
document.querySelectorAll('script:not([type=\"application/ld+json\"])')\
.forEach(el=>el.remove());",
);
if cookie_ext.is_some() {
// Reset overflow:hidden that consent modals inject on body/html.
// Gate on cookie_ext so we never mutate pages where the feature is off.
strip_scripts.push_str(
"document.body&&(document.body.style.overflow='');\
document.documentElement&&(document.documentElement.style.overflow='');\
/* Remove consent overlays the extension may have missed \
* (e.g. Google Funding Choices, Quantcast, Sourcepoint). \
* Selectors are specific to consent infrastructure, not content. */\
document.querySelectorAll(\
'.fc-consent-root,.fc-dialog-overlay,.fc-dialog,\
.qc-cmp2-container,.qc-cmp2-ui,\
.sp-message-container,\
#sp-cc,\
#usercentrics-root'\
).forEach(function(el){el.remove();});",
);
}
if ublock_ext.is_some() {
// uBlock blocks ad network requests but first-party ad placeholder
// elements (ins.adsbygoogle, iframe hosts) retain their computed
// height, leaving blank space. Remove them pre-capture.
strip_scripts.push_str(
"document.querySelectorAll(\
'ins.adsbygoogle,\
[id^=\"aswift_\"],\
iframe[id^=\"google_ads_\"],\
iframe[name^=\"google_ads_frame\"],\
iframe[src*=\"googlesyndication\"],\
iframe[src*=\"doubleclick\"]'\
).forEach(function(el){\
/* Walk up to the nearest ad-slot container so padding/margin \
* on the wrapper (e.g. .top-ad, .google-auto-placed) collapses \
* too, not just the inner ins/iframe element. */\
var slot=el.closest('.top-ad,.google-auto-placed,.ad-slot,.ad-container');\
(slot||el).remove();\
});",
);
}
strip_scripts.push_str("});");
std::fs::write(&strip_scripts_path, &strip_scripts)
.context("failed to write single-file user script")?;
// Chrome's user-data-dir for this capture. Required alongside
// --disable-web-security — newer Chromium silently ignores that flag
// without a writable user-data-dir. Using a subdirectory of temp_dir
// keeps it isolated and it gets cleaned up with the rest of the temp dir.
// Optional reader-mode script: Readability.js + wrapper combined into one
// file so both run in the same execution scope. (Separate --browser-script
// files can each get their own context depending on single-file version.)
let mut extra_browser_scripts: Vec<PathBuf> = Vec::new();
if reader_mode {
let reader_path = temp_dir.join("sf-reader-mode.js");
std::fs::write(&reader_path, READER_MODE_SCRIPT)
.context("failed to write reader-mode script")?;
extra_browser_scripts.push(reader_path);
}
// Isolated Chrome profile directory; cleaned up with the rest of temp.
let chrome_data_dir = temp_dir.join("chrome-data");
// Build the browser-args JSON array. Start with the flags always required,
// then append any extra flags from ARCHIVR_CHROME_ARGS (space-separated).
// Docker containers running as root need "--no-sandbox" here because
// Chromium refuses to start as root without it.
//
// --window-size is set to a realistic desktop viewport so that
// --remove-alternative-medias=false and --remove-unused-styles=false
// actually preserve responsive @media rules and styles that only match
// at normal screen widths (headless Chromium defaults to a small viewport
// that would otherwise defeat the preservation flags).
// Build Chrome flags passed via --browser-args to single-file.
// single-file's browser.js overrides its own defaults with whatever we
// pass here (it strips conflicting flags by prefix before appending ours).
let mut chrome_flags = vec![
"--disable-web-security".to_string(),
format!("--user-data-dir={}", chrome_data_dir.display()),
"--window-size=1920,1080".to_string(),
];
if let Ok(extra) = std::env::var("ARCHIVR_CHROME_ARGS") {
chrome_flags.extend(extra.split_whitespace().filter(|s| !s.is_empty()).map(str::to_string));
// Build comma-separated extension list for Chrome flags.
// --headless=new is required for --load-extension to work.
let ext_paths: Vec<PathBuf> = [ublock_ext, cookie_ext]
.iter()
.filter_map(|p| p.map(|p| p.to_path_buf()))
.collect();
if !ext_paths.is_empty() {
let joined = ext_paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(",");
chrome_flags.push("--headless=new".to_string());
chrome_flags.push(format!("--load-extension={joined}"));
chrome_flags.push(format!("--disable-extensions-except={joined}"));
}
// Operator extras (e.g. --no-sandbox in Docker).
let extra_chrome_args: Vec<String> = env::var("ARCHIVR_CHROME_ARGS")
.unwrap_or_default()
.split_whitespace()
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
chrome_flags.extend(extra_chrome_args);
// single-file expects browser-args as a JSON array of strings.
let quoted: Vec<String> = chrome_flags
.iter()
.map(|f| format!("\"{}\"", f.replace('\\', "\\\\").replace('"', "\\\"")))
.collect();
let browser_args = format!("[{}]", quoted.join(","));
// Write cookie file if cookies are provided.
// Never pass cookie values in process args (ps exposure).
let cookie_file: Option<std::path::PathBuf> = if !cookies.is_empty() {
// Write cookie file (secrets must never appear in process args).
let cookie_file: Option<PathBuf> = if !cookies.is_empty() {
let cf = temp_dir.join("cookies.txt");
let domain = domain_from_url(url);
write_netscape_cookie_file(cookies, &domain, &cf)
@ -106,70 +382,126 @@ fn save_with(
None
};
let mut cmd = Command::new(single_file);
cmd.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")
// Realistic UA: some origins block headless Chrome's default UA string.
.arg("--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
// Chrome-level flags: disable CORS so fonts from any CDN origin can be
// read and inlined (e.g. fonts.gstatic.com without ACAO:*).
.arg(format!("--browser-args={browser_args}"))
// 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");
if let Some(cf) = &cookie_file {
cmd.arg(format!("--browser-cookies-file={}", cf.display()));
}
let spawn_result = cmd
.output()
.with_context(|| format!("failed to spawn {single_file} process"));
let mut scripts: Vec<&Path> = vec![strip_scripts_path.as_path()];
scripts.extend(extra_browser_scripts.iter().map(|p| p.as_path()));
// Delete cookie file unconditionally — including on spawn failure —
// so secrets are never left in store/temp when the capture fails.
let sf_output = run_single_file_standalone(
url,
&out_file,
single_file,
chrome,
&browser_args,
&scripts,
cookie_file.as_deref(),
)
.with_context(|| format!("failed to spawn single-file ({single_file})"))?;
// Delete cookie file unconditionally — including on failure — so secrets
// are never left in store/temp when the capture fails.
if let Some(cf) = &cookie_file {
let _ = std::fs::remove_file(cf);
}
let out = spawn_result?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
bail!("single-file failed: {stderr}");
if !sf_output.status.success() {
let stderr = String::from_utf8_lossy(&sf_output.stderr);
bail!("single-file failed (exit {:?}): {stderr}", sf_output.status.code());
}
if !out_file.exists() {
// Collect diagnostics: stdout, stderr, and what's actually in the temp dir.
let stdout = String::from_utf8_lossy(&sf_output.stdout);
let stderr = String::from_utf8_lossy(&sf_output.stderr);
let dir_contents: String = std::fs::read_dir(&temp_dir)
.map(|rd| {
rd.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_else(|_| "<unreadable>".to_string());
eprintln!(
"warn: single-file produced no file at {}\n temp dir contents: [{dir_contents}]\n stderr: {}\n stdout (first 200 chars): {}",
out_file.display(),
stderr.trim(),
&stdout[..stdout.len().min(200)],
);
bail!(
"single-file exited successfully but produced no output file at {}",
out_file.display()
"single-file exited successfully but produced no output file at {}; \
temp dir contains: [{dir_contents}]; \
stderr: {}",
out_file.display(),
stderr.trim(),
);
}
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 })
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,
ublock_skipped: false, // overwritten by save() from resolve_ublock_config()
cookie_ext_skipped: false, // overwritten by save() from resolve_cookie_ext_config()
})
}
/// Runs single-file, letting it launch and manage Chrome itself.
fn run_single_file_standalone(
url: &str,
out_file: &Path,
single_file: &str,
chrome: &str,
browser_args: &str,
scripts: &[&Path],
cookie_file: Option<&Path>,
) -> std::io::Result<std::process::Output> {
let mut cmd = base_single_file_cmd(url, out_file, single_file, scripts, cookie_file);
cmd.arg(format!("--browser-executable-path={chrome}"))
.arg("--browser-headless")
.arg(format!("--browser-args={browser_args}"));
cmd.output()
}
/// Builds a `Command` with the single-file args that are the same regardless
/// of how Chrome is started. Passes each script as a separate `--browser-script` arg.
fn base_single_file_cmd(
url: &str,
out_file: &Path,
single_file: &str,
scripts: &[&Path],
cookie_file: Option<&Path>,
) -> Command {
let mut cmd = Command::new(single_file);
cmd.arg(url)
.arg(out_file)
.arg("--browser-wait-until=networkidle2")
.arg("--browser-wait-delay=2000")
.arg("--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
.arg("--remove-unused-styles=false")
.arg("--remove-alternative-medias=false")
.arg("--block-scripts=false")
.arg("--remove-unused-fonts=false")
.arg("--remove-alternative-fonts=false")
// Explicitly prevent single-file from dumping HTML to stdout instead of
// writing the file (its Docker-detection heuristic can trigger on some setups).
.arg("--dump-content=false");
for script in scripts {
cmd.arg(format!("--browser-script={}", script.display()));
}
if let Some(cf) = cookie_file {
cmd.arg(format!("--browser-cookies-file={}", cf.display()));
}
cmd
}
// ── HTML helpers ──────────────────────────────────────────────────────────────
/// Reads the first 8 KiB of `path` and extracts the content of the first
/// `<title>…</title>` element. Returns `None` if absent or empty.
///
@ -177,21 +509,17 @@ fn save_with(
/// 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()) }
let mut f = std::fs::File::open(path).ok()?;
let mut buf = vec![0u8; 8192];
let n = f.read(&mut buf).ok()?;
let buf = &buf[..n];
let lower = String::from_utf8_lossy(buf).to_ascii_lowercase();
let start = lower.find("<title>")? + "<title>".len();
let end = lower[start..].find("</title>")? + start;
let title = String::from_utf8_lossy(&buf[start..end])
.trim()
.to_string();
if title.is_empty() { None } else { Some(title) }
}
/// Extracts the favicon embedded in a single-file HTML archive.
@ -205,71 +533,54 @@ fn extract_and_save_favicon(
temp_dir: &Path,
timestamp: &str,
) -> Option<(String, String)> {
let html = std::fs::read_to_string(html_path).ok()?;
let lower = html.to_ascii_lowercase();
let content = std::fs::read_to_string(html_path).ok()?;
let lower = content.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; }
_ => {}
// Find the first <link …> tag that looks like a favicon with a data: href.
let mut search_pos = 0;
loop {
let tag_start = lower[search_pos..].find("<link")? + search_pos;
let tag_end = lower[tag_start..].find('>')? + tag_start;
let tag = &lower[tag_start..=tag_end];
if tag.contains("icon") {
// Look for href="data:image/...;base64,..."
if let Some(href_pos) = tag.find("href=") {
let after_href = &content[tag_start + href_pos + 5..];
let (quote, after_quote) = if after_href.starts_with('"') {
('"', &after_href[1..])
} else if after_href.starts_with('\'') {
('\'', &after_href[1..])
} else {
search_pos = tag_end + 1;
continue;
};
let value_end = after_quote.find(quote)?;
let href_value = &after_quote[..value_end];
if let Some(b64_start) = href_value.to_ascii_lowercase().find(";base64,") {
let mime_part = &href_value[5..b64_start]; // skip "data:"
let ext = mime_to_favicon_ext(mime_part)?;
let b64_data = &href_value[b64_start + 8..];
let bytes = B64.decode(b64_data).ok()?;
let out_path = temp_dir.join(format!("{timestamp}.favicon{ext}"));
std::fs::write(&out_path, &bytes).ok()?;
let hash = hash_file(&out_path).ok()?;
return Some((hash, ext.to_string()));
}
}
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;
search_pos = tag_end + 1;
}
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 {
match mime.to_ascii_lowercase().trim() {
"image/x-icon" | "image/vnd.microsoft.icon" => Some(".ico"),
"image/png" => Some(".png"),
"image/svg+xml" => Some(".svg"),
"image/png" => Some(".png"),
"image/jpeg" => Some(".jpg"),
"image/gif" => Some(".gif"),
"image/gif" => Some(".gif"),
"image/svg+xml" => Some(".svg"),
"image/webp" => Some(".webp"),
_ => None,
}
@ -323,6 +634,9 @@ mod tests {
"/nonexistent/single-file",
"chromium",
&HashMap::new(),
None, // no ublock ext
None, // no cookie ext
false, // reader mode off
);
let err = result.unwrap_err();
let msg = format!("{err:#}");
@ -331,4 +645,42 @@ mod tests {
"unexpected error: {msg}"
);
}
#[test]
fn save_with_both_extensions_uses_comma_joined_flags() {
use std::path::Path;
// We can't run single-file here, but we can exercise the flag-building
// logic by checking the path list construction directly.
let ublock = Path::new("/tmp/ublock");
let cookie = Path::new("/tmp/cookie");
let ext_paths: Vec<std::path::PathBuf> = [Some(ublock), Some(cookie)]
.iter()
.filter_map(|p| p.map(|p| p.to_path_buf()))
.collect();
let joined = ext_paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(",");
assert_eq!(joined, "/tmp/ublock,/tmp/cookie");
let load_flag = format!("--load-extension={joined}");
let except_flag = format!("--disable-extensions-except={joined}");
assert_eq!(load_flag, "--load-extension=/tmp/ublock,/tmp/cookie");
assert_eq!(except_flag, "--disable-extensions-except=/tmp/ublock,/tmp/cookie");
}
#[test]
fn resolve_ublock_config_disabled_when_false() {
// Can't mutate env vars safely in parallel tests; test the logic directly
// by verifying the env-var parsing branch we care about.
let enabled = "false";
let is_disabled =
enabled.eq_ignore_ascii_case("false") || enabled == "0";
assert!(is_disabled);
let enabled = "0";
let is_disabled =
enabled.eq_ignore_ascii_case("false") || enabled == "0";
assert!(is_disabled);
}
}

View file

@ -650,9 +650,11 @@ async fn delete_entry_handler(
#[derive(Debug, serde::Deserialize)]
struct CaptureBody {
locator: String,
/// Optional quality cap for yt-dlp sources: `"best"` or any `"NNNp"` string
/// (e.g. `"1080p"`, `"720p"`, `"2160p"`). Absent or `"best"` → highest available.
quality: Option<String>,
ublock_enabled: Option<bool>,
/// Distil to article content via Readability before archiving. Absent = false.
reader_mode: Option<bool>,
cookie_ext_enabled: Option<bool>,
}
#[derive(Debug, serde::Deserialize)]
@ -732,15 +734,29 @@ async fn capture_handler(
let conn = database::open_or_initialize(&mounted.archive_path)?;
let job_uid = database::create_capture_job(&conn, &archive_id)?;
drop(conn);
// Load cookie rules from the auth DB to pass into the capture background task.
let cookie_rules = {
// Load cookie rules and global uBlock / cookie-ext settings from the auth DB.
let (cookie_rules, global_ublock, global_cookie_ext) = {
match database::open_auth_db(&state.auth_db_path) {
Ok(conn) => database::list_cookie_rules(&conn).unwrap_or_default(),
Err(_) => vec![],
Ok(conn) => {
let rules = database::list_cookie_rules(&conn).unwrap_or_default();
let settings = database::get_instance_settings(&conn);
let ublock = settings.as_ref().map(|s| s.ublock_enabled).unwrap_or(true);
let cookie_ext = settings.map(|s| s.cookie_ext_enabled).unwrap_or(true);
(rules, ublock, cookie_ext)
}
Err(_) => (vec![], true, true),
}
};
let capture_config = capture::CaptureConfig { cookie_rules };
// Per-capture body overrides global; if body doesn't specify, use the global setting.
// The resolved bool is then passed as Some(_) to singlefile, overriding the env var.
let effective_ublock = body.ublock_enabled.unwrap_or(global_ublock);
let effective_cookie_ext = body.cookie_ext_enabled.unwrap_or(global_cookie_ext);
let capture_config = capture::CaptureConfig {
cookie_rules,
ublock_enabled: Some(effective_ublock),
cookie_ext_enabled: Some(effective_cookie_ext),
reader_mode: body.reader_mode.unwrap_or(false),
};
// Spawn background capture.
let locator = body.locator.trim().to_string();
@ -756,15 +772,30 @@ async fn capture_handler(
return;
}
};
database::update_capture_job_status(&conn, &job_uid_bg, "running", None, None).ok();
database::update_capture_job_status(&conn, &job_uid_bg, "running", None, None, None).ok();
match capture::perform_capture(&archive_paths, &locator, Some(&archive_id_bg), quality.as_deref(), &capture_config) {
Ok(result) => {
let mut notes_map = serde_json::Map::new();
if result.ublock_skipped {
notes_map.insert("ublock_skipped".into(), serde_json::Value::Bool(true));
}
if result.cookie_ext_skipped {
notes_map.insert("cookie_ext_skipped".into(), serde_json::Value::Bool(true));
}
let notes_str;
let notes: Option<&str> = if notes_map.is_empty() {
None
} else {
notes_str = serde_json::Value::Object(notes_map).to_string();
Some(&notes_str)
};
database::update_capture_job_status(
&conn,
&job_uid_bg,
"completed",
Some(&result.run_uid),
None,
notes,
)
.ok();
}
@ -775,6 +806,7 @@ async fn capture_handler(
"failed",
None,
Some(&format!("{e:#}")),
None,
)
.ok();
}
@ -1013,10 +1045,26 @@ async fn patch_me(
async fn get_instance_settings_handler(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<Json<database::InstanceSettings>, ApiError> {
) -> Result<Json<serde_json::Value>, ApiError> {
auth_user.require_role(ROLE_ADMIN)?;
let conn = database::open_auth_db(&state.auth_db_path)?;
Ok(Json(database::get_instance_settings(&conn)?))
let settings = database::get_instance_settings(&conn)?;
let ublock_ext_available = std::env::var("ARCHIVR_UBLOCK_EXT")
.ok()
.filter(|s| !s.is_empty())
.map(|p| std::path::Path::new(&p).is_dir())
.unwrap_or(false);
let cookie_ext_available = std::env::var("ARCHIVR_COOKIE_EXT")
.ok()
.filter(|s| !s.is_empty())
.map(|p| std::path::Path::new(&p).is_dir())
.unwrap_or(false);
let mut val = serde_json::to_value(&settings).unwrap_or_default();
if let Some(obj) = val.as_object_mut() {
obj.insert("ublock_ext_available".into(), serde_json::Value::Bool(ublock_ext_available));
obj.insert("cookie_ext_available".into(), serde_json::Value::Bool(cookie_ext_available));
}
Ok(Json(val))
}
async fn update_instance_settings_handler(
@ -1031,6 +1079,8 @@ async fn update_instance_settings_handler(
if let Some(v) = body.public_entry_content_enabled { settings.public_entry_content_enabled = v; }
if let Some(v) = body.open_registration_enabled { settings.open_registration_enabled = v; }
if let Some(v) = body.default_entry_visibility { settings.default_entry_visibility = v; }
if let Some(v) = body.ublock_enabled { settings.ublock_enabled = v; }
if let Some(v) = body.cookie_ext_enabled { settings.cookie_ext_enabled = v; }
database::update_instance_settings(&conn, &settings)?;
Ok(StatusCode::NO_CONTENT)
}
@ -1360,6 +1410,8 @@ struct UpdateInstanceSettingsBody {
public_entry_content_enabled: Option<bool>,
open_registration_enabled: Option<bool>,
default_entry_visibility: Option<u32>,
ublock_enabled: Option<bool>,
cookie_ext_enabled: Option<bool>,
}
async fn admin_list_users(

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-CHDuICqH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BW0QKHXE.css">
<script type="module" crossorigin src="/assets/index-D6iX-c2T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C0BIEdow.css">
</head>
<body>
<div id="root"></div>

View file

@ -60,6 +60,40 @@
tweetPython = pkgs.python312.withPackages (ps: [
twitterApiClient
]);
# uBlock Origin Lite (MV3) — unpacked Chromium extension for headless ad-blocking.
# Fetched from the uBOL-home GitHub releases; update version + hash together.
ublockLite = pkgs.stdenv.mkDerivation {
pname = "ublock-origin-lite";
version = "2026.705.2152";
src = pkgs.fetchurl {
url = "https://github.com/uBlockOrigin/uBOL-home/releases/download/2026.705.2152/uBOLite_2026.705.2152.chromium.zip";
hash = "sha256-4TbvDYbkOkDuVK17TeAbLDBcgf9O6f/vh2buGbLu4XQ=";
};
nativeBuildInputs = [ pkgs.unzip ];
sourceRoot = ".";
installPhase = ''
mkdir -p $out
cp -r . $out/
'';
};
# I Still Don't Care About Cookies (MV3) — unpacked Chromium extension
# for dismissing cookie consent banners during headless captures.
# Fetched from GitHub releases; update version + hash together.
isdcac = pkgs.stdenv.mkDerivation {
pname = "istilldontcareaboutcookies";
version = "1.1.9";
src = pkgs.fetchurl {
url = "https://github.com/OhMyGuus/I-Still-Dont-Care-About-Cookies/releases/download/v1.1.9/ISDCAC-chrome-source.zip";
hash = "sha256-j3CrlHyy0nT0AiqXD13Tzs2OwCsGDgUYe++e48sYu8s=";
};
nativeBuildInputs = [ pkgs.unzip ];
sourceRoot = ".";
installPhase = ''
test -f manifest.json || { echo "ERROR: manifest.json not at extension root; zip structure may have changed"; exit 1; }
mkdir -p $out
cp -r . $out/
'';
};
version = "0.1.0";
src = pkgs.lib.cleanSource ./.;
cargoLock = {
@ -123,6 +157,8 @@
${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 \
--set ARCHIVR_UBLOCK_EXT ${ublockLite} \
--set ARCHIVR_COOKIE_EXT ${isdcac} \
--prefix PATH : ${
lib.makeBinPath ([
pkgs.yt-dlp
@ -149,7 +185,9 @@
--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
--set ARCHIVR_TWEET_SCRAPER $out/libexec/archivr-server/scrape_user_tweet_contents.py \
--set ARCHIVR_UBLOCK_EXT ${ublockLite} \
--set ARCHIVR_COOKIE_EXT ${isdcac}
'';
};
archivr-all = pkgs.symlinkJoin {

View file

@ -85,6 +85,9 @@ export default function App() {
const [toasts, setToasts] = useState([])
const toastIdRef = useRef(0)
const [ublockWarningIgnored, setUblockWarningIgnored] = useState(
() => sessionStorage.getItem('ublockWarningIgnored') === 'true'
)
const humanizeTags = currentUser?.humanize_slugs ?? false;
@ -238,15 +241,22 @@ export default function App() {
])
}, [archiveId, searchQuery, tagFilter, loadEntries])
const handleToast = useCallback((errorText, locator) => {
const handleToast = useCallback((text, locator, type = 'error') => {
if (type === 'warning' && ublockWarningIgnored) return
const id = ++toastIdRef.current
setToasts(prev => [...prev, { id, errorText, locator }])
}, [])
setToasts(prev => [...prev, { id, text, locator, type }])
}, [ublockWarningIgnored])
const handleDismissToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const handleIgnoreUblock = useCallback(() => {
sessionStorage.setItem('ublockWarningIgnored', 'true')
setUblockWarningIgnored(true)
setToasts(prev => prev.filter(t => t.type !== 'warning'))
}, [])
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;
if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;
@ -347,7 +357,7 @@ export default function App() {
onCaptured={handleCaptured}
onToast={handleToast}
/>
<ToastStack toasts={toasts} onDismiss={handleDismissToast} />
<ToastStack toasts={toasts} onDismiss={handleDismissToast} onIgnoreUblock={handleIgnoreUblock} />
</>
</AuthContext.Provider>
)

View file

@ -95,9 +95,15 @@ export async function fetchTags(archiveId) {
return getJson(`/api/archives/${archiveId}/tags`);
}
export async function submitCapture(archiveId, locator, quality = null) {
export async function submitCapture(archiveId, locator, quality = null, extensions = null) {
const payload = { locator }
if (quality && quality !== 'best') payload.quality = quality
// extensions: { ublock_enabled?: bool, reader_mode?: bool } — per-capture overrides
if (extensions) {
if (typeof extensions.ublock_enabled === 'boolean') payload.ublock_enabled = extensions.ublock_enabled
if (typeof extensions.reader_mode === 'boolean') payload.reader_mode = extensions.reader_mode
if (typeof extensions.cookie_ext_enabled === 'boolean') payload.cookie_ext_enabled = extensions.cookie_ext_enabled
}
const res = await fetch(`/api/archives/${archiveId}/captures`, {
method: "POST",
headers: { "Content-Type": "application/json" },

View file

@ -1,5 +1,5 @@
import { useRef, useEffect, useState } from 'react'
import { submitCapture, pollCaptureJob, probeCapture } from '../api'
import { useRef, useEffect, useState, useCallback } from 'react'
import { submitCapture, pollCaptureJob, probeCapture, getInstanceSettings } from '../api'
let nextItemId = 1
@ -112,6 +112,31 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
sessionStorage.setItem('captureItems', JSON.stringify(items))
}, [items])
// Advanced options panel state
const [advancedOpen, setAdvancedOpen] = useState(false)
// null = use server default; true/false = per-session override
const [ublockOverride, setUblockOverride] = useState(null)
// Server-side global settings (loaded on mount, null until loaded)
const [globalSettings, setGlobalSettings] = useState(null)
// Cookie consent: session-level only, initialized from server default
const [cookieExtEnabled, setCookieExtEnabled] = useState(true)
// Load global settings from server once on mount
useEffect(() => {
getInstanceSettings()
.then(s => {
setGlobalSettings(s)
setCookieExtEnabled(s.cookie_ext_enabled ?? true)
})
.catch(() => setGlobalSettings({}))
}, [])
// Effective uBlock for this session
const ublockEnabled = ublockOverride !== null ? ublockOverride : (globalSettings?.ublock_enabled ?? true)
// Reader mode: off by default, per-session only
const [readerMode, setReaderMode] = useState(false)
// On mount: clean up old single-locator sessionStorage keys; reconnect running jobs
useEffect(() => {
;['captureDialogLocator','captureDialogError','captureDialogBusy',
@ -186,6 +211,13 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
})
}, 1400)
onCapturedRef.current()
// Warn if uBlock was requested but the extension wasn't available
try {
const notes = updated.notes_json ? JSON.parse(updated.notes_json) : null
if (notes?.ublock_skipped || notes?.cookie_ext_skipped) {
onToastRef.current(null, locator, 'warning')
}
} catch {}
} else if (updated.status === 'failed') {
clearInterval(pollIntervals.current.get(jobUid))
pollIntervals.current.delete(jobUid)
@ -216,7 +248,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const qual = item.quality || 'best'
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
try {
const job = await submitCapture(aid, loc, qual)
const extensions = { ublock_enabled: ublockEnabled, reader_mode: readerMode, cookie_ext_enabled: cookieExtEnabled }
const job = await submitCapture(aid, loc, qual, extensions)
setItems(prev => prev.map(it =>
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it
))
@ -335,10 +368,82 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
Add another
</button>
<div className="capture-actions">
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
{anyActive ? 'Close' : 'Cancel'}
{/* ── Advanced options ────────────────────────────── */}
<div className="capture-advanced">
<button
type="button"
className="capture-advanced-toggle"
onClick={() => setAdvancedOpen(v => !v)}
aria-expanded={advancedOpen}
>
<svg
className={`capture-chevron${advancedOpen ? ' capture-chevron--open' : ''}`}
viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
>
<polyline points="4 6 8 10 12 6"/>
</svg>
Advanced options
</button>
{advancedOpen && (
<div className="capture-advanced-panel">
<label className="capture-ext-row">
<span className="capture-ext-label">
<span className="capture-ext-name">uBlock Origin Lite</span>
<span className="capture-ext-desc">Block ads during this capture</span>
</span>
<button
type="button"
role="switch"
aria-checked={ublockEnabled}
className={`ext-toggle ext-toggle--sm${ublockEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => setUblockOverride(v => v === null ? !ublockEnabled : !v)}
aria-label="Toggle uBlock for this capture"
>
<span className="ext-toggle-knob" />
</button>
</label>
<label className="capture-ext-row" style={{ marginTop: 8 }}>
<span className="capture-ext-label">
<span className="capture-ext-name">Block cookie banners</span>
<span className="capture-ext-desc">Dismiss cookie consent banners during this capture</span>
{!globalSettings?.cookie_ext_available && (
<span className="capture-ext-hint">Not configured &mdash; set <code>ARCHIVR_COOKIE_EXT</code></span>
)}
</span>
<button
type="button"
role="switch"
aria-checked={cookieExtEnabled}
className={`ext-toggle ext-toggle--sm${cookieExtEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => setCookieExtEnabled(v => !v)}
aria-label="Toggle cookie banner blocking for this capture"
>
<span className="ext-toggle-knob" />
</button>
</label>
<label className="capture-ext-row" style={{ marginTop: 8 }}>
<span className="capture-ext-label">
<span className="capture-ext-name">Reader mode</span>
<span className="capture-ext-desc">Distil to article text via Readability (off by default)</span>
</span>
<button
type="button"
role="switch"
aria-checked={readerMode}
className={`ext-toggle ext-toggle--sm${readerMode ? ' ext-toggle--on' : ''}`}
onClick={() => setReaderMode(v => !v)}
aria-label="Toggle reader mode for this capture"
>
<span className="ext-toggle-knob" />
</button>
</label>
</div>
)}
</div>
{/* ── Primary action ──────────────────────────────── */}
<div className="capture-actions">
<button
type="button"
className="capture-submit"
@ -347,6 +452,9 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
>
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
</button>
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
{anyActive ? 'Close' : 'Cancel'}
</button>
</div>
</div>
</dialog>

View file

@ -14,8 +14,8 @@ export default function SettingsView({ tab, onTabChange, archiveId }) {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance', 'cookies', 'storage'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance', cookies: 'Cookies', storage: 'Storage' }
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance', 'cookies', 'extensions', 'storage'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance', cookies: 'Cookies', extensions: 'Extensions', storage: 'Storage' }
return (
<section className="admin-view">
@ -34,6 +34,7 @@ export default function SettingsView({ tab, onTabChange, archiveId }) {
{tab === 'tokens' && <TokensTab />}
{tab === 'instance' && isAdmin && <InstanceTab />}
{tab === 'cookies' && isAdmin && <CookiesTab />}
{tab === 'extensions' && isAdmin && <ExtensionsTab />}
{tab === 'storage' && isAdmin && <StorageTab archiveId={archiveId} />}
</section>
)
@ -638,4 +639,125 @@ function CookiesTab() {
</div>
</div>
)
}
function ExtensionsTab() {
const [settings, setSettings] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState(null)
useEffect(() => {
(async () => {
try { setSettings(await getInstanceSettings()) }
catch (e) { setMsg({ ok: false, text: e.message }) }
finally { setLoading(false) }
})()
}, [])
async function toggleUblock(val) {
setSaving(true)
setMsg(null)
try {
await updateInstanceSettings({ ublock_enabled: val })
setSettings(s => ({ ...s, ublock_enabled: val }))
setMsg({ ok: true, text: 'Saved.' })
} catch (e) {
setMsg({ ok: false, text: e.message })
} finally {
setSaving(false)
}
}
async function toggleCookieExt(val) {
setSaving(true)
setMsg(null)
try {
await updateInstanceSettings({ cookie_ext_enabled: val })
setSettings(s => ({ ...s, cookie_ext_enabled: val }))
setMsg({ ok: true, text: 'Saved.' })
} catch (e) {
setMsg({ ok: false, text: e.message })
} finally {
setSaving(false)
}
}
if (loading) return <div className="muted">Loading\u2026</div>
const extAvailable = settings?.ublock_ext_available ?? false
const extEnabled = settings?.ublock_enabled ?? true
const cookieExtAvailable = settings?.cookie_ext_available ?? false
const cookieExtEnabled = settings?.cookie_ext_enabled ?? true
return (
<div style={{ maxWidth: 560 }}>
<div className="form-section">
<h2>Extensions</h2>
<p className="form-hint" style={{ marginBottom: 20 }}>
Extensions run inside the browser during WebPage captures and can block ads,
accept cookie banners, and more. Changes take effect on the next capture.
</p>
<div className="ext-card">
<div className="ext-card-header">
<div className="ext-card-info">
<span className="ext-card-name">uBlock Origin Lite</span>
<span className="ext-card-desc">
Blocks ads, trackers, and other page clutter during archiving
via Chrome&rsquo;s declarativeNetRequest API (Manifest V3).
</span>
{!extAvailable && (
<span className="ext-card-hint">
Not configured &mdash; set <code>ARCHIVR_UBLOCK_EXT</code> to the
unpacked extension directory to enable.
</span>
)}
</div>
<button
type="button"
role="switch"
aria-checked={extEnabled}
className={`ext-toggle${extEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => toggleUblock(!extEnabled)}
disabled={saving}
aria-label="Toggle uBlock Origin Lite"
>
<span className="ext-toggle-knob" />
</button>
</div>
</div>
<div className="ext-card">
<div className="ext-card-header">
<div className="ext-card-info">
<span className="ext-card-name">I Still Don&rsquo;t Care About Cookies</span>
<span className="ext-card-desc">
Dismiss cookie consent banners during archiving.
</span>
{!cookieExtAvailable && (
<span className="ext-card-hint">
Not configured &mdash; set <code>ARCHIVR_COOKIE_EXT</code> to the
unpacked extension directory to enable.
</span>
)}
</div>
<button
type="button"
role="switch"
aria-checked={cookieExtEnabled}
className={`ext-toggle${cookieExtEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => toggleCookieExt(!cookieExtEnabled)}
disabled={saving}
aria-label="Toggle I Still Don't Care About Cookies"
>
<span className="ext-toggle-knob" />
</button>
</div>
</div>
{msg && <div className={`form-msg form-msg--${msg.ok ? 'ok' : 'err'}`}>{msg.text}</div>}
</div>
</div>
)
}

View file

@ -1,22 +1,23 @@
import { useState, useEffect } from 'react'
const TOAST_TTL = 7000 // ms before auto-dismiss; paused when error is expanded
const TOAST_TTL = 7000 // ms before auto-dismiss; paused when detail is expanded
export default function ToastStack({ toasts, onDismiss }) {
export default function ToastStack({ toasts, onDismiss, onIgnoreUblock }) {
if (!toasts.length) return null
return (
<div className="toast-stack" role="log" aria-live="polite" aria-label="Notifications">
{toasts.map(t => (
<Toast key={t.id} toast={t} onDismiss={onDismiss} />
<Toast key={t.id} toast={t} onDismiss={onDismiss} onIgnoreUblock={onIgnoreUblock} />
))}
</div>
)
}
function Toast({ toast, onDismiss }) {
function Toast({ toast, onDismiss, onIgnoreUblock }) {
const [expanded, setExpanded] = useState(false)
const isWarning = toast.type === 'warning'
// Auto-dismiss after TTL; paused while error detail is expanded
// Auto-dismiss after TTL; paused while detail is expanded
useEffect(() => {
if (expanded) return
const timer = setTimeout(() => onDismiss(toast.id), TOAST_TTL)
@ -27,6 +28,50 @@ function Toast({ toast, onDismiss }) {
? (toast.locator.length > 48 ? toast.locator.slice(0, 45) + '\u2026' : toast.locator)
: null
if (isWarning) {
return (
<div className="toast toast--warning" role="alert" aria-atomic="true">
<div className="toast-top">
<span className="toast-icon" aria-hidden="true"></span>
<div className="toast-body">
<span className="toast-headline">Ad-blocking unavailable</span>
{short && <span className="toast-locator">{short}</span>}
</div>
<div className="toast-btns">
<button
type="button"
className="toast-view-btn"
onClick={() => setExpanded(v => !v)}
aria-expanded={expanded}
>
{expanded ? 'Hide' : 'Details'}
</button>
<button
type="button"
className="toast-view-btn toast-ignore-btn"
onClick={() => { onIgnoreUblock?.(); onDismiss(toast.id) }}
>
Ignore
</button>
<button
type="button"
className="toast-dismiss"
onClick={() => onDismiss(toast.id)}
aria-label="Dismiss"
>
×
</button>
</div>
</div>
{expanded && (
<p className="toast-warning-detail">
{toast.text || 'ARCHIVR_UBLOCK=true but ARCHIVR_UBLOCK_EXT is not set or the path is invalid. The page was captured without ad-blocking. Set ARCHIVR_UBLOCK_EXT to the unpacked uBlock Origin Lite extension directory to enable ad-blocking, or set ARCHIVR_UBLOCK=false to silence this warning.'}
</p>
)}
</div>
)
}
return (
<div className="toast toast--error" role="alert" aria-atomic="true">
<div className="toast-top">
@ -36,7 +81,7 @@ function Toast({ toast, onDismiss }) {
{short && <span className="toast-locator">{short}</span>}
</div>
<div className="toast-btns">
{toast.errorText && (
{toast.text && (
<button
type="button"
className="toast-view-btn"
@ -55,8 +100,8 @@ function Toast({ toast, onDismiss }) {
</button>
</div>
</div>
{expanded && toast.errorText && (
<pre className="toast-error-detail">{toast.errorText}</pre>
{expanded && toast.text && (
<pre className="toast-error-detail">{toast.text}</pre>
)}
</div>
)

View file

@ -608,30 +608,86 @@ select {
}
.capture-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 16px;
flex-direction: column;
gap: 8px;
margin-top: 18px;
}
.capture-cancel {
border: 1px solid var(--line);
background: none;
color: var(--ink);
padding: 8px 18px;
border-radius: var(--r);
cursor: pointer;
}
.capture-cancel:hover { background: var(--paper-2); }
.capture-submit {
border: 0;
background: var(--ink);
color: var(--paper);
padding: 8px 20px;
border-radius: var(--r);
padding: 13px 20px;
border-radius: var(--r2);
cursor: pointer;
font-weight: 600;
font-size: 15px;
width: 100%;
min-width: 220px;
letter-spacing: 0.01em;
}
.capture-submit:hover { opacity: 0.85; }
.capture-submit:disabled { opacity: 0.45; cursor: default; }
.capture-cancel {
border: 0;
background: none;
color: var(--muted);
padding: 7px 18px;
border-radius: var(--r);
cursor: pointer;
font-size: 13px;
text-align: center;
width: 100%;
}
.capture-cancel:hover { color: var(--ink); background: var(--paper-2); }
/* ── Capture advanced options ────────────────────────────────────────────── */
.capture-advanced { margin-top: 12px; }
.capture-advanced-toggle {
display: flex;
align-items: center;
gap: 5px;
background: none;
border: 0;
cursor: pointer;
color: var(--muted);
font-size: 12.5px;
padding: 4px 0;
border-radius: var(--r);
}
.capture-advanced-toggle:hover { color: var(--ink); }
.capture-chevron {
width: 14px; height: 14px;
flex-shrink: 0;
transition: transform 0.18s ease;
}
.capture-chevron--open { transform: rotate(180deg); }
.capture-advanced-panel {
margin-top: 8px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--r2);
background: var(--paper-2);
}
.capture-ext-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: default;
}
.capture-ext-label { flex: 1; min-width: 0; }
.capture-ext-name {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--ink);
}
.capture-ext-desc {
display: block;
font-size: 11.5px;
color: var(--muted);
margin-top: 1px;
}
/* ── Capture dialog: header + multi-row ─────────────────────────────────── */
.capture-dialog-header {
@ -812,6 +868,7 @@ select {
to { opacity: 1; transform: translateY(0) scale(1); }
}
.toast--error { border-left: 3px solid var(--accent); }
.toast--warning { border-left: 3px solid #e8a000; }
.toast-top {
display: flex;
@ -892,6 +949,84 @@ select {
max-height: 200px;
overflow-y: auto;
}
.toast-warning-detail {
margin: 0;
padding: 10px 14px 12px;
font-size: 12px;
line-height: 1.55;
color: var(--muted);
background: var(--paper-2);
border-top: 1px solid var(--line);
white-space: pre-wrap;
word-break: break-word;
}
.toast-ignore-btn { color: var(--muted); }
/* ── Extension toggle (pill switch) ─────────────────────────────────────── */
.ext-toggle {
flex-shrink: 0;
position: relative;
width: 44px;
height: 24px;
border-radius: 12px;
background: var(--line);
border: 0;
cursor: pointer;
transition: background 0.18s ease;
padding: 0;
}
.ext-toggle--sm { width: 36px; height: 20px; border-radius: 10px; }
.ext-toggle--on { background: var(--ink); }
.ext-toggle:disabled { opacity: 0.4; cursor: default; }
.ext-toggle-knob {
position: absolute;
top: 3px; left: 3px;
width: 18px; height: 18px;
border-radius: 50%;
background: var(--paper);
transition: transform 0.18s ease;
display: block;
box-shadow: 0 1px 3px rgba(0,0,0,.2);
}
.ext-toggle--sm .ext-toggle-knob { width: 14px; height: 14px; top: 3px; left: 3px; }
.ext-toggle--on .ext-toggle-knob { transform: translateX(20px); }
.ext-toggle--sm.ext-toggle--on .ext-toggle-knob { transform: translateX(16px); }
/* ── Extension card (Settings / Extensions tab) ──────────────────────────── */
.ext-card {
border: 1px solid var(--line);
border-radius: var(--r2);
padding: 16px 18px;
background: var(--paper);
}
.ext-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.ext-card-info { flex: 1; min-width: 0; }
.ext-card-name {
display: block;
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
}
.ext-card-desc {
display: block;
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.ext-card-hint {
display: block;
font-size: 12px;
color: #e8a000;
margin-top: 6px;
}
.ext-card-hint code { font-size: 11.5px; }
.form-hint { font-size: 13px; color: var(--muted); line-height: 1.55; margin: 0; }
/* ── Utility ─────────────────────────────────────────────────────────────── */
.muted { color: var(--muted); }

2812
vendor/readability/Readability.js vendored Normal file

File diff suppressed because it is too large Load diff