From d6e0e51175447b64a1d8dd723d0eaa9f95f5f82c Mon Sep 17 00:00:00 2001 From: ookami125 Date: Tue, 23 Jun 2026 00:56:04 -0400 Subject: [PATCH] webui: RSS auto-download + Torznab search backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a real backend behind the RSS and Search tabs: - nautd gains a generic web-UI blob store (set/get_webui_blob) so the web layer can persist RSS feeds, auto-download rules and indexer config under /webui_.json. - The webui plugin runs a background poller that fetches each feed over HTTP(S), parses RSS 2.0 / Atom items (title, link, enclosure, size, pubDate, magnet incl. torrent:magnetURI), dedupes, and stores articles. - Auto-download rules (substring or POSIX regex, mustContain/ mustNotContain, per-feed scope) fire on newly-seen items and add the torrent via the daemon — from a magnet, or by fetching a .torrent enclosure and uploading its bytes — applying category/save path/paused. - Search queries every enabled Torznab indexer and merges results (name, size, seeders, leechers, magnet/.torrent), exposed as searchPlugins in /api/meta. New endpoints: GET/POST /api/rss(+/delete), /api/rss/rules(+/delete), /api/indexers(+/delete), GET /api/search?q=. Co-Authored-By: Claude Opus 4.8 --- apps/nautd/main.c | 73 +++++ plugins/webui/webui.c | 738 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 802 insertions(+), 9 deletions(-) diff --git a/apps/nautd/main.c b/apps/nautd/main.c index 990d5d4..0199ff4 100644 --- a/apps/nautd/main.c +++ b/apps/nautd/main.c @@ -121,6 +121,8 @@ struct daemon_state { json_t *label_categories; /* array of {name, savePath} */ json_t *label_tags; /* array of tag name strings */ char taxonomy_file[PATH_MAX]; /* /labels.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; @@ -1401,6 +1403,70 @@ static json_t *rpc_set_label_taxonomy(void *opaque, const json_t *params, return json_object(); } +/* --- 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) { @@ -2329,6 +2395,8 @@ static bool register_commands(daemon_state *state) { naut_rpc_register(state->rpc, "set_script_settings", rpc_set_script_settings, state) == NAUT_OK && naut_rpc_register(state->rpc, "get_label_taxonomy", rpc_get_label_taxonomy, state) == NAUT_OK && naut_rpc_register(state->rpc, "set_label_taxonomy", rpc_set_label_taxonomy, 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; } @@ -2620,6 +2688,9 @@ static bool resolve_state_dir(daemon_state *state, const char *override) { if ((size_t)snprintf(state->taxonomy_file, sizeof state->taxonomy_file, "%s/labels.json", dir) >= sizeof state->taxonomy_file) return false; + if ((size_t)snprintf(state->data_dir, sizeof state->data_dir, "%s", dir) >= + sizeof state->data_dir) + return false; return true; } @@ -2727,6 +2798,7 @@ int main(int argc, char **argv) { load_script_settings(&state); /* user-set values; schema comes from the script */ pthread_mutex_init(&state.taxonomy_lock, NULL); load_taxonomy(&state); /* persisted category + tag lists for the web UI */ + 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); @@ -2812,5 +2884,6 @@ int main(int argc, char **argv) { json_decref(state.label_categories); json_decref(state.label_tags); pthread_mutex_destroy(&state.taxonomy_lock); + pthread_mutex_destroy(&state.blob_lock); return 0; } diff --git a/plugins/webui/webui.c b/plugins/webui/webui.c index 1b360c8..a8f81d7 100644 --- a/plugins/webui/webui.c +++ b/plugins/webui/webui.c @@ -1,4 +1,5 @@ #include "naut/naut_plugin.h" +#include "naut/http_client.h" #include @@ -6,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -89,6 +91,17 @@ typedef struct { json_t *tags; /* array of tag name strings */ json_t *assignments; /* object keyed by stringified torrent id */ + /* RSS: feeds + auto-download rules, polled by a background thread and + * persisted via the daemon blob store. Search indexers live here too. */ + pthread_mutex_t rss_lock; + json_t *rss_feeds; /* array of {name,url,lastUpdate,articles:[...]} */ + json_t *rss_rules; /* array of rule objects */ + json_t *indexers; /* array of {name,url,apikey,enabled} (Torznab) */ + pthread_t rss_thread; + bool rss_thread_started; + pthread_cond_t rss_cond; /* wakes the poller for an immediate refresh */ + bool rss_wake; /* set with rss_cond to force an early re-poll */ + webui_session sessions[MAX_SESSIONS]; } webui_state; @@ -195,6 +208,38 @@ static void strip_query(char *path) { if (hash) *hash = 0; } +/* Percent-decode a URL query component in place-style into `out`. */ +static void url_decode(char *out, size_t outsz, const char *in, size_t n) { + size_t o = 0; + for (size_t i = 0; i < n && o + 1 < outsz; i++) { + if (in[i] == '%' && i + 2 < n) { + char hex[3] = { in[i+1], in[i+2], 0 }; + char *e; long v = strtol(hex, &e, 16); + if (e == hex + 2) { out[o++] = (char)v; i += 2; continue; } + } + out[o++] = (in[i] == '+') ? ' ' : in[i]; + } + out[o] = 0; +} + +/* Extract a query parameter from a "k=v&k2=v2" string (the part after '?'), + * URL-decoding the value into `out`. Returns true if the key was present. */ +static bool query_get(const char *query, const char *key, char *out, size_t outsz) { + if (!query) { if (outsz) out[0] = 0; return false; } + size_t klen = strlen(key); + for (const char *p = query; p && *p; ) { + const char *amp = strchr(p, '&'); + size_t seg = amp ? (size_t)(amp - p) : strlen(p); + if (seg > klen && p[klen] == '=' && strncmp(p, key, klen) == 0) { + url_decode(out, outsz, p + klen + 1, seg - klen - 1); + return true; + } + p = amp ? amp + 1 : NULL; + } + if (outsz) out[0] = 0; + return false; +} + /* Constant-time equality so credential checks don't leak length/content via * timing. Returns true when both NUL-terminated strings match exactly. */ static bool constant_time_equal(const char *a, const char *b) { @@ -1270,7 +1315,18 @@ static void api_meta(int fd) { json_decref(torrents); json_object_set_new(json, "trackers", trackers ? trackers : json_array()); json_object_set_new(json, "preferences", preferences); - json_object_set_new(json, "searchPlugins", json_array()); + /* searchPlugins mirrors the configured Torznab indexers for the Search tab. */ + json_t *plugins = json_array(); + pthread_mutex_lock(&g_webui.rss_lock); + size_t ii; json_t *ix; + json_array_foreach(g_webui.indexers, ii, ix) + json_array_append_new(plugins, json_pack("{s:s,s:s,s:s,s:b}", + "name", json_string_or(ix, "name", ""), + "url", json_string_or(ix, "url", ""), + "apikey", json_string_or(ix, "apikey", ""), + "enabled", json_boolean_value(json_object_get(ix, "enabled")))); + pthread_mutex_unlock(&g_webui.rss_lock); + json_object_set_new(json, "searchPlugins", plugins); http_json(fd, 200, json); json_decref(json); } @@ -1716,6 +1772,621 @@ static void api_script(int fd, const char *method, const char *body, size_t len) json_decref(result); } +/* ======================= RSS + Torznab search engine ====================== * + * The web layer owns RSS feeds, auto-download rules and search indexers; the + * daemon just persists them (blob store) and adds the torrents we hand it. A + * background thread polls feeds, parses items, and fires matching rules. */ + +#define RSS_POLL_INTERVAL_SEC (15 * 60) /* re-poll each feed every 15 min */ +#define RSS_MAX_ARTICLES 200 /* keep newest N per feed */ + +/* --- tiny XML helpers (scan, not a real parser; enough for RSS/Atom) ------ */ + +/* Decode the handful of XML entities feeds actually use, in place-ish. */ +static void xml_unescape(char *dst, size_t dstsz, const char *src, size_t n) { + size_t o = 0; + for (size_t i = 0; i < n && o + 1 < dstsz; i++) { + if (src[i] == '&') { + if (i + 4 < n && strncmp(src + i, "&", 5) == 0) { dst[o++] = '&'; i += 4; continue; } + if (i + 3 < n && strncmp(src + i, "<", 4) == 0) { dst[o++] = '<'; i += 3; continue; } + if (i + 3 < n && strncmp(src + i, ">", 4) == 0) { dst[o++] = '>'; i += 3; continue; } + if (i + 5 < n && strncmp(src + i, """, 6) == 0){ dst[o++] = '"'; i += 5; continue; } + if (i + 5 < n && strncmp(src + i, "'", 6) == 0){ dst[o++] = '\''; i += 5; continue; } + if (i + 4 < n && strncmp(src + i, "'", 5) == 0) { dst[o++] = '\''; i += 4; continue; } + if (i + 1 < n && src[i + 1] == '#') { /* numeric &#NN; */ + int base = 10, k = i + 2; + if (k < (int)n && (src[k] == 'x' || src[k] == 'X')) { base = 16; k++; } + long code = strtol(src + k, NULL, base); + const char *semi = memchr(src + i, ';', n - i); + if (semi && code > 0 && code < 128) { + dst[o++] = (char)code; + i = (size_t)(semi - src); + continue; + } + } + } + dst[o++] = src[i]; + } + dst[o] = 0; +} + +/* Find ... within [item, item+len) and write its decoded text to + * out. Handles a single CDATA section. Returns true if found. */ +static bool xml_tag_text(const char *item, size_t len, const char *tag, + char *out, size_t outsz) { + char open[64]; + int on = snprintf(open, sizeof open, "<%s", tag); + if (on < 0 || (size_t)on >= sizeof open) return false; + const char *p = item, *end = item + len; + while (p < end) { + const char *o = memmem(p, (size_t)(end - p), open, (size_t)on); + if (!o) return false; + const char *after = o + on; + if (after < end && *after != '>' && *after != ' ' && + *after != '\t' && *after != '/' && *after != ':') { p = after; continue; } + const char *gt = memchr(o, '>', (size_t)(end - o)); + if (!gt) return false; + const char *content = gt + 1; + char close[64]; + snprintf(close, sizeof close, "", tag); + const char *c = memmem(content, (size_t)(end - content), close, strlen(close)); + if (!c) return false; + const char *s = content; size_t slen = (size_t)(c - content); + if (slen >= 12 && strncmp(s, "", 3); + if (cd) slen = (size_t)(cd - s); + } + while (slen && (*s == ' ' || *s == '\n' || *s == '\r' || *s == '\t')) { s++; slen--; } + while (slen && (s[slen-1]==' '||s[slen-1]=='\n'||s[slen-1]=='\r'||s[slen-1]=='\t')) slen--; + xml_unescape(out, outsz, s, slen); + return true; + } + return false; +} + +/* Pull attribute value attr="..." from the first element in range. */ +static bool xml_attr(const char *item, size_t len, const char *tag, + const char *attr, char *out, size_t outsz) { + char open[64]; + int on = snprintf(open, sizeof open, "<%s", tag); + if (on < 0 || (size_t)on >= sizeof open) return false; + const char *o = memmem(item, len, open, (size_t)on); + if (!o) return false; + const char *gt = memchr(o, '>', (size_t)(item + len - o)); + if (!gt) return false; + char needle[64]; + int nn = snprintf(needle, sizeof needle, "%s=\"", attr); + if (nn < 0 || (size_t)nn >= sizeof needle) return false; + const char *a = memmem(o, (size_t)(gt - o), needle, (size_t)nn); + if (!a) return false; + a += nn; + const char *q = memchr(a, '"', (size_t)(gt - a)); + if (!q) return false; + xml_unescape(out, outsz, a, (size_t)(q - a)); + return true; +} + +/* Locate a magnet: URI anywhere inside the item element. */ +static bool find_magnet(const char *item, size_t len, char *out, size_t outsz) { + const char *m = memmem(item, len, "magnet:?", 8); + if (!m) return false; + size_t i = 0; + while (m < item + len && *m && *m != '<' && *m != '"' && *m != '\'' && + *m != ' ' && *m != '\n' && *m != '\r' && *m != '\t' && i + 1 < outsz) + out[i++] = *m++; + out[i] = 0; + /* decode & that often appears in magnet query separators */ + char tmp[2048]; + xml_unescape(tmp, sizeof tmp, out, strlen(out)); + snprintf(out, outsz, "%s", tmp); + return i > 8; +} + +/* --- base64 (for fetching .torrent enclosures and handing bytes to add) --- */ +static char *base64_encode(const unsigned char *in, size_t len) { + static const char tbl[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + char *out = malloc((len + 2) / 3 * 4 + 1); + if (!out) return NULL; + size_t o = 0; + for (size_t i = 0; i < len; i += 3) { + unsigned v = in[i] << 16; + if (i + 1 < len) v |= in[i+1] << 8; + if (i + 2 < len) v |= in[i+2]; + out[o++] = tbl[(v >> 18) & 63]; + out[o++] = tbl[(v >> 12) & 63]; + out[o++] = (i + 1 < len) ? tbl[(v >> 6) & 63] : '='; + out[o++] = (i + 2 < len) ? tbl[v & 63] : '='; + } + out[o] = 0; + return out; +} + +/* --- RSS persistence (via the daemon blob store) -------------------------- */ + +static void rss_save(void) { + 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()); + 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); +} + +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; + 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); } + } + pthread_mutex_unlock(&g_webui.rss_lock); + if (reply) json_decref(reply); +} + +/* --- auto-download: hand a matched article to the daemon ------------------ */ + +/* Add a torrent from a magnet, or by fetching a .torrent enclosure URL and + * uploading its bytes. Applies category/save path/paused, mirrors the label. */ +static bool rss_download(const char *magnet, const char *torrent_url, + const char *category, const char *save_path, + bool paused) { + json_t *params = json_object(); + if (!params) return false; + json_object_set_new(params, "output", + json_string(save_path && *save_path ? save_path : ".")); + if (paused) json_object_set_new(params, "paused", json_true()); + if (category && *category) json_object_set_new(params, "category", json_string(category)); + + char *fetched = NULL; + if (magnet && *magnet) { + json_object_set_new(params, "source", json_string(magnet)); + } else if (torrent_url && *torrent_url) { + naut_http_response r; + if (naut_http_get(torrent_url, &r) != NAUT_OK || r.status / 100 != 2) { + naut_http_response_free(&r); + json_decref(params); + return false; + } + fetched = base64_encode((const unsigned char *)r.body, r.body_len); + naut_http_response_free(&r); + if (!fetched) { json_decref(params); return false; } + json_object_set_new(params, "data", json_string(fetched)); + } else { + json_decref(params); + return false; + } + naut_err err = NAUT_OK; + json_t *result = rpc_call_json_err("add_torrent", params, &err); + json_decref(params); + free(fetched); + if (!result) return false; + uint64_t id = json_u64(result, "torrent_id"); + if (id && category && *category) store_set_category(id, category); + json_decref(result); + publish_snapshot(); + return true; +} + +/* Does `article` satisfy `rule`? Substring or POSIX regex on the title. */ +static bool rule_matches(json_t *rule, const char *feed_name, const char *title) { + if (!json_boolean_value(json_object_get(rule, "enabled"))) return false; + /* affectedFeeds: empty array means "all feeds". */ + json_t *feeds = json_object_get(rule, "affectedFeeds"); + if (json_is_array(feeds) && json_array_size(feeds) > 0) { + bool listed = false; size_t i; json_t *v; + json_array_foreach(feeds, i, v) + if (strcmp(json_string_value(v) ? json_string_value(v) : "", feed_name) == 0) { listed = true; break; } + if (!listed) return false; + } + const char *must = json_string_or(rule, "mustContain", ""); + const char *mustnot = json_string_or(rule, "mustNotContain", ""); + bool regex = json_boolean_value(json_object_get(rule, "useRegex")); + if (regex) { + if (*must) { + regex_t re; + if (regcomp(&re, must, REG_EXTENDED | REG_ICASE | REG_NOSUB) != 0) return false; + int m = regexec(&re, title, 0, NULL, 0); + regfree(&re); + if (m != 0) return false; + } + if (*mustnot) { + regex_t re; + if (regcomp(&re, mustnot, REG_EXTENDED | REG_ICASE | REG_NOSUB) == 0) { + int m = regexec(&re, title, 0, NULL, 0); + regfree(&re); + if (m == 0) return false; + } + } + } else { + if (*must && !strcasestr(title, must)) return false; + if (*mustnot && strcasestr(title, mustnot)) return false; + } + return true; +} + +/* Run every rule against a freshly-seen article; download the first match. */ +static void rss_run_rules(const char *feed_name, const char *title, + const char *magnet, const char *torrent_url) { + size_t i; json_t *rule; + json_t *fire = NULL; char cat[128] = {0}, path[1024] = {0}; bool paused = false; + pthread_mutex_lock(&g_webui.rss_lock); + json_array_foreach(g_webui.rss_rules, i, rule) { + if (rule_matches(rule, feed_name, title)) { + snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", "")); + snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", "")); + paused = json_boolean_value(json_object_get(rule, "addPaused")); + json_object_set_new(rule, "lastMatch", json_integer((json_int_t)time(NULL))); + fire = rule; + break; + } + } + pthread_mutex_unlock(&g_webui.rss_lock); + if (!fire) return; + if (rss_download(magnet, torrent_url, cat, path, paused)) + log_msg(2, "rss: auto-downloaded a match"); +} + +/* Parse a feed body into article objects and merge new ones into `feed`. + * Newly-seen articles are appended to `out_new` (as {title,magnet,torrentUrl}) + * so the caller can fire auto-download rules AFTER releasing rss_lock — running + * them here would re-enter the lock (and do network I/O while holding it). + * Returns the number of newly-seen articles. */ +static int rss_ingest(json_t *feed, const char *xml, size_t len, json_t *out_new) { + json_t *articles = json_object_get(feed, "articles"); + if (!json_is_array(articles)) { + articles = json_array(); + json_object_set_new(feed, "articles", articles); + } + int added = 0; + const char *p = xml, *end = xml + len; + for (;;) { + const char *open = memmem(p, (size_t)(end - p), " RSS_MAX_ARTICLES) + json_array_remove(articles, json_array_size(articles) - 1); + json_object_set_new(feed, "lastUpdate", json_integer((json_int_t)time(NULL))); + return added; +} + +/* Poll one feed (network I/O done without rss_lock held). */ +static void rss_poll_feed_by_index(size_t idx) { + pthread_mutex_lock(&g_webui.rss_lock); + json_t *feed = json_array_get(g_webui.rss_feeds, idx); + char url[1024] = {0}; + if (feed) snprintf(url, sizeof url, "%s", json_string_or(feed, "url", "")); + pthread_mutex_unlock(&g_webui.rss_lock); + if (!url[0]) return; + + naut_http_response r; + if (naut_http_get(url, &r) != NAUT_OK || r.status / 100 != 2 || !r.body) { + naut_http_response_free(&r); + return; + } + char feed_name[256] = {0}; + json_t *new_articles = json_array(); + pthread_mutex_lock(&g_webui.rss_lock); + feed = json_array_get(g_webui.rss_feeds, idx); /* re-fetch under lock */ + int added = feed ? rss_ingest(feed, r.body, r.body_len, new_articles) : 0; + if (feed) snprintf(feed_name, sizeof feed_name, "%s", json_string_or(feed, "name", "")); + pthread_mutex_unlock(&g_webui.rss_lock); + naut_http_response_free(&r); + + /* fire auto-download rules now that rss_lock is released */ + size_t i; json_t *a; + json_array_foreach(new_articles, i, a) + rss_run_rules(feed_name, json_string_or(a, "title", ""), + json_string_or(a, "magnet", ""), + json_string_or(a, "torrentUrl", "")); + json_decref(new_articles); + if (added > 0) rss_save(); +} + +static void rss_poll_all(void) { + pthread_mutex_lock(&g_webui.rss_lock); + size_t n = json_array_size(g_webui.rss_feeds); + pthread_mutex_unlock(&g_webui.rss_lock); + for (size_t i = 0; i < n && !atomic_load(&g_webui.stopping); i++) + rss_poll_feed_by_index(i); +} + +static void *rss_thread_fn(void *arg) { + (void)arg; + rss_load(); + while (!atomic_load(&g_webui.stopping)) { + rss_poll_all(); + pthread_mutex_lock(&g_webui.rss_lock); + g_webui.rss_wake = false; + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + ts.tv_sec += RSS_POLL_INTERVAL_SEC; + while (!atomic_load(&g_webui.stopping) && !g_webui.rss_wake) + if (pthread_cond_timedwait(&g_webui.rss_cond, &g_webui.rss_lock, &ts) == ETIMEDOUT) + break; + pthread_mutex_unlock(&g_webui.rss_lock); + } + return NULL; +} + +/* --- RSS HTTP API --------------------------------------------------------- */ + +/* GET /api/rss → array of feeds (with their articles). */ +static void api_rss_list(int fd) { + pthread_mutex_lock(&g_webui.rss_lock); + json_t *reply = json_deep_copy(g_webui.rss_feeds); + pthread_mutex_unlock(&g_webui.rss_lock); + http_json(fd, 200, reply ? reply : json_array()); + json_decref(reply); +} + +/* POST /api/rss {name,url} adds a feed; POST /api/rss/delete {name} removes. */ +static void api_rss_feed(int fd, const char *body, size_t len, bool remove) { + json_t *req = read_body_json(body, len); + const char *name = json_string_value(json_object_get(req, "name")); + const char *url = json_string_value(json_object_get(req, "url")); + bool changed = false; + pthread_mutex_lock(&g_webui.rss_lock); + if (remove && name) { + size_t i; json_t *f; + json_array_foreach(g_webui.rss_feeds, i, f) + if (strcmp(json_string_or(f, "name", ""), name) == 0) { + json_array_remove(g_webui.rss_feeds, i); changed = true; break; + } + } else if (name && *name && url && *url) { + /* upsert by name */ + size_t i; json_t *f; bool found = false; + json_array_foreach(g_webui.rss_feeds, i, f) + if (strcmp(json_string_or(f, "name", ""), name) == 0) { + json_object_set_new(f, "url", json_string(url)); found = true; break; + } + if (!found) + json_array_append_new(g_webui.rss_feeds, json_pack( + "{s:s,s:s,s:i,s:[]}", "name", name, "url", url, + "lastUpdate", 0, "articles")); + changed = true; + } + pthread_mutex_unlock(&g_webui.rss_lock); + json_decref(req); + if (changed) { + rss_save(); + pthread_mutex_lock(&g_webui.rss_lock); + g_webui.rss_wake = true; + pthread_cond_signal(&g_webui.rss_cond); /* re-poll the new feed now */ + pthread_mutex_unlock(&g_webui.rss_lock); + } + api_rss_list(fd); +} + +/* GET /api/rss/rules → array of rules. */ +static void api_rss_rules_list(int fd) { + pthread_mutex_lock(&g_webui.rss_lock); + json_t *reply = json_deep_copy(g_webui.rss_rules); + pthread_mutex_unlock(&g_webui.rss_lock); + http_json(fd, 200, reply ? reply : json_array()); + json_decref(reply); +} + +/* POST /api/rss/rules upserts a rule; POST /api/rss/rules/delete removes one. */ +static void api_rss_rule(int fd, const char *body, size_t len, bool remove) { + json_t *req = read_body_json(body, len); + const char *name = json_string_value(json_object_get(req, "name")); + bool changed = false; + pthread_mutex_lock(&g_webui.rss_lock); + if (name && *name) { + size_t i; json_t *r; int at = -1; + json_array_foreach(g_webui.rss_rules, i, r) + if (strcmp(json_string_or(r, "name", ""), name) == 0) { at = (int)i; break; } + if (remove) { + if (at >= 0) { json_array_remove(g_webui.rss_rules, (size_t)at); changed = true; } + } else { + json_t *rule = json_pack("{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:O,s:i}", + "name", name, + "enabled", json_boolean_value(json_object_get(req, "enabled")), + "useRegex", json_boolean_value(json_object_get(req, "useRegex")), + "addPaused", json_boolean_value(json_object_get(req, "addPaused")), + "mustContain", json_string_or(req, "mustContain", ""), + "mustNotContain", json_string_or(req, "mustNotContain", ""), + "assignedCategory", json_string_or(req, "assignedCategory", ""), + "savePath", json_string_or(req, "savePath", ""), + "affectedFeeds", json_is_array(json_object_get(req, "affectedFeeds")) + ? json_object_get(req, "affectedFeeds") : json_array(), + "lastMatch", 0); + if (rule) { + if (at >= 0) json_array_set_new(g_webui.rss_rules, (size_t)at, rule); + else json_array_append_new(g_webui.rss_rules, rule); + changed = true; + } + } + } + pthread_mutex_unlock(&g_webui.rss_lock); + json_decref(req); + if (changed) rss_save(); + api_rss_rules_list(fd); +} + +/* POST /api/indexers upserts a Torznab indexer; .../delete removes one. */ +static void api_indexer(int fd, const char *body, size_t len, bool remove) { + json_t *req = read_body_json(body, len); + const char *name = json_string_value(json_object_get(req, "name")); + bool changed = false; + pthread_mutex_lock(&g_webui.rss_lock); + if (name && *name) { + size_t i; json_t *ix; int at = -1; + json_array_foreach(g_webui.indexers, i, ix) + if (strcmp(json_string_or(ix, "name", ""), name) == 0) { at = (int)i; break; } + if (remove) { + if (at >= 0) { json_array_remove(g_webui.indexers, (size_t)at); changed = true; } + } else { + json_t *e = json_pack("{s:s,s:s,s:s,s:b}", "name", name, + "url", json_string_or(req, "url", ""), + "apikey", json_string_or(req, "apikey", ""), + "enabled", json_object_get(req, "enabled") + ? json_boolean_value(json_object_get(req, "enabled")) : true); + if (e) { + if (at >= 0) json_array_set_new(g_webui.indexers, (size_t)at, e); + else json_array_append_new(g_webui.indexers, e); + changed = true; + } + } + } + pthread_mutex_unlock(&g_webui.rss_lock); + json_decref(req); + if (changed) rss_save(); + pthread_mutex_lock(&g_webui.rss_lock); + json_t *reply = json_deep_copy(g_webui.indexers); + pthread_mutex_unlock(&g_webui.rss_lock); + http_json(fd, 200, reply ? reply : json_array()); + json_decref(reply); +} + +/* ----------------------------- Torznab search ----------------------------- */ + +/* Parse Torznab/newznab XML results into the UI's row schema. */ +static json_t *torznab_parse(const char *xml, size_t len, const char *engine) { + json_t *rows = json_array(); + const char *p = xml, *end = xml + len; + for (;;) { + const char *open = memmem(p, (size_t)(end - p), "", 7); + if (!close) break; + size_t ilen = (size_t)(close - open); + char title[512] = {0}, magnet[2048] = {0}, enclosure[1024] = {0}; + char pub[128] = {0}, lenstr[64] = {0}; + xml_tag_text(open, ilen, "title", title, sizeof title); + xml_tag_text(open, ilen, "pubDate", pub, sizeof pub); + find_magnet(open, ilen, magnet, sizeof magnet); + xml_attr(open, ilen, "enclosure", "url", enclosure, sizeof enclosure); + if (!xml_attr(open, ilen, "enclosure", "length", lenstr, sizeof lenstr)) + xml_tag_text(open, ilen, "size", lenstr, sizeof lenstr); + /* Torznab seeders/peers live in */ + long seeds = 0, leech = 0; + const char *ap = open; + while (ap < close) { + const char *attr = memmem(ap, (size_t)(close - ap), "name=\"", 6); + if (!attr) break; + char an[32] = {0}, av[32] = {0}; + const char *aq = attr + 6; + const char *aqe = memchr(aq, '"', (size_t)(close - aq)); + if (!aqe) break; + snprintf(an, sizeof an, "%.*s", (int)(aqe - aq) < 31 ? (int)(aqe - aq) : 31, aq); + const char *valk = memmem(aqe, (size_t)(close - aqe), "value=\"", 7); + const char *gt = (const char *)memchr(aqe, '>', (size_t)(close - aqe)); + if (valk && (!gt || valk < gt)) { + const char *vs = valk + 7; + const char *ve = memchr(vs, '"', (size_t)(close - vs)); + if (ve) snprintf(av, sizeof av, "%.*s", (int)(ve - vs) < 31 ? (int)(ve - vs) : 31, vs); + } + if (strcmp(an, "seeders") == 0) seeds = strtol(av, NULL, 10); + else if (strcmp(an, "peers") == 0 || strcmp(an, "leechers") == 0) leech = strtol(av, NULL, 10); + ap = aqe + 1; + } + if (title[0]) { + json_array_append_new(rows, json_pack( + "{s:s,s:I,s:i,s:i,s:s,s:s,s:s,s:s}", + "name", title, "size", (json_int_t)strtoll(lenstr, NULL, 10), + "seeds", (int)seeds, "leeches", (int)leech, + "engine", engine, "pubDate", pub, + "magnet", magnet, "torrentUrl", enclosure)); + } + p = close + 7; + } + return rows; +} + +/* GET /api/search?q=… queries every enabled Torznab indexer and merges rows. */ +static void api_search(int fd, const char *query) { + json_t *results = json_array(); + /* snapshot the indexer list under the lock */ + pthread_mutex_lock(&g_webui.rss_lock); + json_t *indexers = json_deep_copy(g_webui.indexers); + pthread_mutex_unlock(&g_webui.rss_lock); + + size_t i; json_t *ix; + json_array_foreach(indexers, i, ix) { + if (!json_boolean_value(json_object_get(ix, "enabled"))) continue; + const char *base = json_string_or(ix, "url", ""); + const char *key = json_string_or(ix, "apikey", ""); + const char *engine = json_string_or(ix, "name", "indexer"); + if (!*base) continue; + char url[2048]; + snprintf(url, sizeof url, "%s%st=search&q=%s%s%s", + base, strchr(base, '?') ? "&" : "?", + query ? query : "", + *key ? "&apikey=" : "", key); + naut_http_response r; + if (naut_http_get(url, &r) == NAUT_OK && r.status / 100 == 2 && r.body) { + json_t *rows = torznab_parse(r.body, r.body_len, engine); + size_t j; json_t *row; + json_array_foreach(rows, j, row) json_array_append(results, row); + json_decref(rows); + } + naut_http_response_free(&r); + } + json_decref(indexers); + http_json(fd, 200, results); + json_decref(results); +} + /* POST /api/categories and /api/categories/delete */ static void api_categories(int fd, const char *body, size_t len, bool remove) { json_t *req = read_body_json(body, len); @@ -1847,6 +2518,10 @@ static void serve_cached_torrents(int fd) { static void handle_api(int fd, const char *method, char *path, const char *headers, const char *headers_end, const char *body, size_t body_len) { + /* Snapshot the query string before strip_query() truncates it. */ + char query_str[1024] = {0}; + const char *qmark = strchr(path, '?'); + if (qmark) snprintf(query_str, sizeof query_str, "%s", qmark + 1); strip_query(path); if (strcmp(path, "/api/auth/status") == 0 && strcmp(method, "GET") == 0) { json_t *json = json_pack("{s:b,s:s,s:b}", @@ -1942,14 +2617,35 @@ static void handle_api(int fd, const char *method, char *path, api_delete(fd, body, body_len); } else if (strcmp(path, "/api/action") == 0 && strcmp(method, "POST") == 0) { api_action(fd, body, body_len); - } else if (strncmp(path, "/api/rss", 8) == 0 && strcmp(method, "GET") == 0) { - json_t *json = json_array(); - http_json(fd, 200, json); - json_decref(json); - } else if (strncmp(path, "/api/search", 11) == 0 && strcmp(method, "GET") == 0) { - json_t *json = json_array(); - http_json(fd, 200, json); - json_decref(json); + } else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "GET") == 0) { + api_rss_list(fd); + } else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "POST") == 0) { + api_rss_feed(fd, body, body_len, false); + } else if (strcmp(path, "/api/rss/delete") == 0 && strcmp(method, "POST") == 0) { + api_rss_feed(fd, body, body_len, true); + } else if (strcmp(path, "/api/rss/rules") == 0 && strcmp(method, "GET") == 0) { + api_rss_rules_list(fd); + } else if (strcmp(path, "/api/rss/rules") == 0 && strcmp(method, "POST") == 0) { + api_rss_rule(fd, body, body_len, false); + } else if (strcmp(path, "/api/rss/rules/delete") == 0 && strcmp(method, "POST") == 0) { + api_rss_rule(fd, body, body_len, true); + } else if (strcmp(path, "/api/indexers") == 0 && strcmp(method, "POST") == 0) { + api_indexer(fd, body, body_len, false); + } else if (strcmp(path, "/api/indexers/delete") == 0 && strcmp(method, "POST") == 0) { + api_indexer(fd, body, body_len, true); + } else if (strcmp(path, "/api/search") == 0 && strcmp(method, "GET") == 0) { + char q[512] = {0}; + query_get(query_str, "q", q, sizeof q); + /* re-encode spaces for the upstream query (decode happened above) */ + char enc[1024]; size_t eo = 0; + for (size_t i = 0; q[i] && eo + 4 < sizeof enc; i++) { + unsigned char c = (unsigned char)q[i]; + if ((c >= 'a'&&c<='z')||(c>='A'&&c<='Z')||(c>='0'&&c<='9')|| + c=='-'||c=='_'||c=='.'||c=='~') enc[eo++] = (char)c; + else eo += (size_t)snprintf(enc + eo, sizeof enc - eo, "%%%02X", c); + } + enc[eo] = 0; + api_search(fd, enc); } else { http_text(fd, 404, "Not Found", "not found"); } @@ -2201,6 +2897,11 @@ naut_err naut_plugin_register(const naut_host_api *host) { g_webui.assignments = json_object(); if (!g_webui.categories || !g_webui.tags || !g_webui.assignments) goto fail_store; + pthread_mutex_init(&g_webui.rss_lock, NULL); + pthread_cond_init(&g_webui.rss_cond, NULL); + g_webui.rss_feeds = json_array(); + g_webui.rss_rules = json_array(); + g_webui.indexers = json_array(); init_auth(); error = g_webui.host.set_plugin_name(g_webui.host.host_context, "webui"); @@ -2208,6 +2909,9 @@ naut_err naut_plugin_register(const naut_host_api *host) { error = start_server(); if (error != NAUT_OK) goto fail_store; webui_load_taxonomy(); /* restore category + tag lists from the daemon */ + /* RSS poller: loads feeds/rules from the blob store and polls in the bg. */ + if (pthread_create(&g_webui.rss_thread, NULL, rss_thread_fn, NULL) == 0) + g_webui.rss_thread_started = true; return NAUT_OK; fail_store: @@ -2247,6 +2951,13 @@ naut_err naut_plugin_shutdown(void) { if (g_webui.sampler_started) pthread_join(g_webui.sampler, NULL); g_webui.sampler_started = false; + if (g_webui.rss_thread_started) { + pthread_mutex_lock(&g_webui.rss_lock); + pthread_cond_signal(&g_webui.rss_cond); /* wake the poller to exit */ + pthread_mutex_unlock(&g_webui.rss_lock); + pthread_join(g_webui.rss_thread, NULL); + g_webui.rss_thread_started = false; + } pthread_mutex_lock(&g_webui.conn_lock); while (g_webui.active_connections > 0) pthread_cond_wait(&g_webui.conn_cond, &g_webui.conn_lock); @@ -2264,6 +2975,15 @@ naut_err naut_plugin_shutdown(void) { g_webui.tags = NULL; g_webui.assignments = NULL; + json_decref(g_webui.rss_feeds); + json_decref(g_webui.rss_rules); + json_decref(g_webui.indexers); + g_webui.rss_feeds = NULL; + g_webui.rss_rules = NULL; + g_webui.indexers = NULL; + pthread_cond_destroy(&g_webui.rss_cond); + pthread_mutex_destroy(&g_webui.rss_lock); + pthread_mutex_destroy(&g_webui.meta_lock); pthread_mutex_destroy(&g_webui.speed_lock); pthread_cond_destroy(&g_webui.snap_cond);