Compare commits

...

10 commits

Author SHA1 Message Date
ookami125
252bc8140b Merge webui-plugin into main 2026-06-24 23:02:32 -04:00
ookami125
067b62c23a webui: persist login sessions so they survive restarts
Sessions lived in an in-memory array, so every daemon restart wiped them
and forced a re-login. Move them into the webui DB:

- New sessions table storing a SHA-256 of the bearer token (never the
  raw token, so a DB read can't be replayed), the user, role, and an
  absolute expiry.
- create/lookup/touch/delete + per-user delete + prune in webui_store.
- Login persists the session; auth checks validate against the DB with a
  throttled sliding expiry (re-extended at most hourly to avoid a write
  per request); logout and admin reset/delete drop the rows. Expired
  rows are reaped lazily on lookup and pruned at startup.
- TTL is configurable via NAUT_SESSION_TTL (default 7 days) and drives
  the cookie Max-Age. Removes the in-memory session array + auth_lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:00:36 -04:00
ookami125
096535292d swarm: re-announce promptly when the swarm is starved
The peer top-up loop re-announced to trackers only at the full advertised
interval (~30 min), so a torrent that dropped to a handful of peers would
sit there for up to half an hour with only the 5-minute DHT refresh to
help — looking like the re-announce system was dead.

Now, when fewer than LOW_PEER_THRESHOLD (10) peers are connected, the next
tracker announce is scheduled at the tracker's min_interval floor (never
below 60s) instead of the full interval, so a thin swarm actually tries to
recover. Once peers recover the full interval is used again. Capture the
tracker's min_interval (was being dropped) to stay announce-compliant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:04:24 -04:00
ookami125
a0265cc1ea webui: normalize the RSS schema (articles + rule_feeds tables)
Replace the JSON-blob columns (feeds.articles, rules.affected_feeds)
with proper relational tables:

- articles(feed,key,…,is_read,grabbed) with UNIQUE(feed,key) and indexes,
  so dedup and grabbed become INSERT OR IGNORE / WHERE-key queries and a
  poll inserts only new rows instead of rewriting the whole feed blob.
- rule_feeds(rule,feed) join table for a rule's feed scope.

The webui RSS engine now operates on rows via a row-level store API
(feed/article/rule/indexer upsert/list/etc.) instead of holding the
feeds/rules/indexers in memory and saving whole lists; the in-memory
copies and rss_save/rss_load are gone. The poller, the auto-download
rules, force-run, manual download, refresh and search all read/write the
DB directly. A one-time migration upgrades an existing webui.db in place
(moving the old JSON blobs into the new tables, preserving article
read/grabbed flags and rule scoping, then dropping the legacy columns).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:59:36 -04:00
ookami125
91d4b99aee webui: store RSS feeds/rules/indexers in the webui DB
Move RSS persistence out of the daemon blob store and into the webui's
own SQLite DB (feeds, rules, indexers tables; articles + affectedFeeds
held as JSON columns). Remove the now-unused daemon blob store
(set/get_webui_blob, blob_lock, data_dir).

With this, all webui-owned state — accounts, taxonomy, RSS — lives in
the webui DB; the daemon only keeps naut's own data (per-torrent labels
still flow through set_labels for Lua).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:11:17 -04:00
ookami125
9474e6f31a webui: store category/tag taxonomy in the webui DB
Move the category + tag lists out of the daemon (labels.json + the
get/set_label_taxonomy RPCs) and into the webui's own SQLite DB
(categories, tags tables). The daemon no longer persists webui taxonomy;
per-torrent labels still flow through set_labels for Lua.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:05:22 -04:00
ookami125
1b79fe8079 webui: rename auth_store → webui_store
The plugin's SQLite store will own more than accounts (taxonomy, RSS),
so give it a general name. Mechanical rename of files + symbols; default
DB filename is now webui.db. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:59:30 -04:00
ookami125
ab51774554 ISSUES: real login system backed by a database done (#18)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:48:05 -04:00
ookami125
a4ec585aed webui: real multi-user accounts (SQLite + PBKDF2)
Replace the single env/generated password with a proper account system,
owned entirely by the webui plugin:

- auth_store: SQLite users table, PBKDF2-HMAC-SHA256 password hashing
  (per-user salt, 210k iterations) via OpenSSL. DB at NAUT_WEBUI_DB or
  an XDG default. Thread-safe (serialized connection).
- Login verifies against the DB; sessions now carry the username + role.
  First run bootstraps an admin from NAUT_AUTH_USER/PASSWORD or a
  generated password (logged once).
- Admin-only user management: GET/POST /api/users, /api/users/delete,
  /api/users/password, /api/users/role. Self-service POST
  /api/account/password. Guards the last admin and invalidates a user's
  sessions on delete or password reset.
- /api/auth/status and /api/login now return the role.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:43:24 -04:00
ookami125
ab733cb573 webui: name RSS downloads after the article title
Auto/manual RSS downloads now forward the article (or search result)
title as the torrent's display name, so fetched .torrent enclosures no
longer show as "upload-XXXXXX". Threaded the title through rss_download
/ rss_grab_article and set the web-layer display name on success.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:59:06 -04:00
9 changed files with 1761 additions and 612 deletions

View file

@ -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 ---------

View file

@ -15,3 +15,4 @@
- ✅ 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 <link>/Atom href had no source).
- ✅ Rules should show their current matches.
- ✅ We need a real login system backed by a database.

View file

@ -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]; /* <state_dir>/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]; /* <state_dir>/labels.json */
char data_dir[PATH_MAX]; /* the resolved state dir (for blobs) */
pthread_mutex_t blob_lock; /* guards webui_<key>.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 <state_dir>/webui_<key>.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;
}

View file

@ -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) {

View file

@ -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;

File diff suppressed because it is too large Load diff

1135
plugins/webui/webui_store.c Normal file

File diff suppressed because it is too large Load diff

107
plugins/webui/webui_store.h Normal file
View file

@ -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 <stdbool.h>
#include <stddef.h>
#include <jansson.h>
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 */

View file

@ -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;