/* 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; }; 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; }