diff --git a/CMakeLists.txt b/CMakeLists.txt index a1185e0..86abe96 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -220,14 +220,9 @@ add_library(naut_example MODULE plugins/example/example.c) target_include_directories(naut_example PRIVATE ${CMAKE_SOURCE_DIR}/include) set_target_properties(naut_example PROPERTIES PREFIX "") -# SQLite backs the webui account store. -find_package(PkgConfig REQUIRED) -pkg_check_modules(SQLITE3 REQUIRED IMPORTED_TARGET sqlite3) - -add_library(naut_webui MODULE plugins/webui/webui.c plugins/webui/webui_store.c) +add_library(naut_webui MODULE plugins/webui/webui.c) target_include_directories(naut_webui PRIVATE ${CMAKE_SOURCE_DIR}/include) -target_link_libraries(naut_webui PRIVATE ${NAUT_JANSSON_TARGET} naut_net - PkgConfig::SQLITE3 OpenSSL::Crypto pthread) +target_link_libraries(naut_webui PRIVATE ${NAUT_JANSSON_TARGET} naut_net pthread) set_target_properties(naut_webui PROPERTIES PREFIX "") # --- swarm: multi-peer download driver over the torrent-peer engine --------- diff --git a/ISSUES.md b/ISSUES.md index 4f4d7c0..e2caed3 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -14,5 +14,4 @@ - ✅ RSS should have a manual repull - ✅ I should be able to force re-run a rule for cases where it was modified. - ✅ RSS manual download button doesn't work — there's no + next to articles (feeds whose items only carry a /Atom href had no source). -- ✅ Rules should show their current matches. -- ✅ We need a real login system backed by a database. \ No newline at end of file +- ✅ Rules should show their current matches. \ No newline at end of file diff --git a/apps/nautd/main.c b/apps/nautd/main.c index f313421..0199ff4 100644 --- a/apps/nautd/main.c +++ b/apps/nautd/main.c @@ -114,6 +114,15 @@ 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 */ + /* Label taxonomy: the web UI's full category + tag lists (including ones + * created but not yet assigned). Web-layer schema; the daemon just persists + * it so they survive restarts. */ + pthread_mutex_t taxonomy_lock; + 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; @@ -1311,6 +1320,153 @@ static json_t *rpc_set_script_settings(void *opaque, const json_t *params, return script_status_json(state); } +/* --- label taxonomy (web-layer category + tag lists, persisted here) ------- */ + +static void persist_taxonomy(daemon_state *state) { + if (!state->persist_enabled) return; + pthread_mutex_lock(&state->taxonomy_lock); + json_t *cats = state->label_categories + ? json_deep_copy(state->label_categories) : json_array(); + json_t *tags = state->label_tags + ? json_deep_copy(state->label_tags) : json_array(); + pthread_mutex_unlock(&state->taxonomy_lock); + json_t *doc = json_object(); + if (!doc) { json_decref(cats); json_decref(tags); return; } + json_object_set_new(doc, "categories", cats ? cats : json_array()); + json_object_set_new(doc, "tags", tags ? tags : json_array()); + char tmp[PATH_MAX + 8]; + snprintf(tmp, sizeof tmp, "%s.tmp", state->taxonomy_file); + if (json_dump_file(doc, tmp, JSON_INDENT(2)) != 0 || + rename(tmp, state->taxonomy_file) != 0) { + NAUT_WARN("persist: write %s failed", state->taxonomy_file); + unlink(tmp); + } + json_decref(doc); +} + +static void load_taxonomy(daemon_state *state) { + if (!state->persist_enabled) return; + json_error_t jerr; + json_t *doc = json_load_file(state->taxonomy_file, 0, &jerr); + if (!doc) return; + json_t *cats = json_object_get(doc, "categories"); + json_t *tags = json_object_get(doc, "tags"); + pthread_mutex_lock(&state->taxonomy_lock); + if (json_is_array(cats)) { + json_decref(state->label_categories); + state->label_categories = json_deep_copy(cats); + } + if (json_is_array(tags)) { + json_decref(state->label_tags); + state->label_tags = json_deep_copy(tags); + } + pthread_mutex_unlock(&state->taxonomy_lock); + json_decref(doc); +} + +static json_t *rpc_get_label_taxonomy(void *opaque, const json_t *params, + naut_err *error) { + (void)params; + daemon_state *state = opaque; + pthread_mutex_lock(&state->taxonomy_lock); + json_t *cats = state->label_categories + ? json_deep_copy(state->label_categories) : json_array(); + json_t *tags = state->label_tags + ? json_deep_copy(state->label_tags) : json_array(); + pthread_mutex_unlock(&state->taxonomy_lock); + json_t *out = json_object(); + if (!out) { json_decref(cats); json_decref(tags); *error = NAUT_ERR_NOMEM; return NULL; } + json_object_set_new(out, "categories", cats ? cats : json_array()); + json_object_set_new(out, "tags", tags ? tags : json_array()); + *error = NAUT_OK; + return out; +} + +static json_t *rpc_set_label_taxonomy(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + if (!json_is_object(params)) { *error = NAUT_ERR_INVAL; return NULL; } + json_t *cats = json_object_get(params, "categories"); + json_t *tags = json_object_get(params, "tags"); + pthread_mutex_lock(&state->taxonomy_lock); + if (json_is_array(cats)) { + json_decref(state->label_categories); + state->label_categories = json_deep_copy(cats); + } + if (json_is_array(tags)) { + json_decref(state->label_tags); + state->label_tags = json_deep_copy(tags); + } + pthread_mutex_unlock(&state->taxonomy_lock); + persist_taxonomy(state); + *error = NAUT_OK; + 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) { @@ -2237,6 +2393,10 @@ 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_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; } @@ -2525,6 +2685,12 @@ 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->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; } @@ -2630,6 +2796,9 @@ 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.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); @@ -2712,5 +2881,9 @@ int main(int argc, char **argv) { json_decref(state.script_settings); json_decref(state.script_settings_schema); pthread_mutex_destroy(&state.settings_lock); + 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/apps/swarm/main.c b/apps/swarm/main.c index da0eea6..755320f 100644 --- a/apps/swarm/main.c +++ b/apps/swarm/main.c @@ -58,10 +58,6 @@ static uint32_t target_peer_count(void) { return (uint32_t)NAUT_MIN(value, MAX_TARGET_PEERS); } -/* Below this many connected peers the swarm is "starved": re-announce as often - * as the tracker's min interval allows instead of waiting the full interval. */ -#define LOW_PEER_THRESHOLD 10 - static double tracker_delay_seconds(int32_t interval) { if (interval <= 0) return TRACKER_DEFAULT_INTERVAL; if (interval < (int32_t)TRACKER_MIN_INTERVAL) @@ -69,20 +65,6 @@ static double tracker_delay_seconds(int32_t interval) { return (double)interval; } -/* Seconds to wait before the next tracker announce. Normally the tracker's full - * advertised interval, but when the swarm is starved (< LOW_PEER_THRESHOLD - * connected peers) we re-announce sooner — down to the tracker's min_interval, - * never below our 60s floor — so a thin swarm can actually recover. */ -static double next_announce_delay(int32_t interval, int32_t min_interval, - uint32_t peers_connected) { - double full = interval > 0 ? tracker_delay_seconds(interval) - : TRACKER_FAILURE_RETRY_INTERVAL; - if (peers_connected >= LOW_PEER_THRESHOLD) return full; - double floor_s = min_interval > 0 ? (double)min_interval : TRACKER_MIN_INTERVAL; - if (floor_s < TRACKER_MIN_INTERVAL) floor_s = TRACKER_MIN_INTERVAL; - return floor_s < full ? floor_s : full; -} - static void random_bytes(uint8_t *output, size_t length) { int fd = open("/dev/urandom", O_RDONLY); size_t offset = 0; @@ -403,7 +385,6 @@ static bool discover_trackers(const uint8_t info_hash[20], uint64_t total_length naut_tracker_event event, endpoint_t **eps, size_t *neps, size_t *cap, int32_t *announce_interval, - int32_t *announce_min_interval, naut_swarm_tracker_stats *tracker_stats, uint32_t tracker_count) { naut_announce_req req; @@ -418,7 +399,6 @@ static bool discover_trackers(const uint8_t info_hash[20], uint64_t total_length req.numwant = 100; memcpy(&req.key, peerid + 8, sizeof req.key); if (announce_interval) *announce_interval = 0; - if (announce_min_interval) *announce_min_interval = 0; size_t tier_start = 0; while (tier_start < num_trackers) { @@ -471,8 +451,6 @@ static bool discover_trackers(const uint8_t info_hash[20], uint64_t total_length tier_succeeded = true; if (announce_interval && response.interval > 0) *announce_interval = response.interval; - if (announce_min_interval && response.min_interval > 0) - *announce_min_interval = response.min_interval; if (tracker_stat) { tracker_set_status(tracker_stat, "working", ""); tracker_stat->seeds = response.seeders; @@ -596,7 +574,7 @@ naut_err naut_swarm_run(const naut_swarm_config *config) { endpoint_t *endpoints = NULL; size_t neps = 0, epcap = 0; uint32_t target_peers = target_peer_count(); - int32_t tracker_interval = 0, tracker_min_interval = 0; + int32_t tracker_interval = 0; naut_swarm_tracker_stats tracker_stats[NAUT_SWARM_MAX_TRACKER_STATS]; uint32_t tracker_count = 0; if (config->num_peers > 0) { @@ -630,8 +608,8 @@ naut_err naut_swarm_run(const naut_swarm_config *config) { if (!discover_trackers(hash, 0, trackers, num_trackers, tracker_tiers, peerid, 0, 0, NAUT_TEV_STARTED, &endpoints, &neps, &epcap, - &tracker_interval, &tracker_min_interval, - tracker_stats, tracker_count) || + &tracker_interval, tracker_stats, + tracker_count) || (neps < target_peers && !discover_dht(hash, &endpoints, &neps, &epcap))) { NAUT_ERROR("out of memory collecting discovered peers"); @@ -753,8 +731,8 @@ naut_err naut_swarm_run(const naut_swarm_config *config) { mi.tracker_tiers, peerid, resumed_bytes, resumed_left, NAUT_TEV_STARTED, &endpoints, &neps, &epcap, - &tracker_interval, &tracker_min_interval, - tracker_stats, tracker_count) || + &tracker_interval, tracker_stats, + tracker_count) || (neps < target_peers && !discover_dht(mi.infohash_v1, &endpoints, &neps, &epcap))) { NAUT_ERROR("out of memory collecting discovered peers"); @@ -809,11 +787,10 @@ naut_err naut_swarm_run(const naut_swarm_config *config) { feed_engine(eng, torrent_id, endpoints, neps, &fed); double t0 = now(); - /* Use the discovered endpoint count as the initial peer proxy: if we start - * thin, schedule a quick re-announce instead of waiting the full interval. */ double next_tracker_announce = - t0 + next_announce_delay(tracker_interval, tracker_min_interval, - (uint32_t)neps); + t0 + (tracker_interval > 0 + ? tracker_delay_seconds(tracker_interval) + : TRACKER_FAILURE_RETRY_INTERVAL); double next_dht_lookup = t0 + DHT_REFRESH_INTERVAL; naut_err run_error = NAUT_OK; bool cancelled = false; @@ -850,20 +827,21 @@ naut_err naut_swarm_run(const naut_swarm_config *config) { uint64_t downloaded = naut_download_bytes_done(d); uint64_t left = (uint64_t)mi.total_length > downloaded ? (uint64_t)mi.total_length - downloaded : 0; - int32_t interval = 0, min_interval = 0; + int32_t interval = 0; if (!discover_trackers(mi.infohash_v1, (uint64_t)mi.total_length, mi.trackers, mi.num_trackers, mi.tracker_tiers, peerid, downloaded, left, NAUT_TEV_NONE, &endpoints, &neps, &epcap, - &interval, &min_interval, - tracker_stats, tracker_count)) { + &interval, tracker_stats, + tracker_count)) { run_error = NAUT_ERR_NOMEM; break; } - next_tracker_announce = t + next_announce_delay( - interval, min_interval, ts.peers_connected); + next_tracker_announce = t + (interval > 0 + ? tracker_delay_seconds(interval) + : TRACKER_FAILURE_RETRY_INTERVAL); feed_engine(eng, torrent_id, endpoints, neps, &fed); } if (t >= next_dht_lookup) { diff --git a/include/naut/tracker.h b/include/naut/tracker.h index 76ed8bb..ea16ee9 100644 --- a/include/naut/tracker.h +++ b/include/naut/tracker.h @@ -29,7 +29,6 @@ typedef struct { typedef struct { int32_t interval; - int32_t min_interval; /* tracker's floor, 0 if not advertised */ int32_t seeders, leechers; /* -1 if absent */ naut_peer_addr *peers; size_t num_peers; diff --git a/plugins/webui/webui.c b/plugins/webui/webui.c index b3af7e0..b83fb98 100644 --- a/plugins/webui/webui.c +++ b/plugins/webui/webui.c @@ -1,6 +1,5 @@ #include "naut/naut_plugin.h" #include "naut/http_client.h" -#include "webui_store.h" #include @@ -29,11 +28,18 @@ #define DEFAULT_PORT 8080 #define READ_LIMIT (8u << 20) #define SESSION_COOKIE "naut_session" -#define SESSION_TTL_SECONDS (60 * 60 * 24 * 7) /* default; NAUT_SESSION_TTL */ +#define SESSION_TTL_SECONDS (60 * 60 * 24 * 7) +#define MAX_SESSIONS 64 #define MAX_CONNECTIONS 128 #define SPEED_SLOTS 256 #define ETA_INFINITY 8640000 /* torrent-ui renders >= this as the infinity glyph */ +typedef struct { + char token[96]; + time_t expires; + bool used; +} webui_session; + /* Single-writer (sampler thread) running estimate of a torrent's download * rate, derived from successive byte counts. */ typedef struct { @@ -48,9 +54,8 @@ typedef struct { naut_host_api host; char root[PATH_MAX]; char host_name[64]; - char auth_user[64]; /* bootstrap admin name (for startup banner) */ - char auth_password[64]; /* generated bootstrap password (banner only) */ - webui_store *store; /* SQLite store: accounts, taxonomy, RSS */ + char auth_user[64]; + char auth_password[64]; int port; int listener; bool generated_password; @@ -61,6 +66,8 @@ typedef struct { bool sampler_started; pthread_t sampler; + pthread_mutex_t auth_lock; + pthread_mutex_t conn_lock; pthread_cond_t conn_cond; size_t active_connections; @@ -84,14 +91,18 @@ typedef struct { json_t *tags; /* array of tag name strings */ json_t *assignments; /* object keyed by stringified torrent id */ - /* RSS feeds, articles, auto-download rules and Torznab indexers all live in - * the webui database (g_webui.store). A background thread polls feeds; this - * lock/cond only guards the poller's wake-up, not any data. */ + /* 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; typedef struct { @@ -229,6 +240,21 @@ static bool query_get(const char *query, const char *key, char *out, size_t outs 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) { + if (!a || !b) return false; + size_t la = strlen(a), lb = strlen(b); + size_t n = la > lb ? la : lb; + unsigned diff = (unsigned)(la ^ lb); + for (size_t i = 0; i < n; i++) { + unsigned char ca = i < la ? (unsigned char)a[i] : 0; + unsigned char cb = i < lb ? (unsigned char)b[i] : 0; + diff |= (unsigned)(ca ^ cb); + } + return diff == 0; +} + /* Cryptographically strong hex. Fails closed: if the kernel CSPRNG is * unavailable we refuse rather than fall back to predictable bytes (these * feed session tokens). */ @@ -254,71 +280,26 @@ static bool random_hex(char *out, size_t out_size, size_t bytes) { return true; } -/* mkdir -p for the account DB's parent directory (0700). */ -static int mkdir_p(const char *path, mode_t mode) { - char tmp[PATH_MAX]; - size_t len = snprintf(tmp, sizeof tmp, "%s", path); - if (len == 0 || len >= sizeof tmp) return -1; - for (char *p = tmp + 1; *p; p++) { - if (*p == '/') { - *p = 0; - if (mkdir(tmp, mode) != 0 && errno != EEXIST) return -1; - *p = '/'; - } - } - if (mkdir(tmp, mode) != 0 && errno != EEXIST) return -1; - return 0; -} - -/* Resolve the account database path: NAUT_WEBUI_DB, else an XDG/HOME default - * under naut/. Creates the parent directory. */ -static bool resolve_auth_db_path(char *out, size_t n) { - const char *env = getenv("NAUT_WEBUI_DB"); - if (env && *env) return (size_t)snprintf(out, n, "%s", env) < n; - const char *xdg = getenv("XDG_DATA_HOME"); - const char *home = getenv("HOME"); - char dir[PATH_MAX]; - if (xdg && *xdg) snprintf(dir, sizeof dir, "%s/naut", xdg); - else if (home && *home) snprintf(dir, sizeof dir, "%s/.local/share/naut", home); - else return false; - if (mkdir_p(dir, 0700) != 0) return false; - return (size_t)snprintf(out, n, "%s/webui.db", dir) < n; -} - -/* Open the account store and, on first run (no accounts), bootstrap an admin - * from NAUT_AUTH_USER/PASSWORD or a generated password (logged once). */ static void init_auth(void) { - char db_path[PATH_MAX]; - if (!resolve_auth_db_path(db_path, sizeof db_path)) { - log_msg(0, "webui: cannot resolve account DB path; set NAUT_WEBUI_DB"); - return; - } - g_webui.store = webui_store_open(db_path); - if (!g_webui.store) { - log_msg(0, "webui: failed to open account database"); - return; - } const char *user = getenv("NAUT_AUTH_USER"); if (!user || !*user) user = getenv("NAUT_USER"); if (!user || !*user) user = "admin"; snprintf(g_webui.auth_user, sizeof g_webui.auth_user, "%s", user); - if (webui_store_user_count(g_webui.store) > 0) return; /* already set up */ - - /* No accounts yet — create the initial admin. */ const char *password = getenv("NAUT_AUTH_PASSWORD"); if (!password || !*password) password = getenv("NAUT_PASSWORD"); if (password && *password) { + snprintf(g_webui.auth_password, sizeof g_webui.auth_password, "%s", + password); g_webui.generated_password = false; - } else if (random_hex(g_webui.auth_password, sizeof g_webui.auth_password, 9)) { - password = g_webui.auth_password; - g_webui.generated_password = true; - } else { - log_msg(0, "webui: no CSPRNG; set NAUT_AUTH_PASSWORD to create the admin"); return; } - if (!webui_store_create_user(g_webui.store, user, password, "admin")) - log_msg(0, "webui: failed to create the initial admin account"); + if (!random_hex(g_webui.auth_password, sizeof g_webui.auth_password, 9)) { + /* No CSPRNG: leave the password empty so login is impossible rather + * than guessable. The operator must set NAUT_AUTH_PASSWORD. */ + g_webui.auth_password[0] = 0; + } + g_webui.generated_password = true; } static const char *header_value(const char *headers, const char *end, @@ -365,69 +346,58 @@ static bool cookie_token(const char *headers, const char *end, return false; } -/* Look up the session for this request. On a live session, refreshes its TTL - * and (optionally) copies the account's username and role. Returns true if a - * valid session was found. */ -/* Session lifetime in seconds (sliding). Override with NAUT_SESSION_TTL. */ -static long session_ttl(void) { - const char *env = getenv("NAUT_SESSION_TTL"); - if (env && *env) { - char *e = NULL; - long v = strtol(env, &e, 10); - if (e && e != env && !*e && v > 0) return v; - } - return SESSION_TTL_SECONDS; -} - -/* Don't rewrite the session row on every request; only re-extend the sliding - * expiry once it has advanced by more than this. */ -#define SESSION_REFRESH_THRESHOLD 3600 - -static bool current_identity(const char *headers, const char *end, - char *user, size_t user_sz, - char *role, size_t role_sz) { +static bool current_user(const char *headers, const char *end) { char token[96]; if (!cookie_token(headers, end, token, sizeof token)) return false; - if (!g_webui.store) return false; - char u[64] = {0}, r[16] = {0}; - long expires = 0; - if (!webui_store_session_lookup(g_webui.store, token, u, sizeof u, - r, sizeof r, &expires)) - return false; - long now = (long)time(NULL); - if (expires <= now) { /* expired: clean it up */ - webui_store_session_delete(g_webui.store, token); - return false; + bool ok = false; + time_t now = time(NULL); + pthread_mutex_lock(&g_webui.auth_lock); + for (size_t i = 0; i < MAX_SESSIONS; i++) { + webui_session *session = &g_webui.sessions[i]; + if (!session->used || strcmp(session->token, token) != 0) continue; + if (session->expires < now) { + session->used = false; + break; + } + session->expires = now + SESSION_TTL_SECONDS; + ok = true; + break; } - long fresh = now + session_ttl(); /* sliding window, throttled */ - if (fresh - expires > SESSION_REFRESH_THRESHOLD) - webui_store_session_touch(g_webui.store, token, fresh); - if (user) snprintf(user, user_sz, "%s", u); - if (role) snprintf(role, role_sz, "%s", r); - return true; + pthread_mutex_unlock(&g_webui.auth_lock); + return ok; } -static bool create_session(const char *user, const char *role, - char *out, size_t out_size) { +static bool create_session(char *out, size_t out_size) { char token[96]; - if (!g_webui.store || !random_hex(token, sizeof token, 24)) return false; - long expires = (long)time(NULL) + session_ttl(); - if (!webui_store_session_create(g_webui.store, token, user, role, expires)) - return false; + if (!random_hex(token, sizeof token, 24)) return false; + time_t now = time(NULL); + time_t expires = now + SESSION_TTL_SECONDS; + pthread_mutex_lock(&g_webui.auth_lock); + webui_session *slot = NULL; + for (size_t i = 0; i < MAX_SESSIONS; i++) { + webui_session *s = &g_webui.sessions[i]; + if (!s->used || s->expires < now) { slot = s; break; } + /* Otherwise track the session that expires soonest, so a full table + * evicts the oldest rather than always clobbering slot 0. */ + if (!slot || s->expires < slot->expires) slot = s; + } + snprintf(slot->token, sizeof slot->token, "%s", token); + slot->expires = expires; + slot->used = true; + pthread_mutex_unlock(&g_webui.auth_lock); snprintf(out, out_size, "%s", token); return true; } -/* Invalidate every session belonging to `user` (after delete / password reset - * by an admin). */ -static void drop_user_sessions(const char *user) { - if (g_webui.store) webui_store_sessions_delete_user(g_webui.store, user); -} - static void clear_session(const char *headers, const char *end) { char token[96]; - if (g_webui.store && cookie_token(headers, end, token, sizeof token)) - webui_store_session_delete(g_webui.store, token); + if (!cookie_token(headers, end, token, sizeof token)) return; + pthread_mutex_lock(&g_webui.auth_lock); + for (size_t i = 0; i < MAX_SESSIONS; i++) + if (g_webui.sessions[i].used && + strcmp(g_webui.sessions[i].token, token) == 0) + g_webui.sessions[i].used = false; + pthread_mutex_unlock(&g_webui.auth_lock); } static bool bad_static_path(const char *path) { @@ -934,29 +904,39 @@ static void webui_sync_all_labels(void) { /* Persist the full category + tag lists (including unassigned ones) to the * daemon so they survive restarts. */ -/* Persist the category + tag lists to the web-UI's own database. */ static void webui_sync_taxonomy(void) { - if (!g_webui.store) return; pthread_mutex_lock(&g_webui.meta_lock); json_t *cats = json_deep_copy(g_webui.categories); json_t *tags = json_deep_copy(g_webui.tags); pthread_mutex_unlock(&g_webui.meta_lock); - if (cats) { webui_store_save_categories(g_webui.store, cats); json_decref(cats); } - if (tags) { webui_store_save_tags(g_webui.store, tags); json_decref(tags); } + json_t *params = json_pack("{s:o,s:o}", + "categories", cats ? cats : json_array(), + "tags", tags ? tags : json_array()); + if (!params) { json_decref(cats); json_decref(tags); return; } + json_t *reply = rpc_call_json("set_label_taxonomy", params); + json_decref(params); + if (reply) json_decref(reply); } -/* Seed the category + tag lists from the database at startup. */ +/* Seed the category + tag lists from the daemon's persisted copy at startup. */ static void webui_load_taxonomy(void) { - if (!g_webui.store) return; - json_t *cats = json_array(), *tags = json_array(); - bool ok_c = webui_store_load_categories(g_webui.store, cats); - bool ok_t = webui_store_load_tags(g_webui.store, tags); + json_t *params = json_object(); + json_t *reply = rpc_call_json("get_label_taxonomy", params); + json_decref(params); + if (!json_is_object(reply)) { json_decref(reply); return; } + json_t *cats = json_object_get(reply, "categories"); + json_t *tags = json_object_get(reply, "tags"); pthread_mutex_lock(&g_webui.meta_lock); - if (ok_c) { json_decref(g_webui.categories); g_webui.categories = cats; } - else json_decref(cats); - if (ok_t) { json_decref(g_webui.tags); g_webui.tags = tags; } - else json_decref(tags); + if (json_is_array(cats)) { + json_decref(g_webui.categories); + g_webui.categories = json_deep_copy(cats); + } + if (json_is_array(tags)) { + json_decref(g_webui.tags); + g_webui.tags = json_deep_copy(tags); + } pthread_mutex_unlock(&g_webui.meta_lock); + json_decref(reply); } static bool store_get_name(uint64_t id, char *out, size_t out_size) { @@ -1337,7 +1317,15 @@ static void api_meta(int fd) { json_object_set_new(json, "preferences", preferences); /* searchPlugins mirrors the configured Torznab indexers for the Search tab. */ json_t *plugins = json_array(); - if (g_webui.store) webui_store_indexer_list(g_webui.store, plugins); + 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); @@ -1917,22 +1905,52 @@ static char *base64_encode(const unsigned char *in, size_t len) { /* --- 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 *title, const char *magnet, - const char *torrent_url, const char *category, - const char *save_path, bool paused) { +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)); - /* The article title is the real torrent name; without it the daemon falls - * back to the temp upload filename (upload-XXXXXX) for fetched .torrents. */ - if (title && *title) json_object_set_new(params, "name", json_string(title)); char *fetched = NULL; if (magnet && *magnet) { @@ -1958,7 +1976,6 @@ static bool rss_download(const char *title, const char *magnet, free(fetched); if (!result) return false; uint64_t id = json_u64(result, "torrent_id"); - if (id && title && *title) store_set_name(id, title); if (id && category && *category) store_set_category(id, category); json_decref(result); publish_snapshot(); @@ -2002,54 +2019,65 @@ static bool rule_matches(json_t *rule, const char *feed_name, const char *title) return true; } -/* Mark every article with this key as grabbed, so a rule re-run skips it. */ +/* Mark the article with this key as grabbed (across all feeds), so an + * auto-download rule re-run won't fetch it again. */ static void rss_mark_grabbed(const char *key) { - if (g_webui.store) webui_store_article_mark_grabbed(g_webui.store, key); + if (!key || !*key) return; + pthread_mutex_lock(&g_webui.rss_lock); + size_t fi; json_t *feed; + json_array_foreach(g_webui.rss_feeds, fi, feed) { + json_t *articles = json_object_get(feed, "articles"); + size_t ai; json_t *a; + json_array_foreach(articles, ai, a) + if (strcmp(json_string_or(a, "key", ""), key) == 0) + json_object_set_new(a, "grabbed", json_true()); + } + pthread_mutex_unlock(&g_webui.rss_lock); } /* Download an article and, on success, flag it grabbed by key. */ -static bool rss_grab_article(const char *key, const char *title, - const char *magnet, const char *torrent_url, - const char *cat, const char *path, bool paused) { - bool ok = rss_download(title, magnet, torrent_url, cat, path, paused); +static bool rss_grab_article(const char *key, const char *magnet, + const char *torrent_url, const char *cat, + const char *path, bool paused) { + bool ok = rss_download(magnet, torrent_url, cat, path, paused); if (ok) rss_mark_grabbed(key); return ok; } -/* Run the auto-download rules against one freshly-seen article; download the - * first enabled rule that matches. */ +/* Run every rule against a freshly-seen article; download the first match. */ static void rss_run_rules(const char *feed_name, const char *key, const char *title, const char *magnet, const char *torrent_url) { - if (!g_webui.store) return; - json_t *rules = json_array(); - if (!webui_store_rule_list(g_webui.store, rules)) { json_decref(rules); return; } - char cat[128] = {0}, path[1024] = {0}, rule_name[128] = {0}; - bool paused = false, fire = false; size_t i; json_t *rule; - json_array_foreach(rules, i, 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", "")); - snprintf(rule_name, sizeof rule_name, "%s", json_string_or(rule, "name", "")); paused = json_boolean_value(json_object_get(rule, "addPaused")); - fire = true; + json_object_set_new(rule, "lastMatch", json_integer((json_int_t)time(NULL))); + fire = rule; break; } } - json_decref(rules); + pthread_mutex_unlock(&g_webui.rss_lock); if (!fire) return; - webui_store_rule_set_match(g_webui.store, rule_name, (long)time(NULL)); - if (rss_grab_article(key, title, magnet, torrent_url, cat, path, paused)) + if (rss_grab_article(key, magnet, torrent_url, cat, path, paused)) log_msg(2, "rss: auto-downloaded a match"); } -/* Parse a feed body, inserting newly-seen articles into the store. Each new - * article is appended to out_new ({key,title,magnet,torrentUrl}) so the caller - * can fire rules afterward. Returns the number newly inserted. */ -static int rss_ingest(const char *feed_name, const char *xml, size_t len, - json_t *out_new) { - if (!g_webui.store) return 0; +/* 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 (;;) { @@ -2084,65 +2112,80 @@ static int rss_ingest(const char *feed_name, const char *xml, size_t len, const char *key = magnet[0] ? magnet : (enclosure[0] ? enclosure : link); if (title[0] && key && *key) { - json_t *art = json_pack("{s:s,s:s,s:s,s:s,s:s,s:I,s:s}", - "key", key, "title", title, "magnet", magnet, "torrentUrl", dl_url, - "link", link, "size", (json_int_t)strtoll(lenstr, NULL, 10), - "pubDate", pub); - int rc = art ? webui_store_article_add(g_webui.store, feed_name, art) : -1; - json_decref(art); - if (rc == 1) { + /* dedupe against existing articles by their key */ + bool seen = false; size_t ai; json_t *a; + json_array_foreach(articles, ai, a) { + if (strcmp(json_string_or(a, "key", ""), key) == 0) { seen = true; break; } + } + if (!seen) { + json_t *art = json_pack( + "{s:s,s:s,s:s,s:s,s:s,s:I,s:s,s:b,s:b}", + "title", title, "key", key, + "magnet", magnet, "torrentUrl", dl_url, "link", link, + "size", (json_int_t)strtoll(lenstr, NULL, 10), + "pubDate", pub, "isRead", 0, "grabbed", 0); + json_array_insert_new(articles, 0, art); added++; if (out_new) - json_array_append_new(out_new, json_pack("{s:s,s:s,s:s,s:s}", - "key", key, "title", title, "magnet", magnet, - "torrentUrl", dl_url)); + json_array_append_new(out_new, json_pack( + "{s:s,s:s,s:s,s:s}", "title", title, "key", key, + "magnet", magnet, "torrentUrl", dl_url)); } } p = close + strlen(close_tag); } - webui_store_article_trim(g_webui.store, feed_name, RSS_MAX_ARTICLES); - webui_store_feed_set_updated(g_webui.store, feed_name, (long)time(NULL)); + /* trim to the newest RSS_MAX_ARTICLES */ + while (json_array_size(articles) > 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 by name+url (network I/O done without any lock held). */ -static void rss_poll_one(const char *name, const char *url) { - if (!name || !*name || !url || !*url) return; +/* 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(); - rss_ingest(name, r.body, r.body_len, new_articles); + 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 for the newly-seen articles */ + + /* 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(name, json_string_or(a, "key", ""), + rss_run_rules(feed_name, json_string_or(a, "key", ""), 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) { - if (!g_webui.store) return; - json_t *targets = json_array(); - webui_store_feed_targets(g_webui.store, targets); - size_t i; json_t *t; - json_array_foreach(targets, i, t) { - if (atomic_load(&g_webui.stopping)) break; - char name[256], url[1024]; - snprintf(name, sizeof name, "%s", json_string_or(t, "name", "")); - snprintf(url, sizeof url, "%s", json_string_or(t, "url", "")); - rss_poll_one(name, url); - } - json_decref(targets); + 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); @@ -2158,21 +2201,15 @@ static void *rss_thread_fn(void *arg) { return NULL; } -static void rss_signal_wake(void) { - pthread_mutex_lock(&g_webui.rss_lock); - g_webui.rss_wake = true; - pthread_cond_signal(&g_webui.rss_cond); - pthread_mutex_unlock(&g_webui.rss_lock); -} - /* --- RSS HTTP API --------------------------------------------------------- */ /* GET /api/rss → array of feeds (with their articles). */ static void api_rss_list(int fd) { - json_t *feeds = json_array(); - if (g_webui.store) webui_store_feed_list(g_webui.store, feeds); - http_json(fd, 200, feeds); - json_decref(feeds); + 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. */ @@ -2180,33 +2217,62 @@ 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 added = false; - if (g_webui.store && name && *name) { - if (remove) webui_store_feed_remove(g_webui.store, name); - else if (url && *url) added = webui_store_feed_upsert(g_webui.store, name, 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 (added) rss_signal_wake(); /* re-poll the new feed now */ + 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) { - json_t *rules = json_array(); - if (g_webui.store) webui_store_rule_list(g_webui.store, rules); - http_json(fd, 200, rules); - json_decref(rules); + 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")); - if (g_webui.store && name && *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) { - webui_store_rule_remove(g_webui.store, name); + 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:i}", + 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")), @@ -2215,66 +2281,85 @@ static void api_rss_rule(int fd, const char *body, size_t len, bool remove) { "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) { - json_t *af = json_object_get(req, "affectedFeeds"); - json_object_set_new(rule, "affectedFeeds", - json_is_array(af) ? json_deep_copy(af) : json_array()); - webui_store_rule_upsert(g_webui.store, rule); - json_decref(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/rss/rules/run {name} — re-apply a rule to every stored article (not - * just newly-seen ones), grabbing matches not yet grabbed. Runs regardless of - * the rule's enabled flag. */ +/* POST /api/rss/rules/run {name} — re-apply a rule to every article already in + * the feeds (not just newly-seen ones), downloading matches not yet grabbed. + * Used after editing a rule. Runs regardless of the rule's enabled flag. */ static void api_rss_rule_run(int fd, const char *body, size_t len) { json_t *req = read_body_json(body, len); - const char *rname = json_string_value(json_object_get(req, "name")); - char name[128] = {0}; - if (rname) snprintf(name, sizeof name, "%s", rname); + const char *name = json_string_value(json_object_get(req, "name")); + char cat[128] = {0}, path[1024] = {0}; bool paused = false; + json_t *todo = json_array(); /* {key,magnet,torrentUrl} to grab */ + + pthread_mutex_lock(&g_webui.rss_lock); + json_t *rule = NULL; size_t i; json_t *r; + if (name) json_array_foreach(g_webui.rss_rules, i, r) + if (strcmp(json_string_or(r, "name", ""), name) == 0) { rule = r; break; } + if (rule) { + 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")); + /* match regardless of the enabled flag (explicit manual run) */ + json_t *probe = json_deep_copy(rule); + json_object_set_new(probe, "enabled", json_true()); + size_t fi; json_t *feed; + json_array_foreach(g_webui.rss_feeds, fi, feed) { + const char *fname = json_string_or(feed, "name", ""); + json_t *articles = json_object_get(feed, "articles"); + size_t ai; json_t *a; + json_array_foreach(articles, ai, a) { + if (json_boolean_value(json_object_get(a, "grabbed"))) continue; + const char *mag = json_string_or(a, "magnet", ""); + const char *url = json_string_or(a, "torrentUrl", ""); + if (!*mag && !*url) continue; + if (rule_matches(probe, fname, json_string_or(a, "title", ""))) + json_array_append_new(todo, json_pack("{s:s,s:s,s:s}", + "key", json_string_or(a, "key", ""), "magnet", mag, "torrentUrl", url)); + } + } + json_decref(probe); + if (json_array_size(todo)) + json_object_set_new(rule, "lastMatch", json_integer((json_int_t)time(NULL))); + } + bool found = rule != NULL; + pthread_mutex_unlock(&g_webui.rss_lock); json_decref(req); - json_t *rule = (g_webui.store && name[0]) ? webui_store_rule_get(g_webui.store, name) : NULL; - if (!rule) { http_text(fd, 404, "Not Found", "no such rule"); return; } - char cat[128], path[1024]; - snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", "")); - snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", "")); - bool paused = json_boolean_value(json_object_get(rule, "addPaused")); - json_object_set_new(rule, "enabled", json_true()); /* manual run */ - - json_t *cands = json_array(); - webui_store_articles_ungrabbed(g_webui.store, cands); - json_t *todo = json_array(); - size_t i; json_t *a; - json_array_foreach(cands, i, a) - if (rule_matches(rule, json_string_or(a, "feed", ""), json_string_or(a, "title", ""))) - json_array_append(todo, a); - json_decref(cands); - json_decref(rule); - int grabbed = 0; - json_array_foreach(todo, i, a) - if (rss_grab_article(json_string_or(a, "key", ""), json_string_or(a, "title", ""), - json_string_or(a, "magnet", ""), json_string_or(a, "torrentUrl", ""), - cat, path, paused)) + size_t j; json_t *t; + json_array_foreach(todo, j, t) + if (rss_grab_article(json_string_or(t, "key", ""), json_string_or(t, "magnet", ""), + json_string_or(t, "torrentUrl", ""), cat, path, paused)) grabbed++; size_t matched = json_array_size(todo); json_decref(todo); - if (matched) webui_store_rule_set_match(g_webui.store, name, (long)time(NULL)); + if (grabbed > 0) rss_save(); + if (!found) { http_text(fd, 404, "Not Found", "no such rule"); return; } json_t *reply = json_pack("{s:b,s:i,s:i}", "ok", 1, "matched", (int)matched, "grabbed", grabbed); http_json(fd, 200, reply); json_decref(reply); } -/* POST /api/rss/download {magnet|torrentUrl, title, key, category, savePath, - * paused} — manually grab a torrent from a feed article or search result. */ +/* POST /api/rss/download {magnet|torrentUrl, category, savePath, paused} + * Manually grab a torrent from a feed article or search result. Reuses the + * same add path as the auto-downloader (handles magnets and .torrent URLs). */ static void api_rss_download(int fd, const char *body, size_t len) { json_t *req = read_body_json(body, len); const char *magnet = json_string_or(req, "magnet", ""); @@ -2282,9 +2367,9 @@ static void api_rss_download(int fd, const char *body, size_t len) { const char *cat = json_string_or(req, "category", ""); const char *path = json_string_or(req, "savePath", ""); const char *key = json_string_or(req, "key", ""); - const char *title = json_string_or(req, "title", ""); bool paused = json_boolean_value(json_object_get(req, "paused")); - bool ok = rss_grab_article(key, title, magnet, url, cat, path, paused); + bool ok = rss_grab_article(key, magnet, url, cat, path, paused); + if (ok && *key) rss_save(); /* persist the grabbed flag */ json_decref(req); if (ok) { json_t *reply = json_pack("{s:b}", "ok", 1); @@ -2295,24 +2380,27 @@ static void api_rss_download(int fd, const char *body, size_t len) { } } -/* POST /api/rss/refresh {name?} — re-poll a feed now (or all feeds). */ +/* POST /api/rss/refresh {name?} — re-poll a feed now (or all feeds), running + * the network fetch synchronously so the response reflects fresh articles. */ static void api_rss_refresh(int fd, const char *body, size_t len) { json_t *req = read_body_json(body, len); const char *name = json_string_value(json_object_get(req, "name")); - char target[256] = {0}; - if (name) snprintf(target, sizeof target, "%s", name); + /* find matching index(es) under the lock, then poll outside it */ + pthread_mutex_lock(&g_webui.rss_lock); + size_t n = json_array_size(g_webui.rss_feeds); + int target = -1; + if (name && *name) { + size_t i; json_t *fd_j; + json_array_foreach(g_webui.rss_feeds, i, fd_j) + if (strcmp(json_string_or(fd_j, "name", ""), name) == 0) { target = (int)i; break; } + } + pthread_mutex_unlock(&g_webui.rss_lock); json_decref(req); - if (g_webui.store) { - json_t *targets = json_array(); - webui_store_feed_targets(g_webui.store, targets); - size_t i; json_t *t; - json_array_foreach(targets, i, t) { - if (atomic_load(&g_webui.stopping)) break; - const char *fn = json_string_or(t, "name", ""); - if (target[0] && strcmp(target, fn) != 0) continue; - rss_poll_one(fn, json_string_or(t, "url", "")); - } - json_decref(targets); + if (target >= 0) { + rss_poll_feed_by_index((size_t)target); + } else if (!name || !*name) { + for (size_t i = 0; i < n && !atomic_load(&g_webui.stopping); i++) + rss_poll_feed_by_index(i); } api_rss_list(fd); } @@ -2321,14 +2409,34 @@ static void api_rss_refresh(int fd, const char *body, size_t len) { 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")); - if (g_webui.store && name && *name) { - if (remove) webui_store_indexer_remove(g_webui.store, name); - else webui_store_indexer_upsert(g_webui.store, req); + 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); - json_t *reply = json_array(); - if (g_webui.store) webui_store_indexer_list(g_webui.store, reply); - http_json(fd, 200, reply); + 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); } @@ -2390,8 +2498,10 @@ static json_t *torznab_parse(const char *xml, size_t len, const char *engine) { /* 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(); - json_t *indexers = json_array(); - if (g_webui.store) webui_store_indexer_list(g_webui.store, indexers); + /* 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) { @@ -2547,156 +2657,6 @@ static void serve_cached_torrents(int fd) { free(copy); } -/* ============================ account management ========================== * - * Admin-only user CRUD plus a self-service password change. The web layer owns - * everything via webui_store; the daemon is not involved. */ - -static bool valid_username(const char *u) { - if (!u || !*u || strlen(u) >= 64) return false; - for (const char *p = u; *p; p++) - if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || - (*p >= '0' && *p <= '9') || *p == '_' || *p == '-' || *p == '.')) - return false; - return true; -} - -/* GET /api/users → [{username, role, createdAt}] (admin only). */ -static void api_users_list(int fd) { - json_t *users = json_array(); - if (g_webui.store) webui_store_list_users(g_webui.store, users); - http_json(fd, 200, users); - json_decref(users); -} - -/* POST /api/users {username, password, role} (admin only). */ -static void api_user_create(int fd, const char *body, size_t len) { - json_t *req = read_body_json(body, len); - const char *user = json_string_value(json_object_get(req, "username")); - const char *pass = json_string_value(json_object_get(req, "password")); - const char *role = json_string_value(json_object_get(req, "role")); - if (!valid_username(user) || !pass || !*pass) { - json_decref(req); - http_text(fd, 400, "Bad Request", - "username (letters/digits/._-) and password are required"); - return; - } - bool ok = g_webui.store && - webui_store_create_user(g_webui.store, user, pass, - role && *role ? role : "user"); - json_decref(req); - if (!ok) { http_text(fd, 409, "Conflict", "user already exists"); return; } - api_users_list(fd); -} - -/* POST /api/users/delete {username} (admin only). Refuses to remove the last - * admin so the instance can't lock everyone out. */ -static void api_user_delete(int fd, const char *body, size_t len, - const char *actor) { - json_t *req = read_body_json(body, len); - const char *uname = json_string_value(json_object_get(req, "username")); - if (!uname || !*uname) { json_decref(req); http_text(fd, 400, "Bad Request", "username required"); return; } - char user[64]; - snprintf(user, sizeof user, "%s", uname); /* own it before decref */ - char role[16] = {0}; - /* Look up the target's role to guard the last-admin rule. */ - json_t *list = json_array(); - if (g_webui.store) webui_store_list_users(g_webui.store, list); - size_t i; json_t *u; - json_array_foreach(list, i, u) - if (strcasecmp(json_string_or(u, "username", ""), user) == 0) - snprintf(role, sizeof role, "%s", json_string_or(u, "role", "")); - json_decref(list); - if (strcmp(role, "admin") == 0 && webui_store_admin_count(g_webui.store) <= 1) { - json_decref(req); - http_text(fd, 409, "Conflict", "cannot delete the last admin"); - return; - } - bool ok = g_webui.store && webui_store_delete_user(g_webui.store, user); - json_decref(req); - if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; } - drop_user_sessions(user); - (void)actor; - api_users_list(fd); -} - -/* POST /api/users/password {username, password} — admin reset. */ -static void api_user_set_password(int fd, const char *body, size_t len) { - json_t *req = read_body_json(body, len); - const char *uname = json_string_value(json_object_get(req, "username")); - const char *pass = json_string_value(json_object_get(req, "password")); - if (!uname || !*uname || !pass || !*pass) { - json_decref(req); - http_text(fd, 400, "Bad Request", "username and password required"); - return; - } - char user[64]; - snprintf(user, sizeof user, "%s", uname); - bool ok = g_webui.store && webui_store_set_password(g_webui.store, user, pass); - json_decref(req); - if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; } - drop_user_sessions(user); /* force re-login with the new password */ - json_t *reply = json_pack("{s:b}", "ok", 1); - http_json(fd, 200, reply); - json_decref(reply); -} - -/* POST /api/users/role {username, role} — admin; keeps at least one admin. */ -static void api_user_set_role(int fd, const char *body, size_t len) { - json_t *req = read_body_json(body, len); - const char *user = json_string_value(json_object_get(req, "username")); - const char *role = json_string_value(json_object_get(req, "role")); - if (!user || !*user || (strcmp(role ? role : "", "admin") && strcmp(role ? role : "", "user"))) { - json_decref(req); - http_text(fd, 400, "Bad Request", "username and role (admin|user) required"); - return; - } - if (strcmp(role, "user") == 0 && webui_store_admin_count(g_webui.store) <= 1) { - /* Only block if the target is currently the sole admin. */ - char cur[16] = {0}; - json_t *list = json_array(); - if (g_webui.store) webui_store_list_users(g_webui.store, list); - size_t i; json_t *u; - json_array_foreach(list, i, u) - if (strcasecmp(json_string_or(u, "username", ""), user) == 0) - snprintf(cur, sizeof cur, "%s", json_string_or(u, "role", "")); - json_decref(list); - if (strcmp(cur, "admin") == 0) { - json_decref(req); - http_text(fd, 409, "Conflict", "cannot demote the last admin"); - return; - } - } - bool ok = g_webui.store && webui_store_set_role(g_webui.store, user, role); - json_decref(req); - if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; } - api_users_list(fd); -} - -/* POST /api/account/password {oldPassword, newPassword} — change own password. */ -static void api_account_password(int fd, const char *body, size_t len, - const char *actor) { - json_t *req = read_body_json(body, len); - const char *oldp = json_string_value(json_object_get(req, "oldPassword")); - const char *newp = json_string_value(json_object_get(req, "newPassword")); - if (!oldp || !newp || !*newp) { - json_decref(req); - http_text(fd, 400, "Bad Request", "oldPassword and newPassword required"); - return; - } - char role[16] = {0}; - if (!g_webui.store || !webui_store_verify(g_webui.store, actor, oldp, role, sizeof role)) { - json_decref(req); - http_text(fd, 403, "Forbidden", "current password is incorrect"); - return; - } - bool ok = webui_store_set_password(g_webui.store, actor, newp); - json_decref(req); - if (!ok) { http_text(fd, 500, "Internal Server Error", "could not update password"); return; } - json_t *reply = json_pack("{s:b}", "ok", 1); - http_json(fd, 200, reply); - json_decref(reply); -} - 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) { @@ -2705,15 +2665,13 @@ static void handle_api(int fd, const char *method, char *path, const char *qmark = strchr(path, '?'); if (qmark) snprintf(query_str, sizeof query_str, "%s", qmark + 1); strip_query(path); - char cur_user[64] = {0}, cur_role[16] = {0}; if (strcmp(path, "/api/auth/status") == 0 && strcmp(method, "GET") == 0) { - bool authed = current_identity(headers, headers_end, cur_user, - sizeof cur_user, cur_role, sizeof cur_role); - json_t *json = json_pack("{s:b,s:s,s:s,s:b}", - "authenticated", authed, - "user", authed ? cur_user : "", - "role", authed ? cur_role : "", - "generatedPassword", g_webui.generated_password); + json_t *json = json_pack("{s:b,s:s,s:b}", + "authenticated", + current_user(headers, headers_end), + "user", g_webui.auth_user, + "generatedPassword", + g_webui.generated_password); http_json(fd, 200, json); json_decref(json); } else if (strcmp(path, "/api/login") == 0 && strcmp(method, "POST") == 0) { @@ -2721,10 +2679,10 @@ static void handle_api(int fd, const char *method, char *path, const char *user = json_string_value(json_object_get(req, "username")); const char *password = json_string_value(json_object_get(req, "password")); - char role[16] = {0}; - bool ok = g_webui.store && user && password && - webui_store_verify(g_webui.store, user, password, role, sizeof role); - if (!ok) { + bool user_ok = user && constant_time_equal(user, g_webui.auth_user); + bool pass_ok = password && g_webui.auth_password[0] && + constant_time_equal(password, g_webui.auth_password); + if (!user_ok || !pass_ok) { json_t *json = json_pack("{s:b,s:s}", "ok", 0, "error", "invalid credentials"); http_json(fd, 401, json); @@ -2733,7 +2691,7 @@ static void handle_api(int fd, const char *method, char *path, return; } char token[96]; - if (!create_session(user, role, token, sizeof token)) { + if (!create_session(token, sizeof token)) { json_decref(req); http_text(fd, 500, "Internal Server Error", "session failed"); return; @@ -2741,10 +2699,10 @@ static void handle_api(int fd, const char *method, char *path, char cookie[256]; snprintf(cookie, sizeof cookie, "Set-Cookie: %s=%s; Path=/; HttpOnly; SameSite=Lax; " - "Max-Age=%ld\r\n", - SESSION_COOKIE, token, session_ttl()); - json_t *json = json_pack("{s:b,s:s,s:s}", "ok", 1, - "user", user, "role", role); + "Max-Age=%d\r\n", + SESSION_COOKIE, token, SESSION_TTL_SECONDS); + json_t *json = json_pack("{s:b,s:s}", "ok", 1, + "user", g_webui.auth_user); http_json_extra(fd, 200, json, cookie); json_decref(json); json_decref(req); @@ -2755,8 +2713,7 @@ static void handle_api(int fd, const char *method, char *path, "Set-Cookie: naut_session=; Path=/; HttpOnly; " "SameSite=Lax; Max-Age=0\r\n"); json_decref(json); - } else if (!current_identity(headers, headers_end, cur_user, sizeof cur_user, - cur_role, sizeof cur_role)) { + } else if (!current_user(headers, headers_end)) { json_t *json = json_pack("{s:s}", "error", "authentication required"); http_json(fd, 401, json); @@ -2802,25 +2759,6 @@ 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 (strcmp(path, "/api/account/password") == 0 && strcmp(method, "POST") == 0) { - api_account_password(fd, body, body_len, cur_user); - } else if (strncmp(path, "/api/users", 10) == 0) { - /* All user-management endpoints are admin-only. */ - if (strcmp(cur_role, "admin") != 0) { - http_text(fd, 403, "Forbidden", "admin privileges required"); - } else if (strcmp(path, "/api/users") == 0 && strcmp(method, "GET") == 0) { - api_users_list(fd); - } else if (strcmp(path, "/api/users") == 0 && strcmp(method, "POST") == 0) { - api_user_create(fd, body, body_len); - } else if (strcmp(path, "/api/users/delete") == 0 && strcmp(method, "POST") == 0) { - api_user_delete(fd, body, body_len, cur_user); - } else if (strcmp(path, "/api/users/password") == 0 && strcmp(method, "POST") == 0) { - api_user_set_password(fd, body, body_len); - } else if (strcmp(path, "/api/users/role") == 0 && strcmp(method, "POST") == 0) { - api_user_set_role(fd, body, body_len); - } else { - http_text(fd, 404, "Not Found", "not found"); - } } 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) { @@ -3067,14 +3005,15 @@ static naut_err start_server(void) { if (strcmp(g_webui.host_name, DEFAULT_HOST) != 0) log_msg(1, "webui: bound to a non-loopback address; credentials cross " "the network in plaintext (set NAUT_AUTH_PASSWORD)"); - if (!g_webui.store) { - log_msg(0, "webui: account store unavailable; logins will fail"); - } else if (g_webui.generated_password && g_webui.auth_password[0]) { - /* First run: surface the generated admin credentials once. */ - snprintf(msg, sizeof msg, - "webui: created initial admin '%s' with generated password %s", - g_webui.auth_user, g_webui.auth_password); + snprintf(msg, sizeof msg, "webui: auth user %s", g_webui.auth_user); + log_msg(2, msg); + if (g_webui.generated_password && g_webui.auth_password[0]) { + snprintf(msg, sizeof msg, "webui: generated password %s", + g_webui.auth_password); log_msg(1, msg); + } else if (!g_webui.auth_password[0]) { + log_msg(0, "webui: no password available (CSPRNG unavailable); set " + "NAUT_AUTH_PASSWORD to enable login"); } return NAUT_OK; } @@ -3087,8 +3026,10 @@ naut_err naut_plugin_register(const naut_host_api *host) { memset(&g_webui, 0, sizeof g_webui); g_webui.listener = -1; g_webui.host = *host; - if (pthread_mutex_init(&g_webui.conn_lock, NULL) != 0) + if (pthread_mutex_init(&g_webui.auth_lock, NULL) != 0) return NAUT_ERR_NOMEM; + if (pthread_mutex_init(&g_webui.conn_lock, NULL) != 0) + goto fail_conn_lock; if (pthread_cond_init(&g_webui.conn_cond, NULL) != 0) goto fail_conn_cond; if (pthread_mutex_init(&g_webui.snap_lock, NULL) != 0) @@ -3106,9 +3047,10 @@ naut_err naut_plugin_register(const naut_host_api *host) { 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(); - if (g_webui.store) /* drop sessions that expired while we were down */ - webui_store_sessions_prune(g_webui.store, (long)time(NULL)); error = g_webui.host.set_plugin_name(g_webui.host.host_context, "webui"); if (error != NAUT_OK) goto fail_store; @@ -3135,6 +3077,8 @@ fail_snap_lock: pthread_cond_destroy(&g_webui.conn_cond); fail_conn_cond: pthread_mutex_destroy(&g_webui.conn_lock); +fail_conn_lock: + pthread_mutex_destroy(&g_webui.auth_lock); return error; } @@ -3178,6 +3122,13 @@ naut_err naut_plugin_shutdown(void) { g_webui.categories = NULL; 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); @@ -3187,7 +3138,6 @@ naut_err naut_plugin_shutdown(void) { pthread_mutex_destroy(&g_webui.snap_lock); pthread_cond_destroy(&g_webui.conn_cond); pthread_mutex_destroy(&g_webui.conn_lock); - webui_store_close(g_webui.store); - g_webui.store = NULL; + pthread_mutex_destroy(&g_webui.auth_lock); return NAUT_OK; } diff --git a/plugins/webui/webui_store.c b/plugins/webui/webui_store.c deleted file mode 100644 index 9b69089..0000000 --- a/plugins/webui/webui_store.c +++ /dev/null @@ -1,1135 +0,0 @@ -/* webui_store.c — SQLite + PBKDF2 implementation of the web-UI account store. */ -#include "webui_store.h" - -#include -#include -#include -#include - -#include -#include -#include -#include - -#define PBKDF2_ITERS 210000 -#define SALT_BYTES 16 -#define HASH_BYTES 32 - -struct webui_store { - sqlite3 *db; - pthread_mutex_t lock; -}; - -/* One-time upgrade of a pre-normalization DB (feeds.articles / rules.affected_feeds - * JSON columns) to the relational articles + rule_feeds tables. Defined at the - * end of the file so it can use the row helpers. */ -static void legacy_migrate(webui_store *s); - -static void to_hex(const unsigned char *in, size_t n, char *out) { - static const char hex[] = "0123456789abcdef"; - for (size_t i = 0; i < n; i++) { - out[i * 2] = hex[in[i] >> 4]; - out[i * 2 + 1] = hex[in[i] & 0xf]; - } - out[n * 2] = 0; -} - -static int from_hex(const char *in, unsigned char *out, size_t out_n) { - size_t len = strlen(in); - if (len != out_n * 2) return -1; - for (size_t i = 0; i < out_n; i++) { - char c[3] = { in[i * 2], in[i * 2 + 1], 0 }; - char *end; - long v = strtol(c, &end, 16); - if (end != c + 2) return -1; - out[i] = (unsigned char)v; - } - return 0; -} - -/* Derive a hash for `password` with the given salt + iteration count. */ -static bool derive(const char *password, const unsigned char *salt, - size_t salt_n, int iters, unsigned char out[HASH_BYTES]) { - return PKCS5_PBKDF2_HMAC(password, (int)strlen(password), salt, (int)salt_n, - iters, EVP_sha256(), HASH_BYTES, out) == 1; -} - -static bool valid_role(const char *role) { - return role && (strcmp(role, "admin") == 0 || strcmp(role, "user") == 0); -} - -webui_store *webui_store_open(const char *path) { - webui_store *s = calloc(1, sizeof *s); - if (!s) return NULL; - if (pthread_mutex_init(&s->lock, NULL) != 0) { free(s); return NULL; } - if (sqlite3_open(path, &s->db) != SQLITE_OK) { - sqlite3_close(s->db); - pthread_mutex_destroy(&s->lock); - free(s); - return NULL; - } - sqlite3_busy_timeout(s->db, 4000); - const char *schema = - "PRAGMA journal_mode=WAL;" - "CREATE TABLE IF NOT EXISTS users (" - " id INTEGER PRIMARY KEY," - " username TEXT NOT NULL UNIQUE COLLATE NOCASE," - " pw_hash TEXT NOT NULL," - " pw_salt TEXT NOT NULL," - " pw_iters INTEGER NOT NULL," - " role TEXT NOT NULL DEFAULT 'user'," - " created_at INTEGER NOT NULL);" - "CREATE TABLE IF NOT EXISTS sessions (" - " token_hash TEXT PRIMARY KEY," - " username TEXT NOT NULL," - " role TEXT NOT NULL DEFAULT 'user'," - " expires INTEGER NOT NULL);" - "CREATE INDEX IF NOT EXISTS sessions_user ON sessions(username);" - "CREATE INDEX IF NOT EXISTS sessions_expires ON sessions(expires);" - "CREATE TABLE IF NOT EXISTS categories (" - " name TEXT PRIMARY KEY," - " save_path TEXT NOT NULL DEFAULT '');" - "CREATE TABLE IF NOT EXISTS tags (name TEXT PRIMARY KEY);" - "CREATE TABLE IF NOT EXISTS feeds (" - " name TEXT PRIMARY KEY," - " url TEXT NOT NULL," - " last_update INTEGER NOT NULL DEFAULT 0);" - "CREATE TABLE IF NOT EXISTS articles (" - " id INTEGER PRIMARY KEY AUTOINCREMENT," - " feed TEXT NOT NULL," - " key TEXT NOT NULL," - " title TEXT NOT NULL DEFAULT ''," - " magnet TEXT NOT NULL DEFAULT ''," - " torrent_url TEXT NOT NULL DEFAULT ''," - " link TEXT NOT NULL DEFAULT ''," - " size INTEGER NOT NULL DEFAULT 0," - " pub_date TEXT NOT NULL DEFAULT ''," - " is_read INTEGER NOT NULL DEFAULT 0," - " grabbed INTEGER NOT NULL DEFAULT 0," - " seen_at INTEGER NOT NULL DEFAULT 0," - " UNIQUE(feed, key));" - "CREATE INDEX IF NOT EXISTS articles_feed ON articles(feed);" - "CREATE INDEX IF NOT EXISTS articles_key ON articles(key);" - "CREATE INDEX IF NOT EXISTS articles_grabbed ON articles(grabbed);" - "CREATE TABLE IF NOT EXISTS rules (" - " name TEXT PRIMARY KEY," - " enabled INTEGER NOT NULL DEFAULT 1," - " use_regex INTEGER NOT NULL DEFAULT 0," - " add_paused INTEGER NOT NULL DEFAULT 0," - " must_contain TEXT NOT NULL DEFAULT ''," - " must_not_contain TEXT NOT NULL DEFAULT ''," - " assigned_category TEXT NOT NULL DEFAULT ''," - " save_path TEXT NOT NULL DEFAULT ''," - " last_match INTEGER NOT NULL DEFAULT 0);" - "CREATE TABLE IF NOT EXISTS rule_feeds (" - " rule TEXT NOT NULL," - " feed TEXT NOT NULL," - " PRIMARY KEY(rule, feed));" - "CREATE TABLE IF NOT EXISTS indexers (" - " name TEXT PRIMARY KEY," - " url TEXT NOT NULL DEFAULT ''," - " apikey TEXT NOT NULL DEFAULT ''," - " enabled INTEGER NOT NULL DEFAULT 1);"; - char *err = NULL; - if (sqlite3_exec(s->db, schema, NULL, NULL, &err) != SQLITE_OK) { - sqlite3_free(err); - webui_store_close(s); - return NULL; - } - legacy_migrate(s); /* upgrade an older DB's RSS schema in place */ - return s; -} - -void webui_store_close(webui_store *s) { - if (!s) return; - if (s->db) sqlite3_close(s->db); - pthread_mutex_destroy(&s->lock); - free(s); -} - -/* Run a "SELECT count(*) ... " style query returning a single integer. */ -static int count_query(webui_store *s, const char *sql) { - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(s->db, sql, -1, &st, NULL) != SQLITE_OK) return -1; - int n = -1; - if (sqlite3_step(st) == SQLITE_ROW) n = sqlite3_column_int(st, 0); - sqlite3_finalize(st); - return n; -} - -int webui_store_user_count(webui_store *s) { - if (!s) return -1; - pthread_mutex_lock(&s->lock); - int n = count_query(s, "SELECT count(*) FROM users;"); - pthread_mutex_unlock(&s->lock); - return n; -} - -int webui_store_admin_count(webui_store *s) { - if (!s) return -1; - pthread_mutex_lock(&s->lock); - int n = count_query(s, "SELECT count(*) FROM users WHERE role='admin';"); - pthread_mutex_unlock(&s->lock); - return n; -} - -bool webui_store_user_exists(webui_store *s, const char *username) { - if (!s || !username) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool found = false; - if (sqlite3_prepare_v2(s->db, "SELECT 1 FROM users WHERE username=?;", -1, - &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, username, -1, SQLITE_STATIC); - found = sqlite3_step(st) == SQLITE_ROW; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return found; -} - -bool webui_store_verify(webui_store *s, const char *username, - const char *password, char *role_out, size_t role_sz) { - if (!s || !username || !password) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, - "SELECT pw_hash, pw_salt, pw_iters, role FROM users WHERE username=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, username, -1, SQLITE_STATIC); - if (sqlite3_step(st) == SQLITE_ROW) { - const char *hash_hex = (const char *)sqlite3_column_text(st, 0); - const char *salt_hex = (const char *)sqlite3_column_text(st, 1); - int iters = sqlite3_column_int(st, 2); - const char *role = (const char *)sqlite3_column_text(st, 3); - unsigned char salt[SALT_BYTES], want[HASH_BYTES], got[HASH_BYTES]; - if (hash_hex && salt_hex && - from_hex(salt_hex, salt, SALT_BYTES) == 0 && - from_hex(hash_hex, want, HASH_BYTES) == 0 && - derive(password, salt, SALT_BYTES, iters, got) && - CRYPTO_memcmp(want, got, HASH_BYTES) == 0) { - ok = true; - if (role_out && role) snprintf(role_out, role_sz, "%s", role); - } - } - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -/* Compute a fresh salt + hash for `password`, hex-encoded into the buffers. */ -static bool make_hash(const char *password, char salt_hex[SALT_BYTES * 2 + 1], - char hash_hex[HASH_BYTES * 2 + 1]) { - unsigned char salt[SALT_BYTES], hash[HASH_BYTES]; - if (RAND_bytes(salt, SALT_BYTES) != 1) return false; - if (!derive(password, salt, SALT_BYTES, PBKDF2_ITERS, hash)) return false; - to_hex(salt, SALT_BYTES, salt_hex); - to_hex(hash, HASH_BYTES, hash_hex); - return true; -} - -bool webui_store_create_user(webui_store *s, const char *username, - const char *password, const char *role) { - if (!s || !username || !*username || !password || !*password) return false; - if (!valid_role(role)) role = "user"; - char salt_hex[SALT_BYTES * 2 + 1], hash_hex[HASH_BYTES * 2 + 1]; - if (!make_hash(password, salt_hex, hash_hex)) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, - "INSERT INTO users (username, pw_hash, pw_salt, pw_iters, role, created_at)" - " VALUES (?,?,?,?,?,?);", -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, username, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 2, hash_hex, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 3, salt_hex, -1, SQLITE_STATIC); - sqlite3_bind_int(st, 4, PBKDF2_ITERS); - sqlite3_bind_text(st, 5, role, -1, SQLITE_STATIC); - sqlite3_bind_int64(st, 6, (sqlite3_int64)time(NULL)); - ok = sqlite3_step(st) == SQLITE_DONE; /* false on UNIQUE conflict */ - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_set_password(webui_store *s, const char *username, - const char *password) { - if (!s || !username || !password || !*password) return false; - char salt_hex[SALT_BYTES * 2 + 1], hash_hex[HASH_BYTES * 2 + 1]; - if (!make_hash(password, salt_hex, hash_hex)) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, - "UPDATE users SET pw_hash=?, pw_salt=?, pw_iters=? WHERE username=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, hash_hex, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 2, salt_hex, -1, SQLITE_STATIC); - sqlite3_bind_int(st, 3, PBKDF2_ITERS); - sqlite3_bind_text(st, 4, username, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_set_role(webui_store *s, const char *username, const char *role) { - if (!s || !username || !valid_role(role)) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, "UPDATE users SET role=? WHERE username=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, role, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 2, username, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_delete_user(webui_store *s, const char *username) { - if (!s || !username) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, "DELETE FROM users WHERE username=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, username, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_list_users(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 username, role, created_at FROM users ORDER BY username COLLATE NOCASE;", - -1, &st, NULL) == SQLITE_OK) { - ok = true; - while (sqlite3_step(st) == SQLITE_ROW) { - const char *u = (const char *)sqlite3_column_text(st, 0); - const char *r = (const char *)sqlite3_column_text(st, 1); - json_array_append_new(out, json_pack("{s:s,s:s,s:I}", - "username", u ? u : "", "role", r ? r : "user", - "createdAt", (json_int_t)sqlite3_column_int64(st, 2))); - } - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -/* --- sessions ------------------------------------------------------------- */ - -/* SHA-256 of a bearer token, hex-encoded. We persist only this, never the raw - * token, so a DB leak can't be replayed as a live cookie. */ -static void sha256_hex(const char *token, char out[65]) { - unsigned char d[32]; - unsigned int dl = 0; - EVP_Digest(token, strlen(token), d, &dl, EVP_sha256(), NULL); - to_hex(d, 32, out); -} - -bool webui_store_session_create(webui_store *s, const char *token, - const char *user, const char *role, - long expires) { - if (!s || !token || !*token || !user || !*user) return false; - char th[65]; - sha256_hex(token, th); - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, - "INSERT OR REPLACE INTO sessions (token_hash,username,role,expires)" - " VALUES (?,?,?,?);", -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, th, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 2, user, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 3, role && *role ? role : "user", -1, SQLITE_STATIC); - sqlite3_bind_int64(st, 4, (sqlite3_int64)expires); - ok = sqlite3_step(st) == SQLITE_DONE; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_session_lookup(webui_store *s, const char *token, - char *user, size_t user_sz, - char *role, size_t role_sz, long *expires_out) { - if (!s || !token || !*token) return false; - char th[65]; - sha256_hex(token, th); - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, - "SELECT username, role, expires FROM sessions WHERE token_hash=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, th, -1, SQLITE_STATIC); - if (sqlite3_step(st) == SQLITE_ROW) { - const char *u = (const char *)sqlite3_column_text(st, 0); - const char *r = (const char *)sqlite3_column_text(st, 1); - if (user) snprintf(user, user_sz, "%s", u ? u : ""); - if (role) snprintf(role, role_sz, "%s", r ? r : "user"); - if (expires_out) *expires_out = (long)sqlite3_column_int64(st, 2); - ok = true; - } - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_session_touch(webui_store *s, const char *token, long expires) { - if (!s || !token) return false; - char th[65]; - sha256_hex(token, th); - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, - "UPDATE sessions SET expires=? WHERE token_hash=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, (sqlite3_int64)expires); - sqlite3_bind_text(st, 2, th, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_session_delete(webui_store *s, const char *token) { - if (!s || !token) return false; - char th[65]; - sha256_hex(token, th); - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, "DELETE FROM sessions WHERE token_hash=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, th, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_sessions_delete_user(webui_store *s, const char *user) { - if (!s || !user) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, "DELETE FROM sessions WHERE username=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, user, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -void webui_store_sessions_prune(webui_store *s, long now) { - if (!s) return; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(s->db, "DELETE FROM sessions WHERE expires<=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, (sqlite3_int64)now); - sqlite3_step(st); - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); -} - -/* --- taxonomy ------------------------------------------------------------- */ - -/* Replace one table's contents from a json array, inside a transaction. The - * `bind` callback binds each element's columns onto the prepared INSERT. */ -static bool replace_table(webui_store *s, const char *del_sql, - const char *ins_sql, json_t *items, - void (*bind)(sqlite3_stmt *, json_t *)) { - if (!s || !json_is_array(items)) return false; - pthread_mutex_lock(&s->lock); - bool ok = sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL) == SQLITE_OK && - sqlite3_exec(s->db, del_sql, NULL, NULL, NULL) == SQLITE_OK; - sqlite3_stmt *st = NULL; - if (ok && sqlite3_prepare_v2(s->db, ins_sql, -1, &st, NULL) == SQLITE_OK) { - size_t i; json_t *v; - json_array_foreach(items, i, v) { - bind(st, v); - if (sqlite3_step(st) != SQLITE_DONE) { ok = false; break; } - sqlite3_reset(st); - } - } else ok = false; - sqlite3_finalize(st); - sqlite3_exec(s->db, ok ? "COMMIT;" : "ROLLBACK;", NULL, NULL, NULL); - pthread_mutex_unlock(&s->lock); - return ok; -} - -static const char *str_or(json_t *o, const char *k, const char *fallback) { - const char *v = json_string_value(json_object_get(o, k)); - return v ? v : fallback; -} - -static void bind_category(sqlite3_stmt *st, json_t *c) { - sqlite3_bind_text(st, 1, str_or(c, "name", ""), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 2, str_or(c, "savePath", ""), -1, SQLITE_TRANSIENT); -} - -bool webui_store_save_categories(webui_store *s, json_t *cats) { - return replace_table(s, "DELETE FROM categories;", - "INSERT OR REPLACE INTO categories (name, save_path) VALUES (?,?);", - cats, bind_category); -} - -bool webui_store_load_categories(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, save_path FROM categories 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 *p = (const char *)sqlite3_column_text(st, 1); - json_array_append_new(out, json_pack("{s:s,s:s}", - "name", n ? n : "", "savePath", p ? p : "")); - } - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -static void bind_tag(sqlite3_stmt *st, json_t *t) { - sqlite3_bind_text(st, 1, json_string_value(t) ? json_string_value(t) : "", - -1, SQLITE_TRANSIENT); -} - -bool webui_store_save_tags(webui_store *s, json_t *tags) { - return replace_table(s, "DELETE FROM tags;", - "INSERT OR REPLACE INTO tags (name) VALUES (?);", tags, bind_tag); -} - -bool webui_store_load_tags(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 FROM tags 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); - json_array_append_new(out, json_string(n ? n : "")); - } - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -/* --- RSS (fully relational) ----------------------------------------------- */ - -static int int_of(json_t *o, const char *k) { - return json_boolean_value(json_object_get(o, k)) ? 1 : 0; -} - -/* Parse a TEXT column holding a JSON array; returns a new array (never NULL). - * Only used by the legacy-schema migration. */ -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(); -} - -/* ---- feeds ---- */ - -bool webui_store_feed_upsert(webui_store *s, const char *name, const char *url) { - if (!s || !name || !*name || !url || !*url) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, - "INSERT INTO feeds (name, url, last_update) VALUES (?,?,0)" - " ON CONFLICT(name) DO UPDATE SET url=excluded.url;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 2, url, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_feed_remove(webui_store *s, const char *name) { - if (!s || !name) return false; - pthread_mutex_lock(&s->lock); - bool ok = false; - sqlite3_stmt *st = NULL; - sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL); - if (sqlite3_prepare_v2(s->db, "DELETE FROM articles WHERE feed=?;", -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); - sqlite3_step(st); - } - sqlite3_finalize(st); st = NULL; - if (sqlite3_prepare_v2(s->db, "DELETE FROM feeds WHERE name=?;", -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; - } - sqlite3_finalize(st); - sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_feed_set_updated(webui_store *s, const char *name, long ts) { - if (!s || !name) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, "UPDATE feeds SET last_update=? WHERE name=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, (sqlite3_int64)ts); - sqlite3_bind_text(st, 2, name, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_feed_exists(webui_store *s, const char *name) { - if (!s || !name) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool found = false; - if (sqlite3_prepare_v2(s->db, "SELECT 1 FROM feeds WHERE name=?;", -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); - found = sqlite3_step(st) == SQLITE_ROW; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return found; -} - -bool webui_store_feed_targets(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 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}", - "name", n ? n : "", "url", u ? u : "")); - } - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -/* Build the article array for one feed (newest first). Caller holds the lock. */ -static json_t *feed_articles_locked(webui_store *s, const char *feed) { - json_t *arr = json_array(); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(s->db, - "SELECT key,title,magnet,torrent_url,link,size,pub_date,is_read,grabbed" - " FROM articles WHERE feed=? ORDER BY id DESC;", -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, feed, -1, SQLITE_STATIC); - while (sqlite3_step(st) == SQLITE_ROW) { - const char *k = (const char *)sqlite3_column_text(st, 0); - const char *t = (const char *)sqlite3_column_text(st, 1); - const char *m = (const char *)sqlite3_column_text(st, 2); - const char *tu = (const char *)sqlite3_column_text(st, 3); - const char *ln = (const char *)sqlite3_column_text(st, 4); - const char *pd = (const char *)sqlite3_column_text(st, 6); - json_array_append_new(arr, json_pack( - "{s:s,s:s,s:s,s:s,s:s,s:I,s:s,s:b,s:b}", - "key", k ? k : "", "title", t ? t : "", "magnet", m ? m : "", - "torrentUrl", tu ? tu : "", "link", ln ? ln : "", - "size", (json_int_t)sqlite3_column_int64(st, 5), - "pubDate", pd ? pd : "", "isRead", sqlite3_column_int(st, 7), - "grabbed", sqlite3_column_int(st, 8))); - } - } - sqlite3_finalize(st); - return arr; -} - -bool webui_store_feed_list(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 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", feed_articles_locked(s, n ? n : ""))); - } - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -/* ---- articles ---- */ - -int webui_store_article_add(webui_store *s, const char *feed, json_t *a) { - if (!s || !feed || !json_is_object(a)) return -1; - const char *key = str_or(a, "key", ""); - if (!*key) return -1; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - int rc = -1; - if (sqlite3_prepare_v2(s->db, - "INSERT OR IGNORE INTO articles" - " (feed,key,title,magnet,torrent_url,link,size,pub_date,is_read,grabbed,seen_at)" - " VALUES (?,?,?,?,?,?,?,?,0,0,?);", -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, feed, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 3, str_or(a, "title", ""), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 4, str_or(a, "magnet", ""), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 5, str_or(a, "torrentUrl", ""), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 6, str_or(a, "link", ""), -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 7, (sqlite3_int64)json_integer_value(json_object_get(a, "size"))); - sqlite3_bind_text(st, 8, str_or(a, "pubDate", ""), -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 9, (sqlite3_int64)time(NULL)); - if (sqlite3_step(st) == SQLITE_DONE) rc = sqlite3_changes(s->db) > 0 ? 1 : 0; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return rc; -} - -bool webui_store_article_trim(webui_store *s, const char *feed, int keep) { - if (!s || !feed || keep < 0) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, - "DELETE FROM articles WHERE feed=? AND id NOT IN" - " (SELECT id FROM articles WHERE feed=? ORDER BY id DESC LIMIT ?);", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, feed, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 2, feed, -1, SQLITE_STATIC); - sqlite3_bind_int(st, 3, keep); - ok = sqlite3_step(st) == SQLITE_DONE; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_article_mark_grabbed(webui_store *s, const char *key) { - if (!s || !key || !*key) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, "UPDATE articles SET grabbed=1 WHERE key=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, key, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_articles_ungrabbed(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 feed,key,title,magnet,torrent_url FROM articles" - " WHERE grabbed=0 AND (magnet<>'' OR torrent_url<>'') ORDER BY id DESC;", - -1, &st, NULL) == SQLITE_OK) { - ok = true; - while (sqlite3_step(st) == SQLITE_ROW) { - const char *f = (const char *)sqlite3_column_text(st, 0); - const char *k = (const char *)sqlite3_column_text(st, 1); - const char *t = (const char *)sqlite3_column_text(st, 2); - const char *m = (const char *)sqlite3_column_text(st, 3); - const char *u = (const char *)sqlite3_column_text(st, 4); - json_array_append_new(out, json_pack("{s:s,s:s,s:s,s:s,s:s}", - "feed", f ? f : "", "key", k ? k : "", "title", t ? t : "", - "magnet", m ? m : "", "torrentUrl", u ? u : "")); - } - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -/* ---- rules ---- */ - -static void bind_rule_row(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); - sqlite3_bind_int64(st, 9, (sqlite3_int64)json_integer_value(json_object_get(r, "lastMatch"))); -} - -bool webui_store_rule_upsert(webui_store *s, json_t *r) { - if (!s || !json_is_object(r)) return false; - const char *name = str_or(r, "name", ""); - if (!*name) return false; - pthread_mutex_lock(&s->lock); - bool ok = sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL) == SQLITE_OK; - sqlite3_stmt *st = NULL; - if (ok && sqlite3_prepare_v2(s->db, - "INSERT OR REPLACE INTO rules (name,enabled,use_regex,add_paused," - "must_contain,must_not_contain,assigned_category,save_path,last_match)" - " VALUES (?,?,?,?,?,?,?,?,?);", -1, &st, NULL) == SQLITE_OK) { - bind_rule_row(st, r); - ok = sqlite3_step(st) == SQLITE_DONE; - } else ok = false; - sqlite3_finalize(st); st = NULL; - if (ok && sqlite3_prepare_v2(s->db, "DELETE FROM rule_feeds WHERE rule=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE; - } else ok = false; - sqlite3_finalize(st); st = NULL; - json_t *feeds = json_object_get(r, "affectedFeeds"); - if (ok && json_is_array(feeds) && sqlite3_prepare_v2(s->db, - "INSERT OR IGNORE INTO rule_feeds (rule,feed) VALUES (?,?);", - -1, &st, NULL) == SQLITE_OK) { - size_t i; json_t *v; - json_array_foreach(feeds, i, v) { - const char *fn = json_string_value(v); - if (!fn || !*fn) continue; - sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 2, fn, -1, SQLITE_TRANSIENT); - if (sqlite3_step(st) != SQLITE_DONE) { ok = false; break; } - sqlite3_reset(st); - } - } - sqlite3_finalize(st); - sqlite3_exec(s->db, ok ? "COMMIT;" : "ROLLBACK;", NULL, NULL, NULL); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_rule_remove(webui_store *s, const char *name) { - if (!s || !name) return false; - pthread_mutex_lock(&s->lock); - sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, "DELETE FROM rule_feeds WHERE rule=?;", -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); - sqlite3_step(st); - } - sqlite3_finalize(st); st = NULL; - if (sqlite3_prepare_v2(s->db, "DELETE FROM rules WHERE name=?;", -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; - } - sqlite3_finalize(st); - sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL); - pthread_mutex_unlock(&s->lock); - return ok; -} - -/* Build the affectedFeeds array for one rule. Caller holds the lock. */ -static json_t *rule_feeds_locked(webui_store *s, const char *rule) { - json_t *arr = json_array(); - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(s->db, - "SELECT feed FROM rule_feeds WHERE rule=? ORDER BY feed;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, rule, -1, SQLITE_STATIC); - while (sqlite3_step(st) == SQLITE_ROW) { - const char *f = (const char *)sqlite3_column_text(st, 0); - json_array_append_new(arr, json_string(f ? f : "")); - } - } - sqlite3_finalize(st); - return arr; -} - -static json_t *rule_row_to_json(sqlite3_stmt *st, webui_store *s) { - 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); - return 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", rule_feeds_locked(s, n ? n : ""), - "lastMatch", (json_int_t)sqlite3_column_int64(st, 8)); -} - -static const char RULE_COLS[] = - "SELECT name,enabled,use_regex,add_paused,must_contain,must_not_contain," - "assigned_category,save_path,last_match FROM rules"; - -bool webui_store_rule_list(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; - char sql[256]; - snprintf(sql, sizeof sql, "%s ORDER BY name;", RULE_COLS); - if (sqlite3_prepare_v2(s->db, sql, -1, &st, NULL) == SQLITE_OK) { - ok = true; - while (sqlite3_step(st) == SQLITE_ROW) - json_array_append_new(out, rule_row_to_json(st, s)); - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -json_t *webui_store_rule_get(webui_store *s, const char *name) { - if (!s || !name) return NULL; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - json_t *out = NULL; - char sql[256]; - snprintf(sql, sizeof sql, "%s WHERE name=?;", RULE_COLS); - if (sqlite3_prepare_v2(s->db, sql, -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); - if (sqlite3_step(st) == SQLITE_ROW) out = rule_row_to_json(st, s); - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return out; -} - -bool webui_store_rule_set_match(webui_store *s, const char *name, long ts) { - if (!s || !name) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, "UPDATE rules SET last_match=? WHERE name=?;", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, (sqlite3_int64)ts); - sqlite3_bind_text(st, 2, name, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -/* ---- indexers ---- */ - -bool webui_store_indexer_upsert(webui_store *s, json_t *x) { - if (!s || !json_is_object(x)) return false; - const char *name = str_or(x, "name", ""); - if (!*name) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, - "INSERT OR REPLACE INTO indexers (name,url,apikey,enabled) VALUES (?,?,?,?);", - -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, 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, json_object_get(x, "enabled") ? int_of(x, "enabled") : 1); - ok = sqlite3_step(st) == SQLITE_DONE; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_indexer_remove(webui_store *s, const char *name) { - if (!s || !name) return false; - pthread_mutex_lock(&s->lock); - sqlite3_stmt *st = NULL; - bool ok = false; - if (sqlite3_prepare_v2(s->db, "DELETE FROM indexers WHERE name=?;", -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); - ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; - } - sqlite3_finalize(st); - pthread_mutex_unlock(&s->lock); - return ok; -} - -bool webui_store_indexer_list(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; -} - -/* --- legacy schema migration ---------------------------------------------- */ - -static bool table_has_column(sqlite3 *db, const char *table, const char *col) { - char sql[128]; - snprintf(sql, sizeof sql, "PRAGMA table_info(%s);", table); - sqlite3_stmt *st = NULL; - bool found = false; - if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK) { - while (sqlite3_step(st) == SQLITE_ROW) { - const char *n = (const char *)sqlite3_column_text(st, 1); /* 1 = name */ - if (n && strcmp(n, col) == 0) { found = true; break; } - } - } - sqlite3_finalize(st); - return found; -} - -static void legacy_migrate(webui_store *s) { - /* The tell-tale of the old schema: feeds carried an inline articles blob. */ - if (!table_has_column(s->db, "feeds", "articles")) return; - - /* Snapshot the legacy blobs first, then finalize before mutating. */ - json_t *feed_arts = json_object(); /* feed name -> articles array */ - json_t *rule_feeds = json_object(); /* rule name -> affectedFeeds array */ - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(s->db, "SELECT name, articles FROM feeds;", -1, &st, NULL) == SQLITE_OK) - while (sqlite3_step(st) == SQLITE_ROW) { - const char *f = (const char *)sqlite3_column_text(st, 0); - json_object_set_new(feed_arts, f ? f : "", array_col(st, 1)); - } - sqlite3_finalize(st); st = NULL; - if (table_has_column(s->db, "rules", "affected_feeds") && - sqlite3_prepare_v2(s->db, "SELECT name, affected_feeds FROM rules;", -1, &st, NULL) == SQLITE_OK) - while (sqlite3_step(st) == SQLITE_ROW) { - const char *n = (const char *)sqlite3_column_text(st, 0); - json_object_set_new(rule_feeds, n ? n : "", array_col(st, 1)); - } - sqlite3_finalize(st); st = NULL; - - sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL); - - /* Articles: insert oldest-first so autoincrement id tracks recency (the - * legacy array is newest-first). Preserve is_read / grabbed flags. */ - if (sqlite3_prepare_v2(s->db, - "INSERT OR IGNORE INTO articles" - " (feed,key,title,magnet,torrent_url,link,size,pub_date,is_read,grabbed,seen_at)" - " VALUES (?,?,?,?,?,?,?,?,?,?,?);", -1, &st, NULL) == SQLITE_OK) { - const char *feed; json_t *arts; - json_object_foreach(feed_arts, feed, arts) { - if (!json_is_array(arts)) continue; - for (long i = (long)json_array_size(arts) - 1; i >= 0; i--) { - json_t *a = json_array_get(arts, (size_t)i); - const char *key = str_or(a, "key", ""); - if (!*key) continue; - sqlite3_bind_text(st, 1, feed, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 3, str_or(a, "title", ""), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 4, str_or(a, "magnet", ""), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 5, str_or(a, "torrentUrl", ""), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 6, str_or(a, "link", ""), -1, SQLITE_TRANSIENT); - sqlite3_bind_int64(st, 7, (sqlite3_int64)json_integer_value(json_object_get(a, "size"))); - sqlite3_bind_text(st, 8, str_or(a, "pubDate", ""), -1, SQLITE_TRANSIENT); - sqlite3_bind_int(st, 9, int_of(a, "isRead")); - sqlite3_bind_int(st, 10, int_of(a, "grabbed")); - sqlite3_bind_int64(st, 11, (sqlite3_int64)time(NULL)); - sqlite3_step(st); - sqlite3_reset(st); - } - } - } - sqlite3_finalize(st); st = NULL; - - if (sqlite3_prepare_v2(s->db, - "INSERT OR IGNORE INTO rule_feeds (rule,feed) VALUES (?,?);", - -1, &st, NULL) == SQLITE_OK) { - const char *rule; json_t *feeds; - json_object_foreach(rule_feeds, rule, feeds) { - if (!json_is_array(feeds)) continue; - size_t i; json_t *v; - json_array_foreach(feeds, i, v) { - const char *fn = json_string_value(v); - if (!fn || !*fn) continue; - sqlite3_bind_text(st, 1, rule, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 2, fn, -1, SQLITE_TRANSIENT); - sqlite3_step(st); - sqlite3_reset(st); - } - } - } - sqlite3_finalize(st); st = NULL; - - /* Drop the legacy JSON columns by rebuilding feeds + rules. */ - sqlite3_exec(s->db, - "CREATE TABLE feeds_new (name TEXT PRIMARY KEY, url TEXT NOT NULL," - " last_update INTEGER NOT NULL DEFAULT 0);" - "INSERT INTO feeds_new (name,url,last_update) SELECT name,url,last_update FROM feeds;" - "DROP TABLE feeds;" - "ALTER TABLE feeds_new RENAME TO feeds;" - "CREATE TABLE rules_new (name TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1," - " use_regex INTEGER NOT NULL DEFAULT 0, add_paused INTEGER NOT NULL DEFAULT 0," - " must_contain TEXT NOT NULL DEFAULT '', must_not_contain TEXT NOT NULL DEFAULT ''," - " assigned_category TEXT NOT NULL DEFAULT '', save_path TEXT NOT NULL DEFAULT ''," - " last_match INTEGER NOT NULL DEFAULT 0);" - "INSERT INTO rules_new SELECT name,enabled,use_regex,add_paused,must_contain," - "must_not_contain,assigned_category,save_path,last_match FROM rules;" - "DROP TABLE rules;" - "ALTER TABLE rules_new RENAME TO rules;", - NULL, NULL, NULL); - - sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL); - json_decref(feed_arts); - json_decref(rule_feeds); -} diff --git a/plugins/webui/webui_store.h b/plugins/webui/webui_store.h deleted file mode 100644 index 7391b6f..0000000 --- a/plugins/webui/webui_store.h +++ /dev/null @@ -1,107 +0,0 @@ -/* webui_store.h — SQLite-backed persistence for all web-UI-owned state: - * accounts, the category/tag taxonomy, and RSS feeds/rules/indexers. - * - * Owned entirely by the webui plugin (the daemon persists none of this). - * Account passwords are PBKDF2-HMAC-SHA256 with a per-user random salt. All - * calls are thread-safe (the store serializes access to its SQLite handle). */ -#ifndef NAUT_WEBUI_STORE_H -#define NAUT_WEBUI_STORE_H - -#include -#include -#include - -typedef struct webui_store webui_store; - -/* Open (creating if needed) the account database at `path`. Returns NULL on - * failure. The schema is created/migrated on open. */ -webui_store *webui_store_open(const char *path); -void webui_store_close(webui_store *s); - -/* Number of accounts, or -1 on error. */ -int webui_store_user_count(webui_store *s); -/* Number of admin accounts, or -1 on error. */ -int webui_store_admin_count(webui_store *s); -bool webui_store_user_exists(webui_store *s, const char *username); - -/* Verify a username/password pair (constant-time). On success, copies the - * account's role ("admin"/"user") into role_out. */ -bool webui_store_verify(webui_store *s, const char *username, - const char *password, char *role_out, size_t role_sz); - -/* Create an account. `role` must be "admin" or "user" (defaults to "user" if - * NULL/invalid). Returns false if the username already exists or on error. */ -bool webui_store_create_user(webui_store *s, const char *username, - const char *password, const char *role); - -bool webui_store_set_password(webui_store *s, const char *username, - const char *password); -/* Change an account's role ("admin"/"user"). */ -bool webui_store_set_role(webui_store *s, const char *username, const char *role); -bool webui_store_delete_user(webui_store *s, const char *username); - -/* Append {username, role, createdAt} objects (sorted by username) to the - * json array `out`. Returns false on error. */ -bool webui_store_list_users(webui_store *s, json_t *out); - -/* --- sessions (persisted so logins survive daemon restarts) --------------- * - * Only a SHA-256 of the bearer token is stored, so a DB read can't be replayed - * as a live cookie. `expires` is an absolute unix time. */ -bool webui_store_session_create(webui_store *s, const char *token, - const char *user, const char *role, long expires); -/* On a live (unexpired) session, copies username/role and the stored expiry. */ -bool webui_store_session_lookup(webui_store *s, const char *token, - char *user, size_t user_sz, - char *role, size_t role_sz, long *expires_out); -bool webui_store_session_touch(webui_store *s, const char *token, long expires); -bool webui_store_session_delete(webui_store *s, const char *token); -bool webui_store_sessions_delete_user(webui_store *s, const char *user); -void webui_store_sessions_prune(webui_store *s, long now); - -/* --- category / tag taxonomy (web-UI organization, owned here) ------------- * - * The save_* calls replace the whole list atomically; the load_* calls append - * to the (array) `out`. Categories are {name, savePath}; tags are strings. */ -bool webui_store_save_categories(webui_store *s, json_t *cats); -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, articles, auto-download rules, Torznab indexers ----------- * - * Fully relational: articles live in their own table (deduped by feed+key, - * indexed), and a rule's feed scope lives in a rule_feeds join table. The web - * layer operates on rows, not whole-list blobs. */ - -/* Feeds. upsert preserves an existing feed's lastUpdate (only the url changes); - * remove also drops the feed's articles. feed_list appends - * {name,url,lastUpdate,articles:[...]} (newest article first). feed_targets - * appends lightweight {name,url} objects for the poller. */ -bool webui_store_feed_upsert(webui_store *s, const char *name, const char *url); -bool webui_store_feed_remove(webui_store *s, const char *name); -bool webui_store_feed_set_updated(webui_store *s, const char *name, long ts); -bool webui_store_feed_list(webui_store *s, json_t *out); -bool webui_store_feed_targets(webui_store *s, json_t *out); -bool webui_store_feed_exists(webui_store *s, const char *name); - -/* Articles. add inserts unless (feed,key) already exists: returns 1 if newly - * inserted, 0 if a duplicate, -1 on error. trim keeps the newest `keep` for a - * feed. mark_grabbed flags every article with this key. ungrabbed appends - * {feed,key,title,magnet,torrentUrl} for not-yet-grabbed articles. */ -int webui_store_article_add(webui_store *s, const char *feed, json_t *article); -bool webui_store_article_trim(webui_store *s, const char *feed, int keep); -bool webui_store_article_mark_grabbed(webui_store *s, const char *key); -bool webui_store_articles_ungrabbed(webui_store *s, json_t *out); - -/* Rules. upsert replaces the rule row and its feed scope; list/get assemble the - * rule with its affectedFeeds array. */ -bool webui_store_rule_upsert(webui_store *s, json_t *rule); -bool webui_store_rule_remove(webui_store *s, const char *name); -bool webui_store_rule_list(webui_store *s, json_t *out); -json_t *webui_store_rule_get(webui_store *s, const char *name); -bool webui_store_rule_set_match(webui_store *s, const char *name, long ts); - -/* Torznab indexers. */ -bool webui_store_indexer_upsert(webui_store *s, json_t *indexer); -bool webui_store_indexer_remove(webui_store *s, const char *name); -bool webui_store_indexer_list(webui_store *s, json_t *out); - -#endif /* NAUT_WEBUI_STORE_H */ diff --git a/src/discovery/tracker_client.c b/src/discovery/tracker_client.c index ef975be..97d1fe5 100644 --- a/src/discovery/tracker_client.c +++ b/src/discovery/tracker_client.c @@ -51,7 +51,6 @@ static naut_err collect_peers(const tracker_peer *peers, size_t count, const tracker_announce_response *resp, naut_tracker_response *out) { out->interval = (int32_t)resp->interval; - out->min_interval = (int32_t)resp->min_interval; out->seeders = (int32_t)resp->complete; out->leechers = (int32_t)resp->incomplete; out->peers = NULL;