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

feat: integrate Modal Closer as injected browser script (#27)

Port the modalcloser abx-plugin as a SingleFile --browser-script rather
than a spawned Puppeteer daemon. No external extension download required.

Core behavior (singlefile.rs):
- MODAL_CLOSER_DIALOG_OVERRIDES: main-world <script> bridge (best-effort;
  blocked by strict script-src CSP) that overrides window.alert/confirm/
  prompt/print, nulls window.onbeforeunload, traps its setter, and wraps
  window.addEventListener to no-op 'beforeunload' registrations.
- MODAL_CLOSER_POLLING_SETUP: defines _archivr_mc_run() which runs two
  passes on every tick (immediate first run, then setInterval every 500 ms
  matching MODALCLOSER_POLL_INTERVAL default):
    Pass 1 (best-effort, CSP-sensitive): main-world <script> bridge calls
    Bootstrap/jQuery/jQuery UI/SweetAlert teardown APIs.
    Pass 2 (always, CSP-immune): isolated-world DOM mutations — Escape-key
    dispatch (Radix/Headless UI/Angular Material), backdrop clicks, full
    CSS selector hiding for 40+ named consent/overlay vendors, body
    scroll-lock reset. Direct DOM mutations need no inline script execution.
- resolve_modal_closer_config(): reads ARCHIVR_MODAL_CLOSER env var
  (default true); no external resource required.
- modal_closer_enabled: Option<bool> in CaptureConfig so Default::default()
  yields None (follow env var) rather than false.

Schema (database.rs):
- modal_closer_enabled column in auth DB instance_settings (default 1).
- Idempotent ALTER TABLE migration for existing databases.
- get/update_instance_settings updated (SELECT col 6, UPDATE param ?7).

HTTP (routes.rs):
- modal_closer_enabled in CaptureBody and UpdateInstanceSettingsBody.
- Per-capture body overrides global setting which overrides env var.

Frontend:
- SettingsView ExtensionsTab: third ext-card for Modal & Dialog Closer.
- CaptureDialog: per-capture toggle in Advanced Options, state initialized
  from server default, included in submitCapture payload.
- api.js: submitCapture forwards modal_closer_enabled to capture body.

Behavior vs original modalcloser daemon:
- Functionally identical on non-strict-CSP pages (the large majority).
- Gap: <script> bridge is subject to page CSP; page.evaluate() is not.
  On strict-CSP pages framework teardown and dialog overrides are no-ops;
  CSS selector hiding and Escape dispatch (Pass 2) always work.
- Gap: alert/confirm/prompt overridden to instant no-op vs CDP timed
  dialog.accept() with 1250 ms delay; irrelevant for archival in practice.
This commit is contained in:
TheGeneralist 2026-07-14 13:47:47 +02:00 committed by GitHub
parent b0d02034ea
commit 278dc928df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 294 additions and 63 deletions

View file

@ -85,6 +85,8 @@ pub struct CaptureConfig {
pub cookie_ext_enabled: Option<bool>, pub cookie_ext_enabled: Option<bool>,
/// Apply Mozilla Readability to distil the page to article content before archiving. /// Apply Mozilla Readability to distil the page to article content before archiving.
pub reader_mode: bool, pub reader_mode: bool,
/// Override for modal-closer browser-script behavior during WebPage captures.
pub modal_closer_enabled: Option<bool>,
} }
/// Resolves which cookies apply to `url` by evaluating all rules in ordinal order. /// Resolves which cookies apply to `url` by evaluating all rules in ordinal order.
@ -1036,7 +1038,7 @@ pub fn perform_capture(
// Source: web page — archive as a self-contained HTML snapshot via single-file-cli // Source: web page — archive as a self-contained HTML snapshot via single-file-cli
if source == Source::WebPage { if source == Source::WebPage {
match downloader::singlefile::save(locator, store_path, &timestamp, &cookies, config.ublock_enabled, config.cookie_ext_enabled, config.reader_mode) { match downloader::singlefile::save(locator, store_path, &timestamp, &cookies, config.ublock_enabled, config.cookie_ext_enabled, config.reader_mode, config.modal_closer_enabled) {
Ok(result) => { Ok(result) => {
let file_extension = ".html".to_string(); let file_extension = ".html".to_string();
let temp_html = store_path let temp_html = store_path

View file

@ -142,6 +142,8 @@ pub struct InstanceSettings {
pub ublock_enabled: bool, pub ublock_enabled: bool,
/// Global default for cookie-consent banner dismissal via extension during WebPage captures. /// Global default for cookie-consent banner dismissal via extension during WebPage captures.
pub cookie_ext_enabled: bool, pub cookie_ext_enabled: bool,
/// Global default for modal-closer browser-script behavior during WebPage captures.
pub modal_closer_enabled: bool,
} }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -468,7 +470,8 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
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)),
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)), 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)) cookie_ext_enabled INTEGER NOT NULL DEFAULT 1 CHECK (cookie_ext_enabled IN (0, 1)),
modal_closer_enabled INTEGER NOT NULL DEFAULT 1 CHECK (modal_closer_enabled IN (0, 1))
); );
INSERT OR IGNORE INTO instance_settings INSERT OR IGNORE INTO instance_settings
@ -518,6 +521,11 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
"ALTER TABLE instance_settings ADD COLUMN cookie_ext_enabled INTEGER NOT NULL DEFAULT 1", "ALTER TABLE instance_settings ADD COLUMN cookie_ext_enabled INTEGER NOT NULL DEFAULT 1",
[], [],
); );
// Add modal_closer_enabled column to instance_settings if not present (idempotent migration)
let _ = conn.execute(
"ALTER TABLE instance_settings ADD COLUMN modal_closer_enabled INTEGER NOT NULL DEFAULT 1",
[],
);
Ok(()) Ok(())
} }
@ -745,7 +753,8 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
"SELECT public_index_enabled, public_entry_content_enabled, "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(ublock_enabled, 1),
COALESCE(cookie_ext_enabled, 1) COALESCE(cookie_ext_enabled, 1),
COALESCE(modal_closer_enabled, 1)
FROM instance_settings WHERE id = 1", FROM instance_settings WHERE id = 1",
[], [],
|row| { |row| {
@ -756,6 +765,7 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
default_entry_visibility: row.get::<_, i64>(3)? as u32, default_entry_visibility: row.get::<_, i64>(3)? as u32,
ublock_enabled: row.get::<_, i64>(4)? != 0, ublock_enabled: row.get::<_, i64>(4)? != 0,
cookie_ext_enabled: row.get::<_, i64>(5)? != 0, cookie_ext_enabled: row.get::<_, i64>(5)? != 0,
modal_closer_enabled: row.get::<_, i64>(6)? != 0,
}) })
}, },
) )
@ -770,7 +780,8 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
public_archive_submission_enabled = ?3, public_archive_submission_enabled = ?3,
default_entry_visibility = ?4, default_entry_visibility = ?4,
ublock_enabled = ?5, ublock_enabled = ?5,
cookie_ext_enabled = ?6 cookie_ext_enabled = ?6,
modal_closer_enabled = ?7
WHERE id = 1", WHERE id = 1",
params![ params![
settings.public_index_enabled as i64, settings.public_index_enabled as i64,
@ -779,6 +790,7 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
settings.default_entry_visibility as i64, settings.default_entry_visibility as i64,
settings.ublock_enabled as i64, settings.ublock_enabled as i64,
settings.cookie_ext_enabled as i64, settings.cookie_ext_enabled as i64,
settings.modal_closer_enabled as i64,
], ],
)?; )?;
Ok(()) Ok(())

