From 3d0b71c30b9e3d29040952b5f77fe4b27ef6566d Mon Sep 17 00:00:00 2001 From: enki Date: Tue, 19 May 2026 16:03:32 -0700 Subject: [PATCH] add Settings UI page with live config overrides stored in SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 008: settings table (key/value with updated_at) - DB: set_setting, get_all_settings, delete_setting, settings_count, apply_settings_overrides — merges DB settings over YAML config at startup - Settings page at /ui/settings with sections for: Network/Proxy (mode picker: Direct/Tor/I2P/SOCKS5 + address field) Relays (add/remove with live list) Curation (operator pubkey, follow depth, WoT only, auto-block threshold) Ingest (backfill days) TMDB (enabled toggle + API key) - Yellow restart-required banner when DB has saved settings - Settings link added to nav --- src/db/migrations/008_settings.sql | 5 + src/db/queries.rs | 70 ++++++ src/main.rs | 3 +- src/ui/mod.rs | 19 ++ src/ui/settings.rs | 353 +++++++++++++++++++++++++++++ 5 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 src/db/migrations/008_settings.sql create mode 100644 src/ui/settings.rs diff --git a/src/db/migrations/008_settings.sql b/src/db/migrations/008_settings.sql new file mode 100644 index 0000000..81ca185 --- /dev/null +++ b/src/db/migrations/008_settings.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); diff --git a/src/db/queries.rs b/src/db/queries.rs index 6b74775..679e86f 100644 --- a/src/db/queries.rs +++ b/src/db/queries.rs @@ -1093,6 +1093,76 @@ fn null_str(s: &str) -> Option<&str> { if s.is_empty() { None } else { Some(s) } } +// ─── Settings (UI-editable config overrides) ───────────────────────────────── + +pub async fn get_all_settings(pool: &SqlitePool) -> std::collections::HashMap { + let rows: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM settings") + .fetch_all(pool) + .await + .unwrap_or_default(); + rows.into_iter().collect() +} + +pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, unixepoch()) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", + ) + .bind(key) + .bind(value) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn delete_setting(pool: &SqlitePool, key: &str) -> anyhow::Result<()> { + sqlx::query("DELETE FROM settings WHERE key = ?") + .bind(key) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn settings_count(pool: &SqlitePool) -> i64 { + sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM settings") + .fetch_one(pool) + .await + .map(|r| r.0) + .unwrap_or(0) +} + +/// Apply any settings stored in the DB on top of the already-loaded Config. +/// Called once at startup after migrations run. +pub async fn apply_settings_overrides(pool: &SqlitePool, cfg: &mut crate::config::Config) -> anyhow::Result<()> { + let settings = get_all_settings(pool).await; + for (key, value) in &settings { + match key.as_str() { + "proxy.mode" => cfg.network.proxy.mode = value.clone(), + "proxy.url" => cfg.network.proxy.url = value.clone(), + "relays" => { + if let Ok(v) = serde_json::from_str::>(value) { + cfg.relays = v; + } + } + "backfill_days" => { + if let Ok(n) = value.parse::() { cfg.backfill_days = n; } + } + "curation.wot_only" => cfg.curation.wot_only = value == "true", + "curation.follow_depth" => { + if let Ok(n) = value.parse::() { cfg.curation.follow_depth = n; } + } + "curation.operator_pubkey" => cfg.curation.operator_pubkey = value.clone(), + "curation.auto_block_threshold" => { + if let Ok(n) = value.parse::() { cfg.curation.auto_block_threshold = n; } + } + "tmdb.enabled" => cfg.tmdb.enabled = value == "true", + "tmdb.api_key" => cfg.tmdb.api_key = value.clone(), + _ => {} + } + } + Ok(()) +} + fn build_cat_where(cats: &[i32]) -> String { cats.iter() .map(|c| { diff --git a/src/main.rs b/src/main.rs index 2ecb22b..6f114cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,7 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - let cfg = config::load(&cli.config).context("load config")?; + let mut cfg = config::load(&cli.config).context("load config")?; let log_level: tracing::Level = match cfg.logging.level.as_str() { "debug" => tracing::Level::DEBUG, @@ -54,6 +54,7 @@ async fn main() -> anyhow::Result<()> { let pool = db::open(&cfg.database.path).await?; info!("database ready"); + db::apply_settings_overrides(&pool, &mut cfg).await?; db::sync_relays(&pool, &cfg.relays).await?; let relay_count = Arc::new(AtomicI32::new(0)); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f0fedec..34c5d10 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -12,6 +12,7 @@ mod dashboard; mod indexed; mod publishers; mod published; +mod settings; pub fn router() -> Router { Router::new() @@ -23,6 +24,13 @@ pub fn router() -> Router { .route("/ui/publishers/{pubkey}/unblock", post(publishers::unblock_handler)) .route("/ui/publishers/{pubkey}/mute", post(publishers::mute_handler)) .route("/ui/published", get(published::handler)) + .route("/ui/settings", get(settings::handler)) + .route("/ui/settings/network", post(settings::save_network)) + .route("/ui/settings/relays/add", post(settings::add_relay)) + .route("/ui/settings/relays/remove", post(settings::remove_relay)) + .route("/ui/settings/curation", post(settings::save_curation)) + .route("/ui/settings/ingest", post(settings::save_ingest)) + .route("/ui/settings/tmdb", post(settings::save_tmdb)) .route("/torrent/{info_hash}", get(torrent_blob_handler)) } @@ -57,6 +65,7 @@ pub fn page(active: &str, title: &str, body: &str) -> axum::response::Html, + pub err: Option, +} + +pub async fn handler( + State(state): State, + Query(params): Query, +) -> Html { + match render(&state, ¶ms).await { + Ok(h) => h, + Err(e) => err_page(&e), + } +} + +async fn render(state: &AppState, params: &PageParams) -> anyhow::Result> { + let cfg = &state.cfg; + let has_overrides = db::settings_count(&state.pool).await > 0; + + let restart_banner = if has_overrides { + r#""# + } else { + "" + }; + + let flash = if let Some(msg) = ¶ms.msg { + format!(r#"
{}
"#, esc(msg)) + } else if let Some(err) = ¶ms.err { + format!(r#"
{}
"#, esc(err)) + } else { + String::new() + }; + + // ── Network / Proxy ─────────────────────────────────────────────────────── + let proxy = &cfg.network.proxy; + let proxy_modes = [ + ("direct", "Direct — no proxy"), + ("tor", "Tor — SOCKS5 to 127.0.0.1:9050"), + ("i2p", "I2P — SOCKS5 to 127.0.0.1:4446"), + ("socks5", "SOCKS5 — custom address"), + ]; + let proxy_radios: String = proxy_modes.iter().map(|(val, label)| { + let checked = if *val == proxy.mode { " checked" } else { "" }; + format!( + r#""# + ) + }).collect::>().join("\n"); + + let socks5_hidden = if proxy.mode == "socks5" { "" } else { " style=\"display:none\"" }; + let network_section = format!( + r#"
+
Network / Proxy restart required
+
+
{proxy_radios}
+
+ + +
+
+ +
+
+
"#, + url = esc(&proxy.url), + ); + + // ── Relays ──────────────────────────────────────────────────────────────── + let relay_rows: String = cfg.relays.iter().map(|r| { + format!( + r#"
+ {url} +
+ + +
+
"#, + url = esc(r), + url_raw = esc(r), + ) + }).collect(); + + let relays_section = format!( + r#"
+
Relays restart required
+
{relay_rows}
+
+
+ + +
+
+
"# + ); + + // ── Curation ────────────────────────────────────────────────────────────── + let c = &cfg.curation; + let wot_checked = if c.wot_only { " checked" } else { "" }; + let depth1 = if c.follow_depth == 1 { " selected" } else { "" }; + let depth2 = if c.follow_depth == 2 { " selected" } else { "" }; + let curation_section = format!( + r#"
+
Curation & Trust
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
"#, + op_pk = esc(&c.operator_pubkey), + abt = c.auto_block_threshold, + ); + + // ── Ingest ──────────────────────────────────────────────────────────────── + let ingest_section = format!( + r#"
+
Ingest restart required
+
+
+ + +
+
+ +
+
+
"#, + bd = cfg.backfill_days, + ); + + // ── TMDB ────────────────────────────────────────────────────────────────── + let tmdb_checked = if cfg.tmdb.enabled { " checked" } else { "" }; + let tmdb_section = format!( + r#"
+
TMDB enrichment
+
+
+ + +
+
+ + +
+
+ +
+
+
"#, + api_key = esc(&cfg.tmdb.api_key), + ); + + let js = r#""#; + + let body = format!( + r#"

Settings

+{flash} +{restart_banner} +
+
Network
+ {network_section} +
+
+
Relays
+ {relays_section} +
+
+
Curation & Trust
+ {curation_section} +
+
+
Ingest
+ {ingest_section} +
+
+
TMDB
+ {tmdb_section} +
+{js}"# + ); + + Ok(page("settings", "Settings", &body)) +} + +// ─── POST handlers ──────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct NetworkForm { + pub mode: String, + #[serde(default)] + pub url: String, +} + +pub async fn save_network( + State(state): State, + Form(form): Form, +) -> Redirect { + let mode = match form.mode.as_str() { + "tor" | "i2p" | "socks5" | "direct" => form.mode.clone(), + _ => return Redirect::to("/ui/settings?err=Invalid+proxy+mode"), + }; + if let Err(e) = db::set_setting(&state.pool, "proxy.mode", &mode).await { + return Redirect::to(&format!("/ui/settings?err={e}")); + } + if let Err(e) = db::set_setting(&state.pool, "proxy.url", &form.url).await { + return Redirect::to(&format!("/ui/settings?err={e}")); + } + Redirect::to("/ui/settings?msg=Network+settings+saved") +} + +#[derive(Deserialize)] +pub struct RelayForm { + pub url: String, +} + +pub async fn add_relay( + State(state): State, + Form(form): Form, +) -> Redirect { + let url = form.url.trim().to_string(); + if !url.starts_with("wss://") && !url.starts_with("ws://") { + return Redirect::to("/ui/settings?err=Relay+URL+must+start+with+wss://+or+ws://"); + } + let mut relays = state.cfg.relays.clone(); + if !relays.contains(&url) { + relays.push(url); + } + let json = serde_json::to_string(&relays).unwrap_or_default(); + let _ = db::set_setting(&state.pool, "relays", &json).await; + Redirect::to("/ui/settings?msg=Relay+added") +} + +pub async fn remove_relay( + State(state): State, + Form(form): Form, +) -> Redirect { + let mut relays = state.cfg.relays.clone(); + relays.retain(|r| r != &form.url); + let json = serde_json::to_string(&relays).unwrap_or_default(); + let _ = db::set_setting(&state.pool, "relays", &json).await; + Redirect::to("/ui/settings?msg=Relay+removed") +} + +#[derive(Deserialize)] +pub struct CurationForm { + #[serde(default)] + pub operator_pubkey: String, + pub follow_depth: u32, + #[serde(default)] + pub wot_only: String, + pub auto_block_threshold: i32, +} + +pub async fn save_curation( + State(state): State, + Form(form): Form, +) -> Redirect { + let wot_only = form.wot_only == "true"; + let _ = db::set_setting(&state.pool, "curation.operator_pubkey", &form.operator_pubkey).await; + let _ = db::set_setting(&state.pool, "curation.follow_depth", &form.follow_depth.to_string()).await; + let _ = db::set_setting(&state.pool, "curation.wot_only", if wot_only { "true" } else { "false" }).await; + let _ = db::set_setting(&state.pool, "curation.auto_block_threshold", &form.auto_block_threshold.to_string()).await; + Redirect::to("/ui/settings?msg=Curation+settings+saved") +} + +#[derive(Deserialize)] +pub struct IngestForm { + pub backfill_days: i64, +} + +pub async fn save_ingest( + State(state): State, + Form(form): Form, +) -> Redirect { + let _ = db::set_setting(&state.pool, "backfill_days", &form.backfill_days.to_string()).await; + Redirect::to("/ui/settings?msg=Ingest+settings+saved") +} + +#[derive(Deserialize)] +pub struct TmdbForm { + #[serde(default)] + pub enabled: String, + #[serde(default)] + pub api_key: String, +} + +pub async fn save_tmdb( + State(state): State, + Form(form): Form, +) -> Redirect { + let enabled = form.enabled == "true"; + let _ = db::set_setting(&state.pool, "tmdb.enabled", if enabled { "true" } else { "false" }).await; + let _ = db::set_setting(&state.pool, "tmdb.api_key", &form.api_key).await; + Redirect::to("/ui/settings?msg=TMDB+settings+saved") +}