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:
parent
b0d02034ea
commit
278dc928df
10 changed files with 294 additions and 63 deletions
|
|
@ -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, ×tamp, &cookies, config.ublock_enabled, config.cookie_ext_enabled, config.reader_mode) {
|
match downloader::singlefile::save(locator, store_path, ×tamp, &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
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
|
|
||||||
|
|
@ -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:#}");
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
47
crates/archivr-server/static/assets/index-BHmYoVqT.js
Normal file
47
crates/archivr-server/static/assets/index-BHmYoVqT.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 & 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>}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue