1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 11:15:41 +02:00

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.
This commit is contained in:
TheGeneralist 2026-07-08 11:25:37 +02:00
parent a6ca24b846
commit 52effb60f6
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
10 changed files with 260 additions and 70 deletions

View file

@ -40,6 +40,8 @@ pub struct CaptureResult {
/// `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)]
@ -79,6 +81,8 @@ 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,
}
@ -999,6 +1003,7 @@ pub fn perform_capture(
run_uid: run.run_uid.clone(),
status: "completed".to_string(),
ublock_skipped: false,
cookie_ext_skipped: false,
});
}
Err(e) => {
@ -1014,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, config.ublock_enabled, config.reader_mode) {
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
@ -1142,6 +1147,7 @@ pub fn perform_capture(
run_uid: run.run_uid.clone(),
status: "completed".to_string(),
ublock_skipped: result.ublock_skipped,
cookie_ext_skipped: result.cookie_ext_skipped,
});
}
Err(e) => {
@ -1197,6 +1203,7 @@ pub fn perform_capture(
run_uid: run.run_uid.clone(),
status: "completed".to_string(),
ublock_skipped: false,
cookie_ext_skipped: false,
});
}
Err(e) => {
@ -1353,6 +1360,7 @@ pub fn perform_capture(
run_uid: run.run_uid.clone(),
status: "completed".to_string(),
ublock_skipped: false,
cookie_ext_skipped: false,
})
}

View file

@ -140,6 +140,8 @@ pub struct InstanceSettings {
/// 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)]
@ -198,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 (
@ -464,7 +467,8 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
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,
ublock_enabled INTEGER NOT NULL DEFAULT 1 CHECK (ublock_enabled IN (0, 1))
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
@ -509,6 +513,11 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
"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(())
}
@ -735,7 +744,8 @@ 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,
COALESCE(ublock_enabled, 1)
COALESCE(ublock_enabled, 1),
COALESCE(cookie_ext_enabled, 1)
FROM instance_settings WHERE id = 1",
[],
|row| {
@ -745,6 +755,7 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
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,
})
},
)
@ -758,7 +769,8 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
public_entry_content_enabled = ?2,
public_archive_submission_enabled = ?3,
default_entry_visibility = ?4,
ublock_enabled = ?5
ublock_enabled = ?5,
cookie_ext_enabled = ?6
WHERE id = 1",
params![
settings.public_index_enabled as i64,
@ -766,6 +778,7 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
settings.open_registration_enabled as i64,
settings.default_entry_visibility as i64,
settings.ublock_enabled as i64,
settings.cookie_ext_enabled as i64,
],
)?;
Ok(())

View file

@ -108,6 +108,9 @@ pub struct SaveResult {
/// `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.
@ -124,12 +127,14 @@ pub fn save(
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());
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,
@ -138,9 +143,11 @@ pub fn save(
&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)
}
@ -183,6 +190,44 @@ fn resolve_ublock_config(enabled_override: Option<bool>) -> (Option<PathBuf>, bo
}
}
/// 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.
///
@ -208,6 +253,7 @@ fn save_with(
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);
@ -252,12 +298,21 @@ fn save_with(
format!("--user-data-dir={}", chrome_data_dir.display()),
"--window-size=1920,1080".to_string(),
];
if let Some(ext_path) = ublock_ext {
// --headless=new is required for --load-extension to work.
// It overrides single-file's own --headless default via the prefix-strip logic.
// 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={}", ext_path.display()));
chrome_flags.push(format!("--disable-extensions-except={}", ext_path.display()));
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")
@ -350,7 +405,8 @@ fn save_with(
title,
favicon_hash,
favicon_ext,
ublock_skipped: false, // overwritten by save() from resolve_ublock_config()
ublock_skipped: false, // overwritten by save() from resolve_ublock_config()
cookie_ext_skipped: false, // overwritten by save() from resolve_cookie_ext_config()
})
}
@ -538,6 +594,7 @@ mod tests {
"chromium",
&HashMap::new(),
None, // no ublock ext
None, // no cookie ext
false, // reader mode off
);
let err = result.unwrap_err();
@ -548,6 +605,29 @@ mod tests {
);
}
#[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