add Settings UI page with live config overrides stored in SQLite
- 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
This commit is contained in:
@@ -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())
|
||||
);
|
||||
@@ -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<String, String> {
|
||||
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::<Vec<String>>(value) {
|
||||
cfg.relays = v;
|
||||
}
|
||||
}
|
||||
"backfill_days" => {
|
||||
if let Ok(n) = value.parse::<i64>() { cfg.backfill_days = n; }
|
||||
}
|
||||
"curation.wot_only" => cfg.curation.wot_only = value == "true",
|
||||
"curation.follow_depth" => {
|
||||
if let Ok(n) = value.parse::<u32>() { cfg.curation.follow_depth = n; }
|
||||
}
|
||||
"curation.operator_pubkey" => cfg.curation.operator_pubkey = value.clone(),
|
||||
"curation.auto_block_threshold" => {
|
||||
if let Ok(n) = value.parse::<i32>() { 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| {
|
||||
|
||||
+2
-1
@@ -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));
|
||||
|
||||
@@ -12,6 +12,7 @@ mod dashboard;
|
||||
mod indexed;
|
||||
mod publishers;
|
||||
mod published;
|
||||
mod settings;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
@@ -23,6 +24,13 @@ pub fn router() -> Router<AppState> {
|
||||
.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<Strin
|
||||
("indexed", "/ui/indexed", "Indexed"),
|
||||
("publishers", "/ui/publishers", "Publishers"),
|
||||
("published", "/ui/published", "Published"),
|
||||
("settings", "/ui/settings", "Settings"),
|
||||
]
|
||||
.iter()
|
||||
.map(|(id, href, label)| {
|
||||
@@ -290,4 +299,14 @@ form.ifrm { display: inline; }
|
||||
background: var(--bg3); border-radius: 5px; padding: .4rem .75rem; font-size: 13px; }
|
||||
.relay-url { font-family: monospace; font-size: 12px; color: var(--text); }
|
||||
.relay-ts { font-size: 11px; color: var(--muted); }
|
||||
.banner-restart { background: #2a1a02; border: 1px solid #5a3600; color: var(--warn);
|
||||
border-radius: 8px; padding: .7rem 1rem; margin-bottom: 1.25rem; font-size: 13px; }
|
||||
.radio-group { display: flex; flex-direction: column; gap: .45rem; }
|
||||
.radio-label { display: flex; align-items: center; gap: .5rem; font-size: 13px; cursor: pointer; }
|
||||
.radio-label input[type=radio] { accent-color: var(--accent); width: 15px; height: 15px; }
|
||||
.toggle-label { display: flex; align-items: center; gap: .5rem; font-size: 13px; cursor: pointer; }
|
||||
.toggle-label input[type=checkbox] { accent-color: var(--accent); width: 15px; height: 15px; }
|
||||
.setting-row { display: flex; flex-direction: column; gap: .25rem; margin-bottom: .9rem; }
|
||||
.setting-label { font-size: 12px; font-weight: 600; color: var(--text); }
|
||||
.setting-hint { display: block; font-size: 11px; font-weight: 400; color: var(--muted); margin-top: .1rem; }
|
||||
"#;
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
response::{Html, Redirect},
|
||||
Form,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use crate::{db, AppState};
|
||||
use super::{page, err_page, esc};
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct PageParams {
|
||||
pub msg: Option<String>,
|
||||
pub err: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<PageParams>,
|
||||
) -> Html<String> {
|
||||
match render(&state, ¶ms).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => err_page(&e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn render(state: &AppState, params: &PageParams) -> anyhow::Result<Html<String>> {
|
||||
let cfg = &state.cfg;
|
||||
let has_overrides = db::settings_count(&state.pool).await > 0;
|
||||
|
||||
let restart_banner = if has_overrides {
|
||||
r#"<div class="banner-restart">
|
||||
<strong>Restart required</strong> — settings have been saved to the database.
|
||||
Restart kindexr to apply them.
|
||||
</div>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let flash = if let Some(msg) = ¶ms.msg {
|
||||
format!(r#"<div class="msg-ok">{}</div>"#, esc(msg))
|
||||
} else if let Some(err) = ¶ms.err {
|
||||
format!(r#"<div class="msg-err">{}</div>"#, 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#"<label class="radio-label">
|
||||
<input type="radio" name="mode" value="{val}"{checked} onchange="toggleSocks5(this.value)">
|
||||
{label}
|
||||
</label>"#
|
||||
)
|
||||
}).collect::<Vec<_>>().join("\n");
|
||||
|
||||
let socks5_hidden = if proxy.mode == "socks5" { "" } else { " style=\"display:none\"" };
|
||||
let network_section = format!(
|
||||
r#"<div class="card">
|
||||
<div class="card-title">Network / Proxy <span class="badge badge-warn" style="font-size:10px;vertical-align:middle">restart required</span></div>
|
||||
<form method="POST" action="/ui/settings/network">
|
||||
<div class="radio-group">{proxy_radios}</div>
|
||||
<div id="socks5-url"{socks5_hidden} style="margin-top:.75rem">
|
||||
<label style="font-size:12px;color:var(--muted);display:block;margin-bottom:.3rem">SOCKS5 address (host:port or socks5://host:port)</label>
|
||||
<input type="text" name="url" value="{url}" placeholder="127.0.0.1:1080" style="width:280px">
|
||||
</div>
|
||||
<div style="margin-top:.75rem">
|
||||
<button type="submit" class="btn-accent">Save network settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>"#,
|
||||
url = esc(&proxy.url),
|
||||
);
|
||||
|
||||
// ── Relays ────────────────────────────────────────────────────────────────
|
||||
let relay_rows: String = cfg.relays.iter().map(|r| {
|
||||
format!(
|
||||
r#"<div class="relay-row">
|
||||
<span class="relay-url">{url}</span>
|
||||
<form method="POST" action="/ui/settings/relays/remove" class="ifrm">
|
||||
<input type="hidden" name="url" value="{url_raw}">
|
||||
<button type="submit" class="btn-danger">Remove</button>
|
||||
</form>
|
||||
</div>"#,
|
||||
url = esc(r),
|
||||
url_raw = esc(r),
|
||||
)
|
||||
}).collect();
|
||||
|
||||
let relays_section = format!(
|
||||
r#"<div class="card">
|
||||
<div class="card-title">Relays <span class="badge badge-warn" style="font-size:10px;vertical-align:middle">restart required</span></div>
|
||||
<div class="relay-list" style="margin-bottom:.75rem">{relay_rows}</div>
|
||||
<form method="POST" action="/ui/settings/relays/add">
|
||||
<div class="form-row">
|
||||
<input type="text" name="url" placeholder="wss://relay.example.com" style="width:320px" required>
|
||||
<button type="submit" class="btn-accent">Add relay</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>"#
|
||||
);
|
||||
|
||||
// ── 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#"<div class="card">
|
||||
<div class="card-title">Curation & Trust</div>
|
||||
<form method="POST" action="/ui/settings/curation">
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Operator pubkey
|
||||
<span class="setting-hint">Your Nostr pubkey (npub or hex) — root of the follow graph</span>
|
||||
</label>
|
||||
<input type="text" name="operator_pubkey" value="{op_pk}" placeholder="npub1… or 64-char hex" style="width:420px" class="mono-in">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Follow depth
|
||||
<span class="setting-hint">How many hops to include in the Web of Trust graph</span>
|
||||
</label>
|
||||
<select name="follow_depth">
|
||||
<option value="1"{depth1}>1 — direct follows only</option>
|
||||
<option value="2"{depth2}>2 — follows of follows</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">WoT only
|
||||
<span class="setting-hint">Drop events from pubkeys outside your follow graph</span>
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" name="wot_only" value="true"{wot_checked}>
|
||||
Only index publishers in my follow graph
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Auto-block threshold
|
||||
<span class="setting-hint">Block a publisher automatically after this many reports</span>
|
||||
</label>
|
||||
<input type="number" name="auto_block_threshold" value="{abt}" min="1" max="100" style="width:80px">
|
||||
</div>
|
||||
<div style="margin-top:.75rem">
|
||||
<button type="submit" class="btn-accent">Save curation settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>"#,
|
||||
op_pk = esc(&c.operator_pubkey),
|
||||
abt = c.auto_block_threshold,
|
||||
);
|
||||
|
||||
// ── Ingest ────────────────────────────────────────────────────────────────
|
||||
let ingest_section = format!(
|
||||
r#"<div class="card">
|
||||
<div class="card-title">Ingest <span class="badge badge-warn" style="font-size:10px;vertical-align:middle">restart required</span></div>
|
||||
<form method="POST" action="/ui/settings/ingest">
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Backfill days
|
||||
<span class="setting-hint">How far back to request history from a relay that has never been seen before</span>
|
||||
</label>
|
||||
<input type="number" name="backfill_days" value="{bd}" min="1" max="3650" style="width:100px">
|
||||
</div>
|
||||
<div style="margin-top:.75rem">
|
||||
<button type="submit" class="btn-accent">Save ingest settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>"#,
|
||||
bd = cfg.backfill_days,
|
||||
);
|
||||
|
||||
// ── TMDB ──────────────────────────────────────────────────────────────────
|
||||
let tmdb_checked = if cfg.tmdb.enabled { " checked" } else { "" };
|
||||
let tmdb_section = format!(
|
||||
r#"<div class="card">
|
||||
<div class="card-title">TMDB enrichment</div>
|
||||
<form method="POST" action="/ui/settings/tmdb">
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">Enabled
|
||||
<span class="setting-hint">Look up movie/TV metadata from TMDB when a torrent has no TMDB ID tag</span>
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" name="enabled" value="true"{tmdb_checked}>
|
||||
Enable TMDB enrichment
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label class="setting-label">API key
|
||||
<span class="setting-hint">TMDB v3 read access token — <a href="https://www.themoviedb.org/settings/api" target="_blank">get one here</a></span>
|
||||
</label>
|
||||
<input type="text" name="api_key" value="{api_key}" placeholder="eyJ…" style="width:420px" class="mono-in">
|
||||
</div>
|
||||
<div style="margin-top:.75rem">
|
||||
<button type="submit" class="btn-accent">Save TMDB settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>"#,
|
||||
api_key = esc(&cfg.tmdb.api_key),
|
||||
);
|
||||
|
||||
let js = r#"<script>
|
||||
function toggleSocks5(mode) {
|
||||
document.getElementById('socks5-url').style.display = mode === 'socks5' ? '' : 'none';
|
||||
}
|
||||
</script>"#;
|
||||
|
||||
let body = format!(
|
||||
r#"<h1>Settings</h1>
|
||||
{flash}
|
||||
{restart_banner}
|
||||
<div class="section">
|
||||
<div class="section-title">Network</div>
|
||||
{network_section}
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Relays</div>
|
||||
{relays_section}
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Curation & Trust</div>
|
||||
{curation_section}
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Ingest</div>
|
||||
{ingest_section}
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">TMDB</div>
|
||||
{tmdb_section}
|
||||
</div>
|
||||
{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<AppState>,
|
||||
Form(form): Form<NetworkForm>,
|
||||
) -> 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<AppState>,
|
||||
Form(form): Form<RelayForm>,
|
||||
) -> 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<AppState>,
|
||||
Form(form): Form<RelayForm>,
|
||||
) -> 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<AppState>,
|
||||
Form(form): Form<CurationForm>,
|
||||
) -> 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<AppState>,
|
||||
Form(form): Form<IngestForm>,
|
||||
) -> 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<AppState>,
|
||||
Form(form): Form<TmdbForm>,
|
||||
) -> 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")
|
||||
}
|
||||
Reference in New Issue
Block a user