View file

@ -94,6 +94,112 @@ const READER_MODE_SCRIPT: &str = concat!(
"# "#
); );
// The modal-closer browser scripts below (MODAL_CLOSER_DIALOG_OVERRIDES and
// MODAL_CLOSER_POLLING_SETUP) incorporate logic ported from the modalcloser
// plugin in the abx-plugins project:
// https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/modalcloser
//
// MIT License
// Copyright (c) 2024 Nick Sweeting
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
/// Injected at the top of the strip-scripts user-script when modal-closer is enabled.
/// Bridges to the main JS world via an inline `<script>` element. Best-effort: strict
/// `script-src` CSP can block inline script execution entirely. SingleFile injects
/// `--browser-script` files in `worldName: SINGLE_FILE_WORLD_NAME`, so direct
/// `window.alert = …` assignment would not reach page scripts; the `<script>` bridge
/// reaches the main world but is subject to the page's content security policy.
const MODAL_CLOSER_DIALOG_OVERRIDES: &str = r#"(function(){try{
var s=document.createElement('script');
s.textContent="window.alert=function(){};window.confirm=function(){return true;};window.prompt=function(m,d){return typeof d!=='undefined'?d:'';};window.print=function(){};window.onbeforeunload=null;try{Object.defineProperty(window,'onbeforeunload',{set:function(){},get:function(){return null;},configurable:true});}catch(e){}var _ael=window.addEventListener.bind(window);window.addEventListener=function(t,h,o){if(t==='beforeunload')return;_ael(t,h,o);};";
(document.head||document.documentElement).appendChild(s);s.remove();
}catch(e){}})();
"#;
/// Defines `_archivr_mc_run()` and schedules it every 500 ms (matching
/// `MODALCLOSER_POLL_INTERVAL` default) to catch overlays that appear after initial
/// page load. Each run does two passes:
///
/// **Pass 1 — main-world `<script>` bridge (best-effort):** calls Bootstrap,
/// jQuery, jQuery UI, and SweetAlert teardown APIs. Framework globals live in the
/// main world and cannot be reached from the isolated world; the bridge gets there
/// but is blocked by strict `script-src` CSP.
///
/// **Pass 2 — isolated-world direct DOM (always runs, CSP-immune):** Escape-key
/// dispatch, Angular Material backdrop click, full CSS selector hiding, scroll-lock
/// reset. DOM mutations and `KeyboardEvent` dispatch go through the shared document
/// without inline script execution, so strict CSP does not affect this pass.
///
/// The before-capture hook clears the interval and fires one final run.
const MODAL_CLOSER_POLLING_SETUP: &str = r#"var _archivr_mc_interval=null;
function _archivr_mc_run(){
// Pass 1: main-world bridge — framework teardown APIs (Bootstrap/jQuery/Swal).
// Best-effort: blocked by strict script-src CSP. Pass 2 below is the reliable fallback.
try{var s=document.createElement('script');s.textContent=`(function(){
if(window.bootstrap&&window.bootstrap.Modal){document.querySelectorAll('.modal.show').forEach(function(el){try{var m=bootstrap.Modal.getInstance(el);if(m)m.hide();}catch(e){}});}
if(window.jQuery&&jQuery.fn&&jQuery.fn.modal){try{jQuery('.modal.in,.modal.show').modal('hide');}catch(e){}}
if(window.jQuery&&jQuery.ui&&jQuery.ui.dialog){try{jQuery('.ui-dialog-content').dialog('close');}catch(e){}}
if(window.Swal&&Swal.close){try{Swal.close();}catch(e){}}
if(window.swal&&swal.close){try{swal.close();}catch(e){}}
})()`;(document.head||document.documentElement).appendChild(s);s.remove();}catch(e){}
// Pass 2: isolated-world direct DOM — CSP-immune (no inline script execution).
document.querySelectorAll('[data-radix-dialog-overlay],[data-state="open"][role="dialog"],[role="dialog"][aria-modal="true"]').forEach(function(el){try{el.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));}catch(e){}});
document.querySelectorAll('.cdk-overlay-backdrop').forEach(function(el){try{el.click();}catch(e){}});
var sels=['.cky-consent-container','.cky-popup-center','.cky-overlay','.cky-modal','#ckyPreferenceCenter',
'#onetrust-consent-sdk','#onetrust-banner-sdk','.onetrust-pc-dark-filter','#onetrust-pc-sdk',
'#CybotCookiebotDialog','#CybotCookiebotDialogBodyUnderlay','#CookiebotWidget',
'.qc-cmp-ui-container','#qc-cmp2-container','.qc-cmp2-summary-buttons','#truste-consent-track','.truste-banner','#truste-consent-content',
'.osano-cm-window','.osano-cm-dialog','.klaro .cookie-modal','.klaro .cookie-notice',
'#tarteaucitronRoot','#tarteaucitronAlertBig','.cmplz-cookiebanner','#cmplz-cookiebanner-container',
'#gdpr-cookie-consent-bar','.gdpr-cookie-consent-popup','#cookie-notice','.cookie-notice-container',
'.eupopup','#eu-cookie-law',
'#didomi-popup','#didomi-host','.didomi-popup-container','#usercentrics-root','.uc-banner',
'#axeptio_overlay','#axeptio_btn','#iubenda-cs-banner','.iubenda-cs-container',
'.termly-consent-banner','#termly-code-snippet-support','#BorlabsCookieBox','.BorlabsCookie',
'.cookiefirst-root','#cookiefirst-root','#cookiescript_injected','.cookiescript_injected_wrapper',
'#ccc','#ccc-overlay','#cookie-consent','.cookie-banner','.cookie-notice',
'#cookieConsent','.cookie-consent','.cookies-banner',
'.modal.show','.modal.in','.ui-dialog','.ui-widget-overlay',
'.swal2-container','.swal2-overlay','.sweet-alert','#sweetAlert',
'[class*="cookie"][class*="banner"]','[class*="cookie"][class*="notice"]',
'[class*="cookie"][class*="popup"]','[class*="cookie"][class*="modal"]',
'[class*="consent"][class*="banner"]','[class*="consent"][class*="popup"]',
'[class*="gdpr"]','[class*="privacy"][class*="banner"]',
'.modal-overlay','.modal-backdrop','.overlay-visible','.popup-overlay','.newsletter-popup',
'.age-gate','.subscribe-popup','.subscription-modal',
'[class*="modal"][class*="open"]:not(.modal-open)','[class*="modal"][class*="show"][class*="overlay"]','[class*="modal"][class*="visible"]',
'[class*="dialog"][class*="open"]','[class*="overlay"][class*="visible"]',
'.interstitial','.interstitial-wrapper','[class*="interstitial"]'];
sels.forEach(function(sel){try{document.querySelectorAll(sel).forEach(function(el){
var cs=window.getComputedStyle(el);
if(cs.display==='none'||cs.visibility==='hidden')return;
el.style.display='none';el.style.visibility='hidden';el.style.opacity='0';el.style.pointerEvents='none';
});}catch(e){}});
try{document.body.style.overflow='';document.body.style.position='';
document.body.classList.remove('modal-open','overflow-hidden','no-scroll','scroll-locked');
document.documentElement.style.overflow='';
document.documentElement.classList.remove('overflow-hidden','no-scroll');}catch(e){}
}
_archivr_mc_run();
_archivr_mc_interval=setInterval(_archivr_mc_run,500);
"#;
/// Result of archiving a web page with single-file. /// Result of archiving a web page with single-file.
#[derive(Debug)] #[derive(Debug)]
pub struct SaveResult { pub struct SaveResult {
@ -129,12 +235,14 @@ pub fn save(
ublock_enabled_override: Option<bool>, ublock_enabled_override: Option<bool>,
cookie_ext_enabled: Option<bool>, cookie_ext_enabled: Option<bool>,
reader_mode: bool, reader_mode: bool,
modal_closer_enabled: Option<bool>,
) -> Result<SaveResult> { ) -> Result<SaveResult> {
let single_file = let single_file =
env::var("ARCHIVR_SINGLE_FILE").unwrap_or_else(|_| "single-file".to_string()); 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 chrome = env::var("ARCHIVR_CHROME").unwrap_or_else(|_| "chromium".to_string());
let (ublock_ext, ublock_skipped) = resolve_ublock_config(ublock_enabled_override); 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 (cookie_ext, cookie_ext_skipped) = resolve_cookie_ext_config(cookie_ext_enabled);
let modal_closer = resolve_modal_closer_config(modal_closer_enabled);
let mut result = save_with( let mut result = save_with(
url, url,
store_path, store_path,
@ -145,6 +253,7 @@ pub fn save(
ublock_ext.as_deref(), ublock_ext.as_deref(),
cookie_ext.as_deref(), cookie_ext.as_deref(),
reader_mode, reader_mode,
modal_closer,
)?; )?;
result.ublock_skipped = ublock_skipped; result.ublock_skipped = ublock_skipped;
result.cookie_ext_skipped = cookie_ext_skipped; result.cookie_ext_skipped = cookie_ext_skipped;
@ -228,6 +337,19 @@ fn resolve_cookie_ext_config(enabled_override: Option<bool>) -> (Option<PathBuf>
} }
} }
/// Resolves modal-closer configuration from env vars, optionally overridden by the caller.
/// Returns `true` when enabled (the default), `false` when explicitly disabled via
/// `ARCHIVR_MODAL_CLOSER=false` or `0`.
///
/// Unlike uBlock and cookie-ext, modal-closer has no external resource dependency; the
/// behaviour is implemented entirely as an injected browser script.
fn resolve_modal_closer_config(enabled_override: Option<bool>) -> bool {
enabled_override.unwrap_or_else(|| {
let env_val = env::var("ARCHIVR_MODAL_CLOSER").unwrap_or_else(|_| "true".to_string());
!env_val.eq_ignore_ascii_case("false") && env_val != "0"
})
}
/// Inner implementation. Takes binary paths and an optional uBlock extension /// Inner implementation. Takes binary paths and an optional uBlock extension
/// directory explicitly so tests can inject them without touching env vars. /// directory explicitly so tests can inject them without touching env vars.
/// ///
@ -255,6 +377,7 @@ fn save_with(
ublock_ext: Option<&Path>, ublock_ext: Option<&Path>,
cookie_ext: Option<&Path>, cookie_ext: Option<&Path>,
reader_mode: bool, reader_mode: bool,
modal_closer: bool,
) -> Result<SaveResult> { ) -> Result<SaveResult> {
let temp_dir = store_path.join("temp").join(timestamp); let temp_dir = store_path.join("temp").join(timestamp);
std::fs::create_dir_all(&temp_dir).context("failed to create temp dir")?; std::fs::create_dir_all(&temp_dir).context("failed to create temp dir")?;
@ -265,12 +388,29 @@ fn save_with(
// serialises so JS-applied CSS is captured without broken module imports. // serialises so JS-applied CSS is captured without broken module imports.
// When cookie_ext is active, also resets overflow lockout and removes // When cookie_ext is active, also resets overflow lockout and removes
// consent overlays the extension may have missed. // consent overlays the extension may have missed.
// When modal_closer is active, dialog overrides are prepended (main-world
// bridge), a 500 ms polling loop starts after dispatchEvent (matches
// MODALCLOSER_POLL_INTERVAL default), and the before-capture hook clears
// the interval and fires a final main-world pass before serialisation.
let strip_scripts_path = temp_dir.join("sf-strip-scripts.js"); let strip_scripts_path = temp_dir.join("sf-strip-scripts.js");
let mut strip_scripts = String::from( let mut strip_scripts = String::new();
if modal_closer {
// Must come before dispatchEvent so overrides are installed before any page
// scripts fire. Bridges to main world — see MODAL_CLOSER_DIALOG_OVERRIDES.
strip_scripts.push_str(MODAL_CLOSER_DIALOG_OVERRIDES);
}
strip_scripts.push_str(
// Dispatch single-file-user-script-init so single-file installs // Dispatch single-file-user-script-init so single-file installs
// _singleFile_waitForUserScript, which gates the -request hooks. // _singleFile_waitForUserScript, which gates the -request hooks.
"dispatchEvent(new CustomEvent('single-file-user-script-init'));\ "dispatchEvent(new CustomEvent('single-file-user-script-init'));",
addEventListener('single-file-on-before-capture-request',()=>{\ );
if modal_closer {
// Start the 500 ms main-world polling loop after dispatchEvent so the
// interval is live before any page scripts run.
strip_scripts.push_str(MODAL_CLOSER_POLLING_SETUP);
}
strip_scripts.push_str(
"addEventListener('single-file-on-before-capture-request',()=>{\
document.querySelectorAll('script:not([type=\"application/ld+json\"])')\ document.querySelectorAll('script:not([type=\"application/ld+json\"])')\
.forEach(el=>el.remove());", .forEach(el=>el.remove());",
); );
@ -313,6 +453,17 @@ fn save_with(
});", });",
); );
} }
if modal_closer {
// Clear the polling interval and fire a final main-world pass right
// before SingleFile serialises the DOM.
strip_scripts.push_str(
"if(_archivr_mc_interval){\
clearInterval(_archivr_mc_interval);\
_archivr_mc_interval=null;\
}\
_archivr_mc_run();"
);
}
strip_scripts.push_str("});"); strip_scripts.push_str("});");
std::fs::write(&strip_scripts_path, &strip_scripts) std::fs::write(&strip_scripts_path, &strip_scripts)
.context("failed to write single-file user script")?; .context("failed to write single-file user script")?;
@ -634,9 +785,10 @@ mod tests {
"/nonexistent/single-file", "/nonexistent/single-file",
"chromium", "chromium",
&HashMap::new(), &HashMap::new(),
None, // no ublock ext None, // no ublock ext
None, // no cookie ext None, // no cookie ext
false, // reader mode off false, // reader mode off
false, // modal closer off
); );
let err = result.unwrap_err(); let err = result.unwrap_err();
let msg = format!("{err:#}"); let msg = format!("{err:#}");

View file

@ -685,6 +685,7 @@ struct CaptureBody {
/// Distil to article content via Readability before archiving. Absent = false. /// Distil to article content via Readability before archiving. Absent = false.
reader_mode: Option<bool>, reader_mode: Option<bool>,
cookie_ext_enabled: Option<bool>, cookie_ext_enabled: Option<bool>,
modal_closer_enabled: Option<bool>,
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
@ -765,26 +766,29 @@ async fn capture_handler(
let job_uid = database::create_capture_job(&conn, &archive_id)?; let job_uid = database::create_capture_job(&conn, &archive_id)?;
drop(conn); drop(conn);
// Load cookie rules and global uBlock / cookie-ext settings from the auth DB. // Load cookie rules and global uBlock / cookie-ext settings from the auth DB.
let (cookie_rules, global_ublock, global_cookie_ext) = { let (cookie_rules, global_ublock, global_cookie_ext, global_modal_closer) = {
match database::open_auth_db(&state.auth_db_path) { match database::open_auth_db(&state.auth_db_path) {
Ok(conn) => { Ok(conn) => {
let rules = database::list_cookie_rules(&conn).unwrap_or_default(); let rules = database::list_cookie_rules(&conn).unwrap_or_default();
let settings = database::get_instance_settings(&conn); let settings = database::get_instance_settings(&conn);
let ublock = settings.as_ref().map(|s| s.ublock_enabled).unwrap_or(true); 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); let cookie_ext = settings.as_ref().map(|s| s.cookie_ext_enabled).unwrap_or(true);
(rules, ublock, cookie_ext) let modal_closer = settings.map(|s| s.modal_closer_enabled).unwrap_or(true);
(rules, ublock, cookie_ext, modal_closer)
} }
Err(_) => (vec![], true, true), Err(_) => (vec![], true, true, true),
} }
}; };
// Per-capture body overrides global; if body doesn't specify, use the global setting. // 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. // 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_ublock = body.ublock_enabled.unwrap_or(global_ublock);
let effective_cookie_ext = body.cookie_ext_enabled.unwrap_or(global_cookie_ext); let effective_cookie_ext = body.cookie_ext_enabled.unwrap_or(global_cookie_ext);
let effective_modal_closer = body.modal_closer_enabled.unwrap_or(global_modal_closer);
let capture_config = capture::CaptureConfig { let capture_config = capture::CaptureConfig {
cookie_rules, cookie_rules,
ublock_enabled: Some(effective_ublock), ublock_enabled: Some(effective_ublock),
cookie_ext_enabled: Some(effective_cookie_ext), cookie_ext_enabled: Some(effective_cookie_ext),
modal_closer_enabled: Some(effective_modal_closer),
reader_mode: body.reader_mode.unwrap_or(false), reader_mode: body.reader_mode.unwrap_or(false),
}; };
@ -895,6 +899,7 @@ async fn rearchive_handler(
cookie_rules, cookie_rules,
ublock_enabled: None, ublock_enabled: None,
cookie_ext_enabled: None, cookie_ext_enabled: None,
modal_closer_enabled: None,
reader_mode: false, reader_mode: false,
}; };
@ -1188,6 +1193,7 @@ async fn update_instance_settings_handler(
if let Some(v) = body.default_entry_visibility { settings.default_entry_visibility = 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.ublock_enabled { settings.ublock_enabled = v; }
if let Some(v) = body.cookie_ext_enabled { settings.cookie_ext_enabled = v; } if let Some(v) = body.cookie_ext_enabled { settings.cookie_ext_enabled = v; }
if let Some(v) = body.modal_closer_enabled { settings.modal_closer_enabled = v; }
database::update_instance_settings(&conn, &settings)?; database::update_instance_settings(&conn, &settings)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@ -1519,6 +1525,7 @@ struct UpdateInstanceSettingsBody {
default_entry_visibility: Option<u32>, default_entry_visibility: Option<u32>,
ublock_enabled: Option<bool>, ublock_enabled: Option<bool>,
cookie_ext_enabled: Option<bool>, cookie_ext_enabled: Option<bool>,
modal_closer_enabled: Option<bool>,
} }
async fn admin_list_users( 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

View file

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

View file

@ -122,11 +122,12 @@ export async function fetchTags(archiveId) {
export async function submitCapture(archiveId, locator, quality = null, extensions = null) { export async function submitCapture(archiveId, locator, quality = null, extensions = null) {
const payload = { locator } const payload = { locator }
if (quality && quality !== 'best') payload.quality = quality if (quality && quality !== 'best') payload.quality = quality
// extensions: { ublock_enabled?: bool, reader_mode?: bool } — per-capture overrides // extensions: { ublock_enabled?: bool, reader_mode?: bool, cookie_ext_enabled?: bool, modal_closer_enabled?: bool }
if (extensions) { if (extensions) {
if (typeof extensions.ublock_enabled === 'boolean') payload.ublock_enabled = extensions.ublock_enabled 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.reader_mode === 'boolean') payload.reader_mode = extensions.reader_mode
if (typeof extensions.cookie_ext_enabled === 'boolean') payload.cookie_ext_enabled = extensions.cookie_ext_enabled if (typeof extensions.cookie_ext_enabled === 'boolean') payload.cookie_ext_enabled = extensions.cookie_ext_enabled
if (typeof extensions.modal_closer_enabled === 'boolean') payload.modal_closer_enabled = extensions.modal_closer_enabled
} }
const res = await fetch(`/api/archives/${archiveId}/captures`, { const res = await fetch(`/api/archives/${archiveId}/captures`, {
method: "POST", method: "POST",

View file

@ -122,6 +122,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const [globalSettings, setGlobalSettings] = useState(null) const [globalSettings, setGlobalSettings] = useState(null)
// Cookie consent: session-level only, initialized from server default // Cookie consent: session-level only, initialized from server default
const [cookieExtEnabled, setCookieExtEnabled] = useState(true) const [cookieExtEnabled, setCookieExtEnabled] = useState(true)
const [modalCloserEnabled, setModalCloserEnabled] = useState(true)
// Load global settings from server once on mount // Load global settings from server once on mount
useEffect(() => { useEffect(() => {
@ -129,6 +130,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
.then(s => { .then(s => {
setGlobalSettings(s) setGlobalSettings(s)
setCookieExtEnabled(s.cookie_ext_enabled ?? true) setCookieExtEnabled(s.cookie_ext_enabled ?? true)
setModalCloserEnabled(s.modal_closer_enabled ?? true)
}) })
.catch(() => setGlobalSettings({})) .catch(() => setGlobalSettings({}))
}, []) }, [])
@ -299,7 +301,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const qual = item.quality || 'best' const qual = item.quality || 'best'
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it)) setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
try { try {
const extensions = { ublock_enabled: ublockEnabled, reader_mode: readerMode, cookie_ext_enabled: cookieExtEnabled } const extensions = { ublock_enabled: ublockEnabled, reader_mode: readerMode, cookie_ext_enabled: cookieExtEnabled, modal_closer_enabled: modalCloserEnabled }
const job = await submitCapture(aid, loc, qual, extensions) const job = await submitCapture(aid, loc, qual, extensions)
setItems(prev => prev.map(it => setItems(prev => prev.map(it =>
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it
@ -499,6 +501,22 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
<span className="ext-toggle-knob" /> <span className="ext-toggle-knob" />
</button> </button>
</label> </label>
<label className="capture-ext-row" style={{ marginTop: 8 }}>
<span className="capture-ext-label">
<span className="capture-ext-name">Close modals and dialogs</span>
<span className="capture-ext-desc">Auto-dismiss cookie banners and overlays during this capture</span>
</span>
<button
type="button"
role="switch"
aria-checked={modalCloserEnabled}
className={`ext-toggle ext-toggle--sm${modalCloserEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => setModalCloserEnabled(v => !v)}
aria-label="Toggle modal closer for this capture"
>
<span className="ext-toggle-knob" />
</button>
</label>
</div> </div>
)} )}
</div> </div>

View file

@ -683,12 +683,27 @@ function ExtensionsTab() {
} }
} }
async function toggleModalCloser(val) {
setSaving(true)
setMsg(null)
try {
await updateInstanceSettings({ modal_closer_enabled: val })
setSettings(s => ({ ...s, modal_closer_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> if (loading) return <div className="muted">Loading\u2026</div>
const extAvailable = settings?.ublock_ext_available ?? false const extAvailable = settings?.ublock_ext_available ?? false
const extEnabled = settings?.ublock_enabled ?? true const extEnabled = settings?.ublock_enabled ?? true
const cookieExtAvailable = settings?.cookie_ext_available ?? false const cookieExtAvailable = settings?.cookie_ext_available ?? false
const cookieExtEnabled = settings?.cookie_ext_enabled ?? true const cookieExtEnabled = settings?.cookie_ext_enabled ?? true
const modalCloserEnabled = settings?.modal_closer_enabled ?? true
return ( return (
<div> <div>
@ -756,6 +771,30 @@ function ExtensionsTab() {
</button> </button>
</div> </div>
</div> </div>
<div className="ext-card">
<div className="ext-card-header">
<div className="ext-card-info">
<span className="ext-card-name">Modal &amp; Dialog Closer</span>
<span className="ext-card-desc">
Auto-dismiss cookie banners, consent overlays, and other modal dialogs
before a WebPage capture is taken. Implemented as an injected browser
script; no external extension required.
</span>
</div>
<button
type="button"
role="switch"
aria-checked={modalCloserEnabled}
className={`ext-toggle${modalCloserEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => toggleModalCloser(!modalCloserEnabled)}
disabled={saving}
aria-label="Toggle Modal and Dialog Closer"
>
<span className="ext-toggle-knob" />
</button>
</div>
</div>
</div> </div>
{msg && <div className={`form-msg form-msg--${msg.ok ? 'ok' : 'err'}`}>{msg.text}</div>} {msg && <div className={`form-msg form-msg--${msg.ok ? 'ok' : 'err'}`}>{msg.text}</div>}