From 91d4b99aeebfccee34138d077517904ef461c71e Mon Sep 17 00:00:00 2001 From: ookami125 Date: Tue, 23 Jun 2026 22:11:17 -0400 Subject: [PATCH] webui: store RSS feeds/rules/indexers in the webui DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move RSS persistence out of the daemon blob store and into the webui's own SQLite DB (feeds, rules, indexers tables; articles + affectedFeeds held as JSON columns). Remove the now-unused daemon blob store (set/get_webui_blob, blob_lock, data_dir). With this, all webui-owned state — accounts, taxonomy, RSS — lives in the webui DB; the daemon only keeps naut's own data (per-torrent labels still flow through set_labels for Lua). Co-Authored-By: Claude Opus 4.8 --- apps/nautd/main.c | 73 ----------------- plugins/webui/webui.c | 39 ++++----- plugins/webui/webui_store.c | 152 ++++++++++++++++++++++++++++++++++++ plugins/webui/webui_store.h | 10 +++ 4 files changed, 178 insertions(+), 96 deletions(-) diff --git a/apps/nautd/main.c b/apps/nautd/main.c index 85f64a0..f313421 100644 --- a/apps/nautd/main.c +++ b/apps/nautd/main.c @@ -114,8 +114,6 @@ struct daemon_state { json_t *script_settings_schema; /* array of {key,label,type,default} */ json_t *script_settings; /* object: key -> value string (user-set)*/ char settings_file[PATH_MAX]; /* /script_settings.json */ - char data_dir[PATH_MAX]; /* the resolved state dir (for blobs) */ - pthread_mutex_t blob_lock; /* guards webui_.json blob files */ pthread_mutex_t torrent_lock; torrent_task *torrents[MAX_TORRENTS]; size_t torrent_count; @@ -1313,70 +1311,6 @@ static json_t *rpc_set_script_settings(void *opaque, const json_t *params, return script_status_json(state); } -/* --- generic web-UI blob store (RSS feeds, indexer config, ...) ------------ * - * The web layer owns these schemas; the daemon only persists them, one JSON - * document per key, under /webui_.json. Keys are sanitized to a - * safe filename charset so a key can never escape the state directory. */ - -static bool blob_path(daemon_state *state, const char *key, char *out, size_t n) { - if (!key || !*key || !state->data_dir[0]) return false; - char safe[64]; - size_t j = 0; - for (size_t i = 0; key[i] && j + 1 < sizeof safe; i++) { - char c = key[i]; - if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || - (c >= '0' && c <= '9') || c == '_' || c == '-') - safe[j++] = c; - } - safe[j] = 0; - if (j == 0) return false; - return (size_t)snprintf(out, n, "%s/webui_%s.json", state->data_dir, safe) < n; -} - -static json_t *rpc_get_webui_blob(void *opaque, const json_t *params, - naut_err *error) { - daemon_state *state = opaque; - const char *key = json_string_value(json_object_get(params, "key")); - char path[PATH_MAX]; - if (!blob_path(state, key, path, sizeof path)) { - *error = NAUT_ERR_INVAL; - return NULL; - } - pthread_mutex_lock(&state->blob_lock); - json_error_t jerr; - json_t *value = json_load_file(path, 0, &jerr); - pthread_mutex_unlock(&state->blob_lock); - json_t *out = json_object(); - if (!out) { json_decref(value); *error = NAUT_ERR_NOMEM; return NULL; } - json_object_set_new(out, "value", value ? value : json_null()); - *error = NAUT_OK; - return out; -} - -static json_t *rpc_set_webui_blob(void *opaque, const json_t *params, - naut_err *error) { - daemon_state *state = opaque; - const char *key = json_string_value(json_object_get(params, "key")); - json_t *value = json_object_get(params, "value"); - char path[PATH_MAX]; - if (!value || !blob_path(state, key, path, sizeof path)) { - *error = NAUT_ERR_INVAL; - return NULL; - } - *error = NAUT_OK; - if (!state->persist_enabled) return json_object(); - pthread_mutex_lock(&state->blob_lock); - char tmp[PATH_MAX + 8]; - snprintf(tmp, sizeof tmp, "%s.tmp", path); - if (json_dump_file(value, tmp, JSON_INDENT(2)) != 0 || - rename(tmp, path) != 0) { - NAUT_WARN("persist: write %s failed", path); - unlink(tmp); - } - pthread_mutex_unlock(&state->blob_lock); - return json_object(); -} - /* --- data-overlap guard (block torrents that would write the same files) --- */ static uint8_t *slurp_file(const char *path, size_t *len) { @@ -2303,8 +2237,6 @@ static bool register_commands(daemon_state *state) { naut_rpc_register(state->rpc, "script_status", rpc_script_status, state) == NAUT_OK && naut_rpc_register(state->rpc, "update_script", rpc_update_script, state) == NAUT_OK && naut_rpc_register(state->rpc, "set_script_settings", rpc_set_script_settings, state) == NAUT_OK && - naut_rpc_register(state->rpc, "get_webui_blob", rpc_get_webui_blob, state) == NAUT_OK && - naut_rpc_register(state->rpc, "set_webui_blob", rpc_set_webui_blob, state) == NAUT_OK && naut_rpc_register(state->rpc, "unload_script", rpc_unload_script, state) == NAUT_OK && naut_rpc_register(state->rpc, "shutdown", rpc_shutdown, state) == NAUT_OK; } @@ -2593,9 +2525,6 @@ static bool resolve_state_dir(daemon_state *state, const char *override) { "%s/script_settings.json", dir) >= sizeof state->settings_file) return false; - if ((size_t)snprintf(state->data_dir, sizeof state->data_dir, "%s", dir) >= - sizeof state->data_dir) - return false; return true; } @@ -2701,7 +2630,6 @@ int main(int argc, char **argv) { pthread_mutex_init(&state.script_lock, NULL); pthread_mutex_init(&state.settings_lock, NULL); load_script_settings(&state); /* user-set values; schema comes from the script */ - pthread_mutex_init(&state.blob_lock, NULL); state.events = naut_event_bus_create(); state.rpc = naut_rpc_registry_create(); state.plugins = naut_plugin_manager_create(state.rpc, state.events); @@ -2784,6 +2712,5 @@ int main(int argc, char **argv) { json_decref(state.script_settings); json_decref(state.script_settings_schema); pthread_mutex_destroy(&state.settings_lock); - pthread_mutex_destroy(&state.blob_lock); return 0; } diff --git a/plugins/webui/webui.c b/plugins/webui/webui.c index e47cc76..9d51e95 100644 --- a/plugins/webui/webui.c +++ b/plugins/webui/webui.c @@ -1950,37 +1950,30 @@ static char *base64_encode(const unsigned char *in, size_t len) { /* --- RSS persistence (via the daemon blob store) -------------------------- */ +/* Persist feeds/rules/indexers to the web-UI's own database. */ static void rss_save(void) { + if (!g_webui.store) return; pthread_mutex_lock(&g_webui.rss_lock); - json_t *doc = json_pack("{s:O,s:O,s:O}", - "feeds", g_webui.rss_feeds ? g_webui.rss_feeds : json_array(), - "rules", g_webui.rss_rules ? g_webui.rss_rules : json_array(), - "indexers", g_webui.indexers ? g_webui.indexers : json_array()); + json_t *feeds = json_deep_copy(g_webui.rss_feeds); + json_t *rules = json_deep_copy(g_webui.rss_rules); + json_t *idx = json_deep_copy(g_webui.indexers); pthread_mutex_unlock(&g_webui.rss_lock); - if (!doc) return; - json_t *params = json_pack("{s:s,s:o}", "key", "rss", "value", doc); - if (!params) { json_decref(doc); return; } - json_t *reply = rpc_call_json("set_webui_blob", params); - json_decref(params); - if (reply) json_decref(reply); + if (feeds) { webui_store_save_feeds(g_webui.store, feeds); json_decref(feeds); } + if (rules) { webui_store_save_rules(g_webui.store, rules); json_decref(rules); } + if (idx) { webui_store_save_indexers(g_webui.store, idx); json_decref(idx); } } static void rss_load(void) { - json_t *params = json_pack("{s:s}", "key", "rss"); - json_t *reply = rpc_call_json("get_webui_blob", params); - json_decref(params); - json_t *value = reply ? json_object_get(reply, "value") : NULL; + if (!g_webui.store) return; + json_t *feeds = json_array(), *rules = json_array(), *idx = json_array(); + bool of = webui_store_load_feeds(g_webui.store, feeds); + bool orr = webui_store_load_rules(g_webui.store, rules); + bool oi = webui_store_load_indexers(g_webui.store, idx); pthread_mutex_lock(&g_webui.rss_lock); - if (json_is_object(value)) { - json_t *feeds = json_object_get(value, "feeds"); - json_t *rules = json_object_get(value, "rules"); - json_t *idx = json_object_get(value, "indexers"); - if (json_is_array(feeds)) { json_decref(g_webui.rss_feeds); g_webui.rss_feeds = json_deep_copy(feeds); } - if (json_is_array(rules)) { json_decref(g_webui.rss_rules); g_webui.rss_rules = json_deep_copy(rules); } - if (json_is_array(idx)) { json_decref(g_webui.indexers); g_webui.indexers = json_deep_copy(idx); } - } + if (of) { json_decref(g_webui.rss_feeds); g_webui.rss_feeds = feeds; } else json_decref(feeds); + if (orr) { json_decref(g_webui.rss_rules); g_webui.rss_rules = rules; } else json_decref(rules); + if (oi) { json_decref(g_webui.indexers); g_webui.indexers = idx; } else json_decref(idx); pthread_mutex_unlock(&g_webui.rss_lock); - if (reply) json_decref(reply); } /* --- auto-download: hand a matched article to the daemon ------------------ */ diff --git a/plugins/webui/webui_store.c b/plugins/webui/webui_store.c index 4a56889..a388cbf 100644 --- a/plugins/webui/webui_store.c +++ b/plugins/webui/webui_store.c @@ -388,3 +388,155 @@ bool webui_store_load_tags(webui_store *s, json_t *out) { pthread_mutex_unlock(&s->lock); return ok; } + +/* --- RSS ------------------------------------------------------------------ */ + +static int int_of(json_t *o, const char *k) { + return json_boolean_value(json_object_get(o, k)) ? 1 : 0; +} + +/* Bind a json array column as a compact JSON string (SQLite copies it). */ +static void bind_json_array(sqlite3_stmt *st, int col, json_t *arr) { + char *s = json_dumps(json_is_array(arr) ? arr : json_array(), JSON_COMPACT); + sqlite3_bind_text(st, col, s ? s : "[]", -1, SQLITE_TRANSIENT); + free(s); +} + +/* Parse a TEXT column holding a JSON array; returns a new array (never NULL). */ +static json_t *array_col(sqlite3_stmt *st, int col) { + const char *txt = (const char *)sqlite3_column_text(st, col); + if (txt) { + json_t *a = json_loads(txt, 0, NULL); + if (json_is_array(a)) return a; + json_decref(a); + } + return json_array(); +} + +static void bind_feed(sqlite3_stmt *st, json_t *f) { + sqlite3_bind_text(st, 1, str_or(f, "name", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, str_or(f, "url", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 3, (sqlite3_int64)json_integer_value(json_object_get(f, "lastUpdate"))); + bind_json_array(st, 4, json_object_get(f, "articles")); +} + +bool webui_store_save_feeds(webui_store *s, json_t *feeds) { + return replace_table(s, "DELETE FROM feeds;", + "INSERT OR REPLACE INTO feeds (name, url, last_update, articles) VALUES (?,?,?,?);", + feeds, bind_feed); +} + +bool webui_store_load_feeds(webui_store *s, json_t *out) { + if (!s || !json_is_array(out)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "SELECT name, url, last_update, articles FROM feeds ORDER BY name;", + -1, &st, NULL) == SQLITE_OK) { + ok = true; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *n = (const char *)sqlite3_column_text(st, 0); + const char *u = (const char *)sqlite3_column_text(st, 1); + json_array_append_new(out, json_pack("{s:s,s:s,s:I,s:o}", + "name", n ? n : "", "url", u ? u : "", + "lastUpdate", (json_int_t)sqlite3_column_int64(st, 2), + "articles", array_col(st, 3))); + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +static void bind_rule(sqlite3_stmt *st, json_t *r) { + sqlite3_bind_text(st, 1, str_or(r, "name", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(st, 2, int_of(r, "enabled")); + sqlite3_bind_int(st, 3, int_of(r, "useRegex")); + sqlite3_bind_int(st, 4, int_of(r, "addPaused")); + sqlite3_bind_text(st, 5, str_or(r, "mustContain", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, str_or(r, "mustNotContain", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 7, str_or(r, "assignedCategory", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 8, str_or(r, "savePath", ""), -1, SQLITE_TRANSIENT); + bind_json_array(st, 9, json_object_get(r, "affectedFeeds")); + sqlite3_bind_int64(st, 10, (sqlite3_int64)json_integer_value(json_object_get(r, "lastMatch"))); +} + +bool webui_store_save_rules(webui_store *s, json_t *rules) { + return replace_table(s, "DELETE FROM rules;", + "INSERT OR REPLACE INTO rules (name, enabled, use_regex, add_paused," + " must_contain, must_not_contain, assigned_category, save_path," + " affected_feeds, last_match) VALUES (?,?,?,?,?,?,?,?,?,?);", + rules, bind_rule); +} + +bool webui_store_load_rules(webui_store *s, json_t *out) { + if (!s || !json_is_array(out)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "SELECT name, enabled, use_regex, add_paused, must_contain," + " must_not_contain, assigned_category, save_path, affected_feeds," + " last_match FROM rules ORDER BY name;", -1, &st, NULL) == SQLITE_OK) { + ok = true; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *n = (const char *)sqlite3_column_text(st, 0); + const char *mc = (const char *)sqlite3_column_text(st, 4); + const char *mn = (const char *)sqlite3_column_text(st, 5); + const char *ac = (const char *)sqlite3_column_text(st, 6); + const char *sp = (const char *)sqlite3_column_text(st, 7); + json_array_append_new(out, json_pack( + "{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:o,s:I}", + "name", n ? n : "", + "enabled", sqlite3_column_int(st, 1), + "useRegex", sqlite3_column_int(st, 2), + "addPaused", sqlite3_column_int(st, 3), + "mustContain", mc ? mc : "", + "mustNotContain", mn ? mn : "", + "assignedCategory", ac ? ac : "", + "savePath", sp ? sp : "", + "affectedFeeds", array_col(st, 8), + "lastMatch", (json_int_t)sqlite3_column_int64(st, 9))); + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +static void bind_indexer(sqlite3_stmt *st, json_t *x) { + sqlite3_bind_text(st, 1, str_or(x, "name", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, str_or(x, "url", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, str_or(x, "apikey", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(st, 4, int_of(x, "enabled")); +} + +bool webui_store_save_indexers(webui_store *s, json_t *indexers) { + return replace_table(s, "DELETE FROM indexers;", + "INSERT OR REPLACE INTO indexers (name, url, apikey, enabled) VALUES (?,?,?,?);", + indexers, bind_indexer); +} + +bool webui_store_load_indexers(webui_store *s, json_t *out) { + if (!s || !json_is_array(out)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "SELECT name, url, apikey, enabled FROM indexers ORDER BY name;", + -1, &st, NULL) == SQLITE_OK) { + ok = true; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *n = (const char *)sqlite3_column_text(st, 0); + const char *u = (const char *)sqlite3_column_text(st, 1); + const char *k = (const char *)sqlite3_column_text(st, 2); + json_array_append_new(out, json_pack("{s:s,s:s,s:s,s:b}", + "name", n ? n : "", "url", u ? u : "", "apikey", k ? k : "", + "enabled", sqlite3_column_int(st, 3))); + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} diff --git a/plugins/webui/webui_store.h b/plugins/webui/webui_store.h index 6fcfdf4..fc5c9c6 100644 --- a/plugins/webui/webui_store.h +++ b/plugins/webui/webui_store.h @@ -52,4 +52,14 @@ bool webui_store_load_categories(webui_store *s, json_t *out); bool webui_store_save_tags(webui_store *s, json_t *tags); bool webui_store_load_tags(webui_store *s, json_t *out); +/* --- RSS: feeds, auto-download rules, Torznab indexers (owned here) -------- * + * save_* replace the whole list atomically; load_* append to the array `out`, + * rebuilding the exact JSON shapes the web layer/UI use. */ +bool webui_store_save_feeds(webui_store *s, json_t *feeds); +bool webui_store_load_feeds(webui_store *s, json_t *out); +bool webui_store_save_rules(webui_store *s, json_t *rules); +bool webui_store_load_rules(webui_store *s, json_t *out); +bool webui_store_save_indexers(webui_store *s, json_t *indexers); +bool webui_store_load_indexers(webui_store *s, json_t *out); + #endif /* NAUT_WEBUI_STORE_H */