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

feat: add user-configurable cookie rules (#20)

Adds per-instance cookie rules (admin-only) that are injected into
every network touchpoint during capture.

Storage:
- New cookie_rules table in the auth DB (idempotent migration)
- Rules have pattern_kind (global/wildcard/regex), optional url_pattern,
  and cookies_json (validated as string-only JSON object)

Matching (resolve_cookies_for_url):
- Global rules always apply
- Wildcard: * and ? with full metacharacter escaping; matched against
  hostname via reqwest::Url when pattern has no ://, full URL otherwise
- Regex: matched against the full URL
- Later rules in ordinal order override earlier ones per cookie name

All six network touchpoints receive resolved cookies:
- http::probe_url_kind and http::download: Cookie request header
- singlefile::save: Netscape cookie file -> --browser-cookies-file
- ytdlp::fetch_metadata and ytdlp::download: Netscape cookie file -> --cookies
- tweets::archive: semicolon credentials file -> --credentials-file
  (only when both ct0 and auth_token are present; otherwise falls back
  to ARCHIVR_TWITTER_CREDENTIALS_FILE)

Security:
- Cookie files written 0o600 (owner read/write only)
- Exact parsed hostname used as cookie domain (no PSL stripping)
- Files deleted unconditionally before any error propagates,
  including spawn failures (hold-result-then-delete pattern)
- No cookie values in process args (no --add-header exposure)

API: GET/POST /api/admin/cookie-rules, PATCH/DELETE /api/admin/cookie-rules/:uid
Frontend: Cookies tab in Settings (admin only) with rule list,
  inline edit, pattern-type selector, client-side JSON validation
CLI: CaptureConfig::default() - no behaviour change

254 tests passing (4 new cookie-rule handler tests)
This commit is contained in:
TheGeneralist 2026-07-06 19:01:34 +02:00 committed by GitHub
parent 21b11c211f
commit dae61e585d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1013 additions and 103 deletions

View file

@ -138,6 +138,16 @@ pub struct InstanceSettings {
pub default_entry_visibility: u32,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CookieRule {
pub rule_uid: String,
pub url_pattern: Option<String>,
pub pattern_kind: String,
pub cookies_json: String,
pub ordinal: i64,
pub created_at: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CollectionRecord {
pub id: i64,
@ -464,6 +474,16 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
created_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS cookie_rules (
id INTEGER PRIMARY KEY,
rule_uid TEXT NOT NULL UNIQUE,
url_pattern TEXT,
pattern_kind TEXT NOT NULL DEFAULT 'global',
cookies_json TEXT NOT NULL DEFAULT '{}',
ordinal INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
"#,
)?;
// Add display_name column to users if not present (idempotent migration)
@ -731,6 +751,85 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
Ok(())
}
pub fn list_cookie_rules(conn: &Connection) -> Result<Vec<CookieRule>> {
let mut stmt = conn.prepare(
"SELECT rule_uid, url_pattern, pattern_kind, cookies_json, ordinal, created_at
FROM cookie_rules ORDER BY ordinal ASC, created_at ASC",
)?;
let records = stmt
.query_map([], |row| {
Ok(CookieRule {
rule_uid: row.get(0)?,
url_pattern: row.get(1)?,
pattern_kind: row.get(2)?,
cookies_json: row.get(3)?,
ordinal: row.get(4)?,
created_at: row.get(5)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(records)
}
pub fn create_cookie_rule(
conn: &Connection,
url_pattern: Option<&str>,
pattern_kind: &str,
cookies_json: &str,
) -> Result<CookieRule> {
let rule_uid = format!("cr_{}", Uuid::new_v4().simple());
let now = Utc::now().to_rfc3339();
let ordinal: i64 = conn.query_row(
"SELECT COALESCE(MAX(ordinal), -1) + 1 FROM cookie_rules",
[],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO cookie_rules (rule_uid, url_pattern, pattern_kind, cookies_json, ordinal, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![rule_uid, url_pattern, pattern_kind, cookies_json, ordinal, now],
)?;
Ok(CookieRule {
rule_uid,
url_pattern: url_pattern.map(str::to_string),
pattern_kind: pattern_kind.to_string(),
cookies_json: cookies_json.to_string(),
ordinal,
created_at: now,
})
}
pub fn update_cookie_rule(
conn: &Connection,
rule_uid: &str,
url_pattern: Option<&str>,
pattern_kind: &str,
cookies_json: &str,
ordinal: i64,
) -> Result<()> {
let rows = conn.execute(
"UPDATE cookie_rules
SET url_pattern = ?1, pattern_kind = ?2, cookies_json = ?3, ordinal = ?4
WHERE rule_uid = ?5",
params![url_pattern, pattern_kind, cookies_json, ordinal, rule_uid],
)?;
if rows == 0 {
anyhow::bail!("cookie rule not found: {rule_uid}");
}
Ok(())
}
pub fn delete_cookie_rule(conn: &Connection, rule_uid: &str) -> Result<()> {
let rows = conn.execute(
"DELETE FROM cookie_rules WHERE rule_uid = ?1",
[rule_uid],
)?;
if rows == 0 {
anyhow::bail!("cookie rule not found: {rule_uid}");
}
Ok(())
}
pub fn get_user_password_hash(conn: &Connection, user_id: i64) -> Result<Option<String>> {
conn.query_row(
"SELECT password_hash FROM users WHERE id = ?1",