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>
This commit is contained in:
ookami125 2026-06-23 22:05:22 -04:00
parent 1b79fe8079
commit 9474e6f31a
4 changed files with 160 additions and 146 deletions

View file

@ -59,7 +59,7 @@ typedef struct {
char host_name[64];
char auth_user[64]; /* bootstrap admin name (for startup banner) */
char auth_password[64]; /* generated bootstrap password (banner only) */
webui_store *auth; /* SQLite-backed account store */
webui_store *store; /* SQLite store: accounts, taxonomy, RSS */
int port;
int listener;
bool generated_password;
@ -308,8 +308,8 @@ static void init_auth(void) {
log_msg(0, "webui: cannot resolve account DB path; set NAUT_WEBUI_DB");
return;
}
g_webui.auth = webui_store_open(db_path);
if (!g_webui.auth) {
g_webui.store = webui_store_open(db_path);
if (!g_webui.store) {
log_msg(0, "webui: failed to open account database");
return;
}
@ -318,7 +318,7 @@ static void init_auth(void) {
if (!user || !*user) user = "admin";
snprintf(g_webui.auth_user, sizeof g_webui.auth_user, "%s", user);
if (webui_store_user_count(g_webui.auth) > 0) return; /* already set up */
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");
@ -332,7 +332,7 @@ static void init_auth(void) {
log_msg(0, "webui: no CSPRNG; set NAUT_AUTH_PASSWORD to create the admin");
return;
}
if (!webui_store_create_user(g_webui.auth, user, password, "admin"))
if (!webui_store_create_user(g_webui.store, user, password, "admin"))
log_msg(0, "webui: failed to create the initial admin account");
}
@ -959,39 +959,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) {
@ -2736,7 +2726,7 @@ static bool valid_username(const char *u) {
/* GET /api/users → [{username, role, createdAt}] (admin only). */
static void api_users_list(int fd) {
json_t *users = json_array();
if (g_webui.auth) webui_store_list_users(g_webui.auth, users);
if (g_webui.store) webui_store_list_users(g_webui.store, users);
http_json(fd, 200, users);
json_decref(users);
}
@ -2753,8 +2743,8 @@ static void api_user_create(int fd, const char *body, size_t len) {
"username (letters/digits/._-) and password are required");
return;
}
bool ok = g_webui.auth &&
webui_store_create_user(g_webui.auth, user, pass,
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; }
@ -2773,18 +2763,18 @@ static void api_user_delete(int fd, const char *body, size_t len,
char role[16] = {0};
/* Look up the target's role to guard the last-admin rule. */
json_t *list = json_array();
if (g_webui.auth) webui_store_list_users(g_webui.auth, list);
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.auth) <= 1) {
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.auth && webui_store_delete_user(g_webui.auth, user);
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);
@ -2804,7 +2794,7 @@ static void api_user_set_password(int fd, const char *body, size_t len) {
}
char user[64];
snprintf(user, sizeof user, "%s", uname);
bool ok = g_webui.auth && webui_store_set_password(g_webui.auth, user, pass);
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 */
@ -2823,11 +2813,11 @@ static void api_user_set_role(int fd, const char *body, size_t len) {
http_text(fd, 400, "Bad Request", "username and role (admin|user) required");
return;
}
if (strcmp(role, "user") == 0 && webui_store_admin_count(g_webui.auth) <= 1) {
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.auth) webui_store_list_users(g_webui.auth, list);
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)
@ -2839,7 +2829,7 @@ static void api_user_set_role(int fd, const char *body, size_t len) {
return;
}
}
bool ok = g_webui.auth && webui_store_set_role(g_webui.auth, user, role);
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);
@ -2857,12 +2847,12 @@ static void api_account_password(int fd, const char *body, size_t len,
return;
}
char role[16] = {0};
if (!g_webui.auth || !webui_store_verify(g_webui.auth, actor, oldp, role, sizeof role)) {
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.auth, actor, newp);
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);
@ -2895,8 +2885,8 @@ static void handle_api(int fd, const char *method, char *path,
const char *password =
json_string_value(json_object_get(req, "password"));
char role[16] = {0};
bool ok = g_webui.auth && user && password &&
webui_store_verify(g_webui.auth, user, password, role, sizeof role);
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");
@ -3240,7 +3230,7 @@ static naut_err start_server(void) {
if (strcmp(g_webui.host_name, DEFAULT_HOST) != 0)
log_msg(1, "webui: bound to a non-loopback address; credentials cross "
"the network in plaintext (set NAUT_AUTH_PASSWORD)");
if (!g_webui.auth) {
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. */
@ -3373,7 +3363,7 @@ naut_err naut_plugin_shutdown(void) {
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.auth);
g_webui.auth = NULL;
webui_store_close(g_webui.store);
g_webui.store = NULL;
return NAUT_OK;
}