313 lines
12 KiB
Rust
313 lines
12 KiB
Rust
//! Self-describing per-plugin configuration: schema, redaction, merge, and the
|
|
//! generic schema-driven HTML page the feeder serves at `/config`.
|
|
//!
|
|
//! The point of this module is that **the gateway carries no per-plugin config
|
|
//! knowledge** and **the SDK carries no per-plugin field names**. A plugin
|
|
//! declares its fields via [`FeederPlugin::config_schema`](crate::FeederPlugin::config_schema);
|
|
//! everything here (redaction of secrets, merge-on-save, and the rendered form)
|
|
//! is driven entirely by that schema. Add a feeder → it shows up with a working
|
|
//! config form and zero UI/gateway changes.
|
|
//!
|
|
//! ## Persistence & precedence
|
|
//!
|
|
//! Config lives at `cache_dir/config.json` (the per-plugin dir handed to
|
|
//! `configure()`). The plugin reads **file-or-env** at startup: env is the
|
|
//! first-boot seed and stays authoritative until the operator saves through the
|
|
//! UI, at which point `config.json` exists and wins. There is no runtime reload
|
|
//! — a save returns `restart_required: true` (mirrors the gateway's
|
|
//! `gateway-config.json` posture).
|
|
//!
|
|
//! ## Secrets
|
|
//!
|
|
//! Fields of kind [`FieldKind::Secret`] are **write-only**: never returned to
|
|
//! the page (redacted to a `<key>_set: bool`), and a blank incoming value on
|
|
//! save means "keep the stored secret".
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Map, Value};
|
|
|
|
/// A plugin's full config surface — an ordered list of fields.
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
pub struct ConfigSchema {
|
|
pub fields: Vec<ConfigField>,
|
|
}
|
|
|
|
impl ConfigSchema {
|
|
/// Convenience: `true` when the plugin declares no configurable fields (the
|
|
/// page then renders a "no configuration required" notice).
|
|
pub fn is_empty(&self) -> bool {
|
|
self.fields.is_empty()
|
|
}
|
|
}
|
|
|
|
/// One configurable field. `record_array` fields nest `fields` for the
|
|
/// per-record sub-schema (e.g. torznab's `indexers`).
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct ConfigField {
|
|
/// JSON key in the config object.
|
|
pub key: String,
|
|
/// Human label for the form.
|
|
pub label: String,
|
|
#[serde(rename = "type")]
|
|
pub kind: FieldKind,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub help: Option<String>,
|
|
#[serde(default)]
|
|
pub required: bool,
|
|
/// Sub-fields for [`FieldKind::RecordArray`]. The FIRST sub-field is treated
|
|
/// as the record identifier for secret back-fill on merge (e.g. `name`).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub fields: Option<Vec<ConfigField>>,
|
|
}
|
|
|
|
impl ConfigField {
|
|
pub fn text(key: &str, label: &str) -> Self {
|
|
Self::new(key, label, FieldKind::Text)
|
|
}
|
|
pub fn secret(key: &str, label: &str) -> Self {
|
|
Self::new(key, label, FieldKind::Secret)
|
|
}
|
|
pub fn bool(key: &str, label: &str) -> Self {
|
|
Self::new(key, label, FieldKind::Bool)
|
|
}
|
|
pub fn number(key: &str, label: &str) -> Self {
|
|
Self::new(key, label, FieldKind::Number)
|
|
}
|
|
pub fn list(key: &str, label: &str) -> Self {
|
|
Self::new(key, label, FieldKind::List)
|
|
}
|
|
pub fn record_array(key: &str, label: &str, fields: Vec<ConfigField>) -> Self {
|
|
let mut f = Self::new(key, label, FieldKind::RecordArray);
|
|
f.fields = Some(fields);
|
|
f
|
|
}
|
|
fn new(key: &str, label: &str, kind: FieldKind) -> Self {
|
|
Self {
|
|
key: key.to_string(),
|
|
label: label.to_string(),
|
|
kind,
|
|
help: None,
|
|
required: false,
|
|
fields: None,
|
|
}
|
|
}
|
|
pub fn with_help(mut self, help: &str) -> Self {
|
|
self.help = Some(help.to_string());
|
|
self
|
|
}
|
|
pub fn required(mut self) -> Self {
|
|
self.required = true;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Field widget / value type. Drives both rendering and the secret/merge rules.
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum FieldKind {
|
|
/// Free text → JSON string.
|
|
Text,
|
|
/// Write-only secret → JSON string; redacted on read, "blank keeps" on save.
|
|
Secret,
|
|
/// Checkbox → JSON bool.
|
|
Bool,
|
|
/// Numeric input → JSON number.
|
|
Number,
|
|
/// Comma/line list → JSON array of strings.
|
|
List,
|
|
/// Repeatable record → JSON array of objects keyed by nested `fields`.
|
|
RecordArray,
|
|
}
|
|
|
|
fn is_blank(v: Option<&Value>) -> bool {
|
|
match v {
|
|
None | Some(Value::Null) => true,
|
|
Some(Value::String(s)) => s.is_empty(),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Produce a UI-safe view of `values`: secret fields are dropped and replaced
|
|
/// with a `<key>_set` boolean; everything else passes through. Recurses into
|
|
/// `record_array` records for secret sub-fields.
|
|
pub fn redact(values: &Value, schema: &ConfigSchema) -> Value {
|
|
let src = values.as_object().cloned().unwrap_or_default();
|
|
Value::Object(redact_object(&src, &schema.fields))
|
|
}
|
|
|
|
fn redact_object(src: &Map<String, Value>, fields: &[ConfigField]) -> Map<String, Value> {
|
|
let mut out = Map::new();
|
|
for f in fields {
|
|
match f.kind {
|
|
FieldKind::Secret => {
|
|
let set = !is_blank(src.get(&f.key));
|
|
out.insert(format!("{}_set", f.key), Value::Bool(set));
|
|
}
|
|
FieldKind::RecordArray => {
|
|
let subs = f.fields.as_deref().unwrap_or(&[]);
|
|
let arr = src
|
|
.get(&f.key)
|
|
.and_then(Value::as_array)
|
|
.map(|recs| {
|
|
recs.iter()
|
|
.map(|r| {
|
|
let m = r.as_object().cloned().unwrap_or_default();
|
|
Value::Object(redact_object(&m, subs))
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
out.insert(f.key.clone(), Value::Array(arr));
|
|
}
|
|
_ => {
|
|
if let Some(v) = src.get(&f.key) {
|
|
out.insert(f.key.clone(), v.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Merge `incoming` (from the UI) onto `base` (the stored/effective config),
|
|
/// schema-aware. Returns the full, UNREDACTED object to persist:
|
|
/// - secret: blank/absent incoming keeps `base`; non-blank overwrites.
|
|
/// - text/number/bool/list: present in incoming overwrites; absent keeps base.
|
|
/// - record_array: incoming array replaces base, but blank secret sub-fields are
|
|
/// back-filled from the base record matched by the first sub-field (identifier).
|
|
pub fn merge(base: &Value, incoming: &Value, schema: &ConfigSchema) -> Value {
|
|
let base_obj = base.as_object().cloned().unwrap_or_default();
|
|
let inc_obj = incoming.as_object().cloned().unwrap_or_default();
|
|
Value::Object(merge_object(&base_obj, &inc_obj, &schema.fields))
|
|
}
|
|
|
|
fn merge_object(
|
|
base: &Map<String, Value>,
|
|
inc: &Map<String, Value>,
|
|
fields: &[ConfigField],
|
|
) -> Map<String, Value> {
|
|
let mut out = base.clone();
|
|
for f in fields {
|
|
match f.kind {
|
|
FieldKind::Secret => {
|
|
if !is_blank(inc.get(&f.key)) {
|
|
out.insert(f.key.clone(), inc.get(&f.key).cloned().unwrap());
|
|
}
|
|
// blank/absent → keep base (no-op)
|
|
}
|
|
FieldKind::RecordArray => {
|
|
let subs = f.fields.as_deref().unwrap_or(&[]);
|
|
let id_key = subs.first().map(|s| s.key.clone());
|
|
let base_recs = base.get(&f.key).and_then(Value::as_array).cloned();
|
|
let inc_recs = match inc.get(&f.key).and_then(Value::as_array) {
|
|
Some(r) => r.clone(),
|
|
None => continue, // absent → keep base array untouched
|
|
};
|
|
let merged: Vec<Value> = inc_recs
|
|
.iter()
|
|
.map(|rec| {
|
|
let rec_obj = rec.as_object().cloned().unwrap_or_default();
|
|
// Find the matching base record by identifier sub-field.
|
|
let base_match = match (&id_key, base_recs.as_ref()) {
|
|
(Some(idk), Some(brs)) => brs
|
|
.iter()
|
|
.find(|br| br.get(idk) == rec_obj.get(idk))
|
|
.and_then(Value::as_object)
|
|
.cloned()
|
|
.unwrap_or_default(),
|
|
_ => Map::new(),
|
|
};
|
|
Value::Object(merge_object(&base_match, &rec_obj, subs))
|
|
})
|
|
.collect();
|
|
out.insert(f.key.clone(), Value::Array(merged));
|
|
}
|
|
_ => {
|
|
if let Some(v) = inc.get(&f.key) {
|
|
out.insert(f.key.clone(), v.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The generic, schema-driven config page served at `GET /config`. Self-contained
|
|
/// (inline CSS + JS, no external assets) so it loads identically when hit
|
|
/// directly on the feeder OR reverse-proxied through the gateway. All API calls
|
|
/// are relative to `location.pathname`, so the proxy mount prefix is irrelevant.
|
|
pub const CONFIG_PAGE_HTML: &str = include_str!("config_page.html");
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn torznab_like() -> ConfigSchema {
|
|
ConfigSchema {
|
|
fields: vec![
|
|
ConfigField::secret("tmdb_token", "TMDB token"),
|
|
ConfigField::bool("resolve_torrent_files", "Resolve .torrent"),
|
|
ConfigField::record_array(
|
|
"indexers",
|
|
"Indexers",
|
|
vec![
|
|
ConfigField::text("name", "Name"),
|
|
ConfigField::secret("api_key", "API key"),
|
|
],
|
|
),
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn redact_hides_secrets_and_flags_set() {
|
|
let v = json!({
|
|
"tmdb_token": "abc",
|
|
"resolve_torrent_files": true,
|
|
"indexers": [{"name":"prowlarr","api_key":"k"}]
|
|
});
|
|
let r = redact(&v, &torznab_like());
|
|
assert_eq!(r["tmdb_token_set"], json!(true));
|
|
assert!(r.get("tmdb_token").is_none());
|
|
assert_eq!(r["resolve_torrent_files"], json!(true));
|
|
assert_eq!(r["indexers"][0]["name"], json!("prowlarr"));
|
|
assert_eq!(r["indexers"][0]["api_key_set"], json!(true));
|
|
assert!(r["indexers"][0].get("api_key").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn merge_blank_secret_keeps_base() {
|
|
let base = json!({"tmdb_token": "keepme"});
|
|
let inc = json!({"tmdb_token": ""});
|
|
let m = merge(&base, &inc, &torznab_like());
|
|
assert_eq!(m["tmdb_token"], json!("keepme"));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_nonblank_secret_overwrites() {
|
|
let base = json!({"tmdb_token": "old"});
|
|
let inc = json!({"tmdb_token": "new"});
|
|
let m = merge(&base, &inc, &torznab_like());
|
|
assert_eq!(m["tmdb_token"], json!("new"));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_record_array_backfills_blank_secret_by_identifier() {
|
|
let base = json!({"indexers":[{"name":"prowlarr","api_key":"secret"}]});
|
|
// UI re-submits the indexer with a blank api_key → keep the stored one.
|
|
let inc = json!({"indexers":[{"name":"prowlarr","api_key":""}]});
|
|
let m = merge(&base, &inc, &torznab_like());
|
|
assert_eq!(m["indexers"][0]["api_key"], json!("secret"));
|
|
assert_eq!(m["indexers"][0]["name"], json!("prowlarr"));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_bool_overwrites_when_present() {
|
|
let base = json!({"resolve_torrent_files": true});
|
|
let inc = json!({"resolve_torrent_files": false});
|
|
let m = merge(&base, &inc, &torznab_like());
|
|
assert_eq!(m["resolve_torrent_files"], json!(false));
|
|
}
|
|
}
|