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;