diff --git a/apps/nautd/main.c b/apps/nautd/main.c index 0199ff4..85f64a0 100644 --- a/apps/nautd/main.c +++ b/apps/nautd/main.c @@ -114,13 +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; @@ -1320,89 +1313,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 @@ -2393,8 +2303,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 && @@ -2685,9 +2593,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; @@ -2796,8 +2701,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(); @@ -2881,9 +2784,6 @@ 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/plugins/webui/webui.c b/plugins/webui/webui.c index 040b202..e47cc76 100644 --- a/plugins/webui/webui.c +++ b/plugins/webui/webui.c @@ -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; } diff --git a/plugins/webui/webui_store.c b/plugins/webui/webui_store.c index a3c4796..4a56889 100644 --- a/plugins/webui/webui_store.c +++ b/plugins/webui/webui_store.c @@ -73,7 +73,32 @@ webui_store *webui_store_open(const char *path) { " pw_salt TEXT NOT NULL," " pw_iters INTEGER NOT NULL," " role TEXT NOT NULL DEFAULT 'user'," - " created_at INTEGER NOT NULL);"; + " created_at INTEGER NOT NULL);" + "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," + " articles TEXT NOT NULL DEFAULT '[]');" + "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 ''," + " affected_feeds TEXT NOT NULL DEFAULT '[]'," + " last_match INTEGER NOT NULL DEFAULT 0);" + "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); @@ -272,3 +297,94 @@ bool webui_store_list_users(webui_store *s, json_t *out) { pthread_mutex_unlock(&s->lock); return ok; } + +/* --- 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; +} diff --git a/plugins/webui/webui_store.h b/plugins/webui/webui_store.h index e183842..6fcfdf4 100644 --- a/plugins/webui/webui_store.h +++ b/plugins/webui/webui_store.h @@ -44,4 +44,12 @@ bool webui_store_delete_user(webui_store *s, const char *username); * json array `out`. Returns false on error. */ bool webui_store_list_users(webui_store *s, json_t *out); +/* --- 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); + #endif /* NAUT_WEBUI_STORE_H */