1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00
archivr/crates/archivr-cli/src/main.rs
TheGeneralist dae61e585d
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)
2026-07-06 19:01:34 +02:00

102 lines
2.9 KiB
Rust

use anyhow::{Context, Result};
use archivr_core::{archive, capture::CaptureConfig};
use clap::{Parser, Subcommand};
use std::{
env,
path::Path,
process,
};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Archive the specified file or directory
Archive {
/// URL or Path to archive
path: String,
},
Init {
/// Path to initialize the archive in
#[arg(default_value = ".")]
path: String,
/// Store path - path to store the archived files in.
/// Structure will be:
/// store_path/
/// temp/
/// ...
/// raw/
/// ...
/// raw_tweets/
/// ...
/// structured/
/// ...
#[arg(default_value = "./.archivr/store")]
store_path: String,
/// Name of the archive
#[arg(short, long)]
name: String,
/// Wipe existing .archivr repository data
#[arg(long = "force-with-info-removal")]
force_with_info_removal: bool,
},
}
fn main() -> Result<()> {
let args = Args::parse();
match args.command {
Command::Archive { ref path } => {
let archive_path = match archive::find_archive_path()? {
Some(path) => path,
None => {
eprintln!("Not in an archive. Use 'archivr init' to create one.");
process::exit(1);
}
};
let archive_paths = archive::read_archive_paths(&archive_path)?;
let result = archivr_core::capture::perform_capture(&archive_paths, path, None, None, &CaptureConfig::default())?;
println!("Archived: run {}", result.run_uid);
Ok(())
}
Command::Init {
path: ref archive_path_string,
store_path: ref store_path_string,
name: ref archive_name,
force_with_info_removal,
} => {
let archive_parent = Path::new(&archive_path_string);
let store_path = if Path::new(&store_path_string).is_relative() {
env::current_dir()
.context("failed to read current working directory")?
.join(store_path_string)
} else {
Path::new(store_path_string).to_path_buf()
};
let paths = archive::initialize_archive(
archive_parent,
&store_path,
archive_name,
force_with_info_removal,
)?;
println!(
"Initialized empty archive in {}",
paths.archive_path.display()
);
Ok(())
} // _ => eprintln!("Unknown command: {:?}", args.command),
}
}