1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

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

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

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

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

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

Styles:
- .ext-toggle pill switch (44×24 and 36×20 small variant)
- .ext-card for Settings Extensions tab
- .capture-advanced + .capture-advanced-panel + .capture-chevron
- .capture-ext-row / .capture-ext-label / .capture-ext-name / .capture-ext-desc
- .form-hint utility class
This commit is contained in:
TheGeneralist 2026-07-07 17:33:35 +02:00
parent 8a90259a76
commit 1b3543ead8
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
13 changed files with 400 additions and 87 deletions

View file

@ -77,6 +77,11 @@ impl PlatformMetadata {
#[derive(Debug, Clone, Default)]
pub struct CaptureConfig {
pub cookie_rules: Vec<database::CookieRule>,
/// Override for uBlock Origin Lite during WebPage captures.
/// `None` = derive from `ARCHIVR_UBLOCK` env var (existing behaviour).
/// `Some(false)` = disabled by admin/user choice for this capture.
/// `Some(true)` = explicitly enabled (still needs `ARCHIVR_UBLOCK_EXT` to be valid).
pub ublock_enabled: Option<bool>,
}
/// Resolves which cookies apply to `url` by evaluating all rules in ordinal order.
@ -1010,7 +1015,7 @@ pub fn perform_capture(
// Source: web page — archive as a self-contained HTML snapshot via single-file-cli
if source == Source::WebPage {
match downloader::singlefile::save(locator, store_path, &timestamp, &cookies) {
match downloader::singlefile::save(locator, store_path, &timestamp, &cookies, config.ublock_enabled) {
Ok(result) => {
let file_extension = ".html".to_string();
let temp_html = store_path

View file

@ -137,6 +137,9 @@ pub struct InstanceSettings {
pub public_entry_content_enabled: bool,
pub open_registration_enabled: bool, // maps to public_archive_submission_enabled column
pub default_entry_visibility: u32,
/// Global default for ad-blocking via uBlock Origin Lite during WebPage captures.
/// Per-capture requests can override this.
pub ublock_enabled: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -460,13 +463,14 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)),
public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)),
public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)),
default_entry_visibility INTEGER NOT NULL DEFAULT 2
default_entry_visibility INTEGER NOT NULL DEFAULT 2,
ublock_enabled INTEGER NOT NULL DEFAULT 1 CHECK (ublock_enabled IN (0, 1))
);
INSERT OR IGNORE INTO instance_settings
(id, public_index_enabled, public_entry_content_enabled,
public_archive_submission_enabled, default_entry_visibility)
VALUES (1, 0, 0, 0, 2);
public_archive_submission_enabled, default_entry_visibility, ublock_enabled)
VALUES (1, 0, 0, 0, 2, 1);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
@ -500,6 +504,12 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
[],
);
// Add ublock_enabled column to instance_settings if not present (idempotent migration)
let _ = conn.execute(
"ALTER TABLE instance_settings ADD COLUMN ublock_enabled INTEGER NOT NULL DEFAULT 1",
[],
);
Ok(())
}
@ -724,7 +734,8 @@ pub fn list_user_tokens(conn: &Connection, user_id: i64) -> Result<Vec<ApiTokenR
pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
conn.query_row(
"SELECT public_index_enabled, public_entry_content_enabled,
public_archive_submission_enabled, default_entry_visibility
public_archive_submission_enabled, default_entry_visibility,
COALESCE(ublock_enabled, 1)
FROM instance_settings WHERE id = 1",
[],
|row| {
@ -733,6 +744,7 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
public_entry_content_enabled: row.get::<_, i64>(1)? != 0,
open_registration_enabled: row.get::<_, i64>(2)? != 0,
default_entry_visibility: row.get::<_, i64>(3)? as u32,
ublock_enabled: row.get::<_, i64>(4)? != 0,
})
},
)
@ -745,13 +757,15 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
SET public_index_enabled = ?1,
public_entry_content_enabled = ?2,
public_archive_submission_enabled = ?3,
default_entry_visibility = ?4
default_entry_visibility = ?4,
ublock_enabled = ?5
WHERE id = 1",
params![
settings.public_index_enabled as i64,
settings.public_entry_content_enabled as i64,
settings.open_registration_enabled as i64,
settings.default_entry_visibility as i64,
settings.ublock_enabled as i64,
],
)?;
Ok(())

View file

@ -43,11 +43,12 @@ pub fn save(
store_path: &Path,
timestamp: &str,
cookies: &HashMap<String, String>,
ublock_enabled_override: 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();
let (ublock_ext, ublock_skipped) = resolve_ublock_config(ublock_enabled_override);
let mut result = save_with(
url,
store_path,
@ -61,21 +62,26 @@ pub fn save(
Ok(result)
}
/// Reads `ARCHIVR_UBLOCK` and `ARCHIVR_UBLOCK_EXT` and returns:
/// Resolves uBlock configuration from env vars, optionally overridden by the caller.
///
/// Returns:
/// - `(Some(path), false)` — uBlock is enabled and the extension dir is valid.
/// - `(None, true)` — uBlock is enabled but the extension dir is missing/invalid
/// (warns to stderr; caller should surface this to the user).
/// - `(None, false)` — uBlock is explicitly disabled (`ARCHIVR_UBLOCK=false/0`).
fn resolve_ublock_config() -> (Option<PathBuf>, bool) {
let enabled = env::var("ARCHIVR_UBLOCK").unwrap_or_else(|_| "true".to_string());
if enabled.eq_ignore_ascii_case("false") || enabled == "0" {
/// (warns to stderr; the capture proceeds without ad-blocking).
/// - `(None, false)` — uBlock is disabled (`ARCHIVR_UBLOCK=false` or overridden).
fn resolve_ublock_config(enabled_override: Option<bool>) -> (Option<PathBuf>, bool) {
// The override (from instance settings or per-capture body) takes precedence over env.
let want_ublock = enabled_override.unwrap_or_else(|| {
let env_val = env::var("ARCHIVR_UBLOCK").unwrap_or_else(|_| "true".to_string());
!env_val.eq_ignore_ascii_case("false") && env_val != "0"
});
if !want_ublock {
return (None, false);
}
match env::var("ARCHIVR_UBLOCK_EXT").ok().filter(|s| !s.is_empty()) {
None => {
eprintln!(
"warn: uBlock: ARCHIVR_UBLOCK=true but ARCHIVR_UBLOCK_EXT is not set; \
"warn: uBlock: ARCHIVR_UBLOCK_EXT is not set; \
capturing without ad-blocking"
);
(None, true)

View file

@ -653,6 +653,8 @@ struct CaptureBody {
/// Optional quality cap for yt-dlp sources: `"best"` or any `"NNNp"` string
/// (e.g. `"1080p"`, `"720p"`, `"2160p"`). Absent or `"best"` → highest available.
quality: Option<String>,
/// Per-capture uBlock override. Absent → use global instance setting.
ublock_enabled: Option<bool>,
}
#[derive(Debug, serde::Deserialize)]
@ -733,14 +735,26 @@ async fn capture_handler(
let job_uid = database::create_capture_job(&conn, &archive_id)?;
drop(conn);
// Load cookie rules from the auth DB to pass into the capture background task.
let cookie_rules = {
// Load cookie rules and global uBlock setting from the auth DB.
let (cookie_rules, global_ublock) = {
match database::open_auth_db(&state.auth_db_path) {
Ok(conn) => database::list_cookie_rules(&conn).unwrap_or_default(),
Err(_) => vec![],
Ok(conn) => {
let rules = database::list_cookie_rules(&conn).unwrap_or_default();
let ublock = database::get_instance_settings(&conn)
.map(|s| s.ublock_enabled)
.unwrap_or(true);
(rules, ublock)
}
Err(_) => (vec![], true),
}
};
let capture_config = capture::CaptureConfig { cookie_rules };
// Per-capture body overrides global; if body doesn't specify, use the global setting.
// The resolved bool is then passed as Some(_) to singlefile, overriding the env var.
let effective_ublock = body.ublock_enabled.unwrap_or(global_ublock);
let capture_config = capture::CaptureConfig {
cookie_rules,
ublock_enabled: Some(effective_ublock),
};
// Spawn background capture.
let locator = body.locator.trim().to_string();
@ -1020,10 +1034,20 @@ async fn patch_me(
async fn get_instance_settings_handler(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<Json<database::InstanceSettings>, ApiError> {
) -> Result<Json<serde_json::Value>, ApiError> {
auth_user.require_role(ROLE_ADMIN)?;
let conn = database::open_auth_db(&state.auth_db_path)?;
Ok(Json(database::get_instance_settings(&conn)?))
let settings = database::get_instance_settings(&conn)?;
let ublock_ext_available = std::env::var("ARCHIVR_UBLOCK_EXT")
.ok()
.filter(|s| !s.is_empty())
.map(|p| std::path::Path::new(&p).is_dir())
.unwrap_or(false);
let mut val = serde_json::to_value(&settings).unwrap_or_default();
if let Some(obj) = val.as_object_mut() {
obj.insert("ublock_ext_available".into(), serde_json::Value::Bool(ublock_ext_available));
}
Ok(Json(val))
}
async fn update_instance_settings_handler(
@ -1038,6 +1062,7 @@ async fn update_instance_settings_handler(
if let Some(v) = body.public_entry_content_enabled { settings.public_entry_content_enabled = v; }
if let Some(v) = body.open_registration_enabled { settings.open_registration_enabled = v; }
if let Some(v) = body.default_entry_visibility { settings.default_entry_visibility = v; }
if let Some(v) = body.ublock_enabled { settings.ublock_enabled = v; }
database::update_instance_settings(&conn, &settings)?;
Ok(StatusCode::NO_CONTENT)
}
@ -1367,6 +1392,7 @@ struct UpdateInstanceSettingsBody {
public_entry_content_enabled: Option<bool>,
open_registration_enabled: Option<bool>,
default_entry_visibility: Option<u32>,
ublock_enabled: Option<bool>,
}
async fn admin_list_users(

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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