Naut/plugins/webui/webui_store.c
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

542 lines
21 KiB
C

/* webui_store.c — SQLite + PBKDF2 implementation of the web-UI account store. */
#include "webui_store.h"
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sqlite3.h>
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <openssl/crypto.h>
#define PBKDF2_ITERS 210000
#define SALT_BYTES 16
#define HASH_BYTES 32
struct webui_store {
sqlite3 *db;
pthread_mutex_t lock;
};
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 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);
webui_store_close(s);
return NULL;
}
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;
}
/* --- 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 ------------------------------------------------------------------ */
static int int_of(json_t *o, const char *k) {
return json_boolean_value(json_object_get(o, k)) ? 1 : 0;
}
/* Bind a json array column as a compact JSON string (SQLite copies it). */
static void bind_json_array(sqlite3_stmt *st, int col, json_t *arr) {
char *s = json_dumps(json_is_array(arr) ? arr : json_array(), JSON_COMPACT);
sqlite3_bind_text(st, col, s ? s : "[]", -1, SQLITE_TRANSIENT);
free(s);
}
/* Parse a TEXT column holding a JSON array; returns a new array (never NULL). */
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();
}
static void bind_feed(sqlite3_stmt *st, json_t *f) {
sqlite3_bind_text(st, 1, str_or(f, "name", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 2, str_or(f, "url", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(st, 3, (sqlite3_int64)json_integer_value(json_object_get(f, "lastUpdate")));
bind_json_array(st, 4, json_object_get(f, "articles"));
}
bool webui_store_save_feeds(webui_store *s, json_t *feeds) {
return replace_table(s, "DELETE FROM feeds;",
"INSERT OR REPLACE INTO feeds (name, url, last_update, articles) VALUES (?,?,?,?);",
feeds, bind_feed);
}
bool webui_store_load_feeds(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, articles 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", array_col(st, 3)));
}
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
static void bind_rule(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);
bind_json_array(st, 9, json_object_get(r, "affectedFeeds"));
sqlite3_bind_int64(st, 10, (sqlite3_int64)json_integer_value(json_object_get(r, "lastMatch")));
}
bool webui_store_save_rules(webui_store *s, json_t *rules) {
return replace_table(s, "DELETE FROM rules;",
"INSERT OR REPLACE INTO rules (name, enabled, use_regex, add_paused,"
" must_contain, must_not_contain, assigned_category, save_path,"
" affected_feeds, last_match) VALUES (?,?,?,?,?,?,?,?,?,?);",
rules, bind_rule);
}
bool webui_store_load_rules(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, enabled, use_regex, add_paused, must_contain,"
" must_not_contain, assigned_category, save_path, affected_feeds,"
" last_match FROM rules 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 *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);
json_array_append_new(out, 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", array_col(st, 8),
"lastMatch", (json_int_t)sqlite3_column_int64(st, 9)));
}
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
static void bind_indexer(sqlite3_stmt *st, json_t *x) {
sqlite3_bind_text(st, 1, str_or(x, "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, int_of(x, "enabled"));
}
bool webui_store_save_indexers(webui_store *s, json_t *indexers) {
return replace_table(s, "DELETE FROM indexers;",
"INSERT OR REPLACE INTO indexers (name, url, apikey, enabled) VALUES (?,?,?,?);",
indexers, bind_indexer);
}
bool webui_store_load_indexers(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;
}