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>,
/// Apply Mozilla Readability to distil the page to article content before archiving.
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.
@ -1036,7 +1038,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.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) => {
let file_extension = ".html".to_string();
let temp_html = store_path

View file

@ -142,6 +142,8 @@ pub struct InstanceSettings {
pub ublock_enabled: bool,
/// Global default for cookie-consent banner dismissal via extension during WebPage captures.
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)]
@ -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)),
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))
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
@ -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",
[],
);
// 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(())
}
@ -745,7 +753,8 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
"SELECT public_index_enabled, public_entry_content_enabled,
public_archive_submission_enabled, default_entry_visibility,
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",
[],
|row| {
@ -756,6 +765,7 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
default_entry_visibility: row.get::<_, i64>(3)? as u32,
ublock_enabled: row.get::<_, i64>(4)? != 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,
default_entry_visibility = ?4,
ublock_enabled = ?5,
cookie_ext_enabled = ?6
cookie_ext_enabled = ?6,
modal_closer_enabled = ?7
WHERE id = 1",
params![
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.ublock_enabled as i64,
settings.cookie_ext_enabled as i64,
settings.modal_closer_enabled as i64,
],
)?;
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.
#[derive(Debug)]
pub struct SaveResult {
@ -129,12 +235,14 @@ pub fn save(
ublock_enabled_override: Option<bool>,
cookie_ext_enabled: Option<bool>,
reader_mode: bool,
modal_closer_enabled: Option<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 modal_closer = resolve_modal_closer_config(modal_closer_enabled);
let mut result = save_with(
url,
store_path,
@ -145,6 +253,7 @@ pub fn save(
ublock_ext.as_deref(),
cookie_ext.as_deref(),
reader_mode,
modal_closer,
)?;
result.ublock_skipped = ublock_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
/// directory explicitly so tests can inject them without touching env vars.
///
@ -255,6 +377,7 @@ fn save_with(
ublock_ext: Option<&Path>,
cookie_ext: Option<&Path>,
reader_mode: bool,
modal_closer: 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")?;
@ -265,12 +388,29 @@ fn save_with(
// 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.
// 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 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
// _singleFile_waitForUserScript, which gates the -request hooks.
"dispatchEvent(new CustomEvent('single-file-user-script-init'));\
addEventListener('single-file-on-before-capture-request',()=>{\
"dispatchEvent(new CustomEvent('single-file-user-script-init'));",
);
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\"])')\
.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("});");
std::fs::write(&strip_scripts_path, &strip_scripts)
.context("failed to write single-file user script")?;
@ -634,9 +785,10 @@ mod tests {
"/nonexistent/single-file",
"chromium",
&HashMap::new(),
None, // no ublock ext
None, // no cookie ext
None, // no ublock ext
None, // no cookie ext
false, // reader mode off
false, // modal closer off
);
let err = result.unwrap_err();
let msg = format!("{err:#}");