diff --git a/CMakeLists.txt b/CMakeLists.txt index 86abe96..a1185e0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -220,9 +220,14 @@ 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 "") -add_library(naut_webui MODULE plugins/webui/webui.c) +# 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) target_include_directories(naut_webui PRIVATE ${CMAKE_SOURCE_DIR}/include) -target_link_libraries(naut_webui PRIVATE ${NAUT_JANSSON_TARGET} naut_net pthread) +target_link_libraries(naut_webui PRIVATE ${NAUT_JANSSON_TARGET} naut_net + PkgConfig::SQLITE3 OpenSSL::Crypto 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 e2caed3..4f4d7c0 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -14,4 +14,5 @@ - ✅ 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. \ No newline at end of file +- ✅ Rules should show their current matches. +- ✅ We need a real login system backed by a database. \ No newline at end of file diff --git a/apps/nautd/main.c b/apps/nautd/main.c index 0199ff4..f313421 100644 --- a/apps/nautd/main.c +++ b/apps/nautd/main.c @@ -114,15 +114,6 @@ struct daemon_state { json_t *script_settings_schema; /* array of {key,label,type,default} */ json_t *script_settings; /* object: key -> value string (user-set)*/ char settings_file[PATH_MAX]; /* /script_settings.json */ - /* 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; @@ -1320,153 +1311,6 @@ 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) { @@ -2393,10 +2237,6 @@ static bool register_commands(daemon_state *state) { naut_rpc_register(state->rpc, "script_status", rpc_script_status, state) == NAUT_OK && naut_rpc_register(state->rpc, "update_script", rpc_update_script, state) == NAUT_OK && naut_rpc_register(state->rpc, "set_script_settings", rpc_set_script_settings, state) == NAUT_OK && - naut_rpc_register(state->rpc, "get_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; } @@ -2685,12 +2525,6 @@ static bool resolve_state_dir(daemon_state *state, const char *override) { "%s/script_settings.json", dir) >= sizeof state->settings_file) return false; - if ((size_t)snprintf(state->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; } @@ -2796,9 +2630,6 @@ int main(int argc, char **argv) { pthread_mutex_init(&state.script_lock, NULL); pthread_mutex_init(&state.settings_lock, NULL); load_script_settings(&state); /* user-set values; schema comes from the script */ - pthread_mutex_init(&state.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); @@ -2881,9 +2712,5 @@ int main(int argc, char **argv) { json_decref(state.script_settings); json_decref(state.script_settings_schema); pthread_mutex_destroy(&state.settings_lock); - 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 755320f..da0eea6 100644 --- a/apps/swarm/main.c +++ b/apps/swarm/main.c @@ -58,6 +58,10 @@ 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) @@ -65,6 +69,20 @@ 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; @@ -385,6 +403,7 @@ 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; @@ -399,6 +418,7 @@ 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) { @@ -451,6 +471,8 @@ 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; @@ -574,7 +596,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; + int32_t tracker_interval = 0, tracker_min_interval = 0; naut_swarm_tracker_stats tracker_stats[NAUT_SWARM_MAX_TRACKER_STATS]; uint32_t tracker_count = 0; if (config->num_peers > 0) { @@ -608,8 +630,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_stats, - tracker_count) || + &tracker_interval, &tracker_min_interval, + tracker_stats, tracker_count) || (neps < target_peers && !discover_dht(hash, &endpoints, &neps, &epcap))) { NAUT_ERROR("out of memory collecting discovered peers"); @@ -731,8 +753,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_stats, - tracker_count) || + &tracker_interval, &tracker_min_interval, + tracker_stats, tracker_count) || (neps < target_peers && !discover_dht(mi.infohash_v1, &endpoints, &neps, &epcap))) { NAUT_ERROR("out of memory collecting discovered peers"); @@ -787,10 +809,11 @@ 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 + (tracker_interval > 0 - ? tracker_delay_seconds(tracker_interval) - : TRACKER_FAILURE_RETRY_INTERVAL); + t0 + next_announce_delay(tracker_interval, tracker_min_interval, + (uint32_t)neps); double next_dht_lookup = t0 + DHT_REFRESH_INTERVAL; naut_err run_error = NAUT_OK; bool cancelled = false; @@ -827,21 +850,20 @@ 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; + int32_t interval = 0, min_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, tracker_stats, - tracker_count)) { + &interval, &min_interval, + tracker_stats, tracker_count)) { run_error = NAUT_ERR_NOMEM; break; } - next_tracker_announce = t + (interval > 0 - ? tracker_delay_seconds(interval) - : TRACKER_FAILURE_RETRY_INTERVAL); + next_tracker_announce = t + next_announce_delay( + interval, min_interval, ts.peers_connected); 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 ea16ee9..76ed8bb 100644 --- a/include/naut/tracker.h +++ b/include/naut/tracker.h @@ -29,6 +29,7 @@ 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 b83fb98..b3af7e0 100644 --- a/plugins/webui/webui.c +++ b/plugins/webui/webui.c @@ -1,5 +1,6 @@ #include "naut/naut_plugin.h" #include "naut/http_client.h" +#include "webui_store.h" #include @@ -28,18 +29,11 @@ #define DEFAULT_PORT 8080 #define READ_LIMIT (8u << 20) #define SESSION_COOKIE "naut_session" -#define SESSION_TTL_SECONDS (60 * 60 * 24 * 7) -#define MAX_SESSIONS 64 +#define SESSION_TTL_SECONDS (60 * 60 * 24 * 7) /* default; NAUT_SESSION_TTL */ #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 { @@ -54,8 +48,9 @@ typedef struct { naut_host_api host; char root[PATH_MAX]; char host_name[64]; - char auth_user[64]; - char auth_password[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 */ int port; int listener; bool generated_password; @@ -66,8 +61,6 @@ 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; @@ -91,18 +84,14 @@ typedef struct { json_t *tags; /* array of tag name strings */ json_t *assignments; /* object keyed by stringified torrent id */ - /* RSS: feeds + auto-download rules, polled by a background thread and - * persisted via the daemon blob store. Search indexers live here too. */ + /* 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. */ 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 { @@ -240,21 +229,6 @@ 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). */ @@ -280,26 +254,71 @@ 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 (!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; + if (!webui_store_create_user(g_webui.store, user, password, "admin")) + log_msg(0, "webui: failed to create the initial admin account"); } static const char *header_value(const char *headers, const char *end, @@ -346,58 +365,69 @@ static bool cookie_token(const char *headers, const char *end, return false; } -static bool current_user(const char *headers, const char *end) { - char token[96]; - if (!cookie_token(headers, end, token, sizeof 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; +/* 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; } - pthread_mutex_unlock(&g_webui.auth_lock); - return ok; + return SESSION_TTL_SECONDS; } -static bool create_session(char *out, size_t out_size) { +/* 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) { char token[96]; - 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; + 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; } - snprintf(slot->token, sizeof slot->token, "%s", token); - slot->expires = expires; - slot->used = true; - pthread_mutex_unlock(&g_webui.auth_lock); + 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; +} + +static bool create_session(const char *user, const char *role, + 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; 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 (!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); + if (g_webui.store && cookie_token(headers, end, token, sizeof token)) + webui_store_session_delete(g_webui.store, token); } static bool bad_static_path(const char *path) { @@ -904,39 +934,29 @@ 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); - 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); + 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); } } -/* Seed the category + tag lists from the daemon's persisted copy at startup. */ +/* Seed the category + tag lists from the database at startup. */ static void webui_load_taxonomy(void) { - 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"); + 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); pthread_mutex_lock(&g_webui.meta_lock); - 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); - } + 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); pthread_mutex_unlock(&g_webui.meta_lock); - json_decref(reply); } static bool store_get_name(uint64_t id, char *out, size_t out_size) { @@ -1317,15 +1337,7 @@ 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(); - 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); + if (g_webui.store) webui_store_indexer_list(g_webui.store, plugins); json_object_set_new(json, "searchPlugins", plugins); http_json(fd, 200, json); json_decref(json); @@ -1905,52 +1917,22 @@ 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 *magnet, const char *torrent_url, - const char *category, const char *save_path, - bool paused) { +static bool rss_download(const char *title, 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) { @@ -1976,6 +1958,7 @@ static bool rss_download(const char *magnet, const char *torrent_url, 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(); @@ -2019,65 +2002,54 @@ static bool rule_matches(json_t *rule, const char *feed_name, const char *title) return true; } -/* Mark the article with this key as grabbed (across all feeds), so an - * auto-download rule re-run won't fetch it again. */ +/* Mark every article with this key as grabbed, so a rule re-run skips it. */ static void rss_mark_grabbed(const char *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); + if (g_webui.store) webui_store_article_mark_grabbed(g_webui.store, key); } /* Download an article and, on success, flag it grabbed by key. */ -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); +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); if (ok) rss_mark_grabbed(key); return ok; } -/* Run every rule against a freshly-seen article; download the first match. */ +/* Run the auto-download rules against one freshly-seen article; download the + * first enabled rule that matches. */ 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_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) { + json_array_foreach(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")); - json_object_set_new(rule, "lastMatch", json_integer((json_int_t)time(NULL))); - fire = rule; + fire = true; break; } } - pthread_mutex_unlock(&g_webui.rss_lock); + json_decref(rules); if (!fire) return; - if (rss_grab_article(key, magnet, torrent_url, cat, path, paused)) + 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)) log_msg(2, "rss: auto-downloaded a match"); } -/* Parse a feed body into article objects and merge new ones into `feed`. - * Newly-seen articles are appended to `out_new` (as {title,magnet,torrentUrl}) - * so the caller can fire auto-download rules AFTER releasing rss_lock — running - * them here would re-enter the lock (and do network I/O while holding it). - * Returns the number of newly-seen articles. */ -static int rss_ingest(json_t *feed, const char *xml, size_t len, json_t *out_new) { - json_t *articles = json_object_get(feed, "articles"); - if (!json_is_array(articles)) { - articles = json_array(); - json_object_set_new(feed, "articles", articles); - } +/* 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; int added = 0; const char *p = xml, *end = xml + len; for (;;) { @@ -2112,80 +2084,65 @@ static int rss_ingest(json_t *feed, const char *xml, size_t len, json_t *out_new const char *key = magnet[0] ? magnet : (enclosure[0] ? enclosure : link); if (title[0] && key && *key) { - /* 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); + 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) { added++; if (out_new) - 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)); + 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)); } } p = close + strlen(close_tag); } - /* 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))); + 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)); return added; } -/* Poll one feed (network I/O done without rss_lock held). */ -static void rss_poll_feed_by_index(size_t idx) { - pthread_mutex_lock(&g_webui.rss_lock); - json_t *feed = json_array_get(g_webui.rss_feeds, idx); - char url[1024] = {0}; - if (feed) snprintf(url, sizeof url, "%s", json_string_or(feed, "url", "")); - pthread_mutex_unlock(&g_webui.rss_lock); - if (!url[0]) return; - +/* 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; naut_http_response r; if (naut_http_get(url, &r) != NAUT_OK || r.status / 100 != 2 || !r.body) { naut_http_response_free(&r); return; } - char feed_name[256] = {0}; json_t *new_articles = json_array(); - pthread_mutex_lock(&g_webui.rss_lock); - feed = json_array_get(g_webui.rss_feeds, idx); /* re-fetch under lock */ - int added = feed ? rss_ingest(feed, r.body, r.body_len, new_articles) : 0; - if (feed) snprintf(feed_name, sizeof feed_name, "%s", json_string_or(feed, "name", "")); - pthread_mutex_unlock(&g_webui.rss_lock); + rss_ingest(name, r.body, r.body_len, new_articles); naut_http_response_free(&r); - - /* fire auto-download rules now that rss_lock is released */ + /* fire auto-download rules for the newly-seen articles */ size_t i; json_t *a; json_array_foreach(new_articles, i, a) - rss_run_rules(feed_name, json_string_or(a, "key", ""), + rss_run_rules(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) { - 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); + 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); } 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); @@ -2201,15 +2158,21 @@ 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) { - 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); + 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); } /* POST /api/rss {name,url} adds a feed; POST /api/rss/delete {name} removes. */ @@ -2217,62 +2180,33 @@ static void api_rss_feed(int fd, const char *body, size_t len, bool remove) { json_t *req = read_body_json(body, len); const char *name = json_string_value(json_object_get(req, "name")); const char *url = json_string_value(json_object_get(req, "url")); - bool changed = false; - pthread_mutex_lock(&g_webui.rss_lock); - if (remove && name) { - size_t i; json_t *f; - json_array_foreach(g_webui.rss_feeds, i, f) - if (strcmp(json_string_or(f, "name", ""), name) == 0) { - json_array_remove(g_webui.rss_feeds, i); changed = true; break; - } - } else if (name && *name && url && *url) { - /* upsert by name */ - size_t i; json_t *f; bool found = false; - json_array_foreach(g_webui.rss_feeds, i, f) - if (strcmp(json_string_or(f, "name", ""), name) == 0) { - json_object_set_new(f, "url", json_string(url)); found = true; break; - } - if (!found) - json_array_append_new(g_webui.rss_feeds, json_pack( - "{s:s,s:s,s:i,s:[]}", "name", name, "url", url, - "lastUpdate", 0, "articles")); - changed = true; + 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); } - pthread_mutex_unlock(&g_webui.rss_lock); json_decref(req); - if (changed) { - rss_save(); - pthread_mutex_lock(&g_webui.rss_lock); - g_webui.rss_wake = true; - pthread_cond_signal(&g_webui.rss_cond); /* re-poll the new feed now */ - pthread_mutex_unlock(&g_webui.rss_lock); - } + if (added) rss_signal_wake(); /* re-poll the new feed now */ api_rss_list(fd); } /* GET /api/rss/rules → array of rules. */ static void api_rss_rules_list(int fd) { - pthread_mutex_lock(&g_webui.rss_lock); - json_t *reply = json_deep_copy(g_webui.rss_rules); - pthread_mutex_unlock(&g_webui.rss_lock); - http_json(fd, 200, reply ? reply : json_array()); - json_decref(reply); + 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); } /* POST /api/rss/rules upserts a rule; POST /api/rss/rules/delete removes one. */ static void api_rss_rule(int fd, const char *body, size_t len, bool remove) { json_t *req = read_body_json(body, len); const char *name = json_string_value(json_object_get(req, "name")); - bool changed = false; - pthread_mutex_lock(&g_webui.rss_lock); - if (name && *name) { - size_t i; json_t *r; int at = -1; - json_array_foreach(g_webui.rss_rules, i, r) - if (strcmp(json_string_or(r, "name", ""), name) == 0) { at = (int)i; break; } + if (g_webui.store && name && *name) { if (remove) { - if (at >= 0) { json_array_remove(g_webui.rss_rules, (size_t)at); changed = true; } + webui_store_rule_remove(g_webui.store, name); } else { - json_t *rule = json_pack("{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:O,s:i}", + json_t *rule = json_pack("{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:i}", "name", name, "enabled", json_boolean_value(json_object_get(req, "enabled")), "useRegex", json_boolean_value(json_object_get(req, "useRegex")), @@ -2281,85 +2215,66 @@ 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) { - 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; + 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); } } } - 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 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. */ +/* 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. */ static void api_rss_rule_run(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 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); + const char *rname = json_string_value(json_object_get(req, "name")); + char name[128] = {0}; + if (rname) snprintf(name, sizeof name, "%s", rname); 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; - 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)) + 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)) grabbed++; size_t matched = json_array_size(todo); json_decref(todo); - if (grabbed > 0) rss_save(); + if (matched) webui_store_rule_set_match(g_webui.store, name, (long)time(NULL)); - 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, 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). */ +/* POST /api/rss/download {magnet|torrentUrl, title, key, category, savePath, + * paused} — manually grab a torrent from a feed article or search result. */ 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", ""); @@ -2367,9 +2282,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, magnet, url, cat, path, paused); - if (ok && *key) rss_save(); /* persist the grabbed flag */ + bool ok = rss_grab_article(key, title, magnet, url, cat, path, paused); json_decref(req); if (ok) { json_t *reply = json_pack("{s:b}", "ok", 1); @@ -2380,27 +2295,24 @@ 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), running - * the network fetch synchronously so the response reflects fresh articles. */ +/* POST /api/rss/refresh {name?} — re-poll a feed now (or all feeds). */ 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")); - /* 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); + char target[256] = {0}; + if (name) snprintf(target, sizeof target, "%s", name); json_decref(req); - 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); + 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); } api_rss_list(fd); } @@ -2409,34 +2321,14 @@ 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")); - 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; - } - } + 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); } - pthread_mutex_unlock(&g_webui.rss_lock); json_decref(req); - if (changed) rss_save(); - pthread_mutex_lock(&g_webui.rss_lock); - json_t *reply = json_deep_copy(g_webui.indexers); - pthread_mutex_unlock(&g_webui.rss_lock); - http_json(fd, 200, reply ? reply : json_array()); + json_t *reply = json_array(); + if (g_webui.store) webui_store_indexer_list(g_webui.store, reply); + http_json(fd, 200, reply); json_decref(reply); } @@ -2498,10 +2390,8 @@ 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(); - /* 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); + json_t *indexers = json_array(); + if (g_webui.store) webui_store_indexer_list(g_webui.store, indexers); size_t i; json_t *ix; json_array_foreach(indexers, i, ix) { @@ -2657,6 +2547,156 @@ 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) { @@ -2665,13 +2705,15 @@ 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) { - 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); + 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); http_json(fd, 200, json); json_decref(json); } else if (strcmp(path, "/api/login") == 0 && strcmp(method, "POST") == 0) { @@ -2679,10 +2721,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")); - 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) { + char role[16] = {0}; + bool ok = g_webui.store && user && password && + webui_store_verify(g_webui.store, user, password, role, sizeof role); + if (!ok) { json_t *json = json_pack("{s:b,s:s}", "ok", 0, "error", "invalid credentials"); http_json(fd, 401, json); @@ -2691,7 +2733,7 @@ static void handle_api(int fd, const char *method, char *path, return; } char token[96]; - if (!create_session(token, sizeof token)) { + if (!create_session(user, role, token, sizeof token)) { json_decref(req); http_text(fd, 500, "Internal Server Error", "session failed"); return; @@ -2699,10 +2741,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=%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); + "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); http_json_extra(fd, 200, json, cookie); json_decref(json); json_decref(req); @@ -2713,7 +2755,8 @@ 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_user(headers, headers_end)) { + } else if (!current_identity(headers, headers_end, cur_user, sizeof cur_user, + cur_role, sizeof cur_role)) { json_t *json = json_pack("{s:s}", "error", "authentication required"); http_json(fd, 401, json); @@ -2759,6 +2802,25 @@ 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) { @@ -3005,15 +3067,14 @@ 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)"); - 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); + 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); 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; } @@ -3026,10 +3087,8 @@ 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.auth_lock, NULL) != 0) - return NAUT_ERR_NOMEM; if (pthread_mutex_init(&g_webui.conn_lock, NULL) != 0) - goto fail_conn_lock; + return NAUT_ERR_NOMEM; if (pthread_cond_init(&g_webui.conn_cond, NULL) != 0) goto fail_conn_cond; if (pthread_mutex_init(&g_webui.snap_lock, NULL) != 0) @@ -3047,10 +3106,9 @@ 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; @@ -3077,8 +3135,6 @@ 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; } @@ -3122,13 +3178,6 @@ 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); @@ -3138,6 +3187,7 @@ 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); - pthread_mutex_destroy(&g_webui.auth_lock); + webui_store_close(g_webui.store); + g_webui.store = NULL; return NAUT_OK; } diff --git a/plugins/webui/webui_store.c b/plugins/webui/webui_store.c new file mode 100644 index 0000000..9b69089 --- /dev/null +++ b/plugins/webui/webui_store.c @@ -0,0 +1,1135 @@ +/* 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 new file mode 100644 index 0000000..7391b6f --- /dev/null +++ b/plugins/webui/webui_store.h @@ -0,0 +1,107 @@ +/* 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 97d1fe5..ef975be 100644 --- a/src/discovery/tracker_client.c +++ b/src/discovery/tracker_client.c @@ -51,6 +51,7 @@ 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;