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>
1135 lines
45 KiB
C
1135 lines
45 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;
|
|
};
|
|
|
|
/* One-time upgrade of a pre-normalization DB (feeds.articles / rules.affected_feeds
|
|
* JSON columns) to the relational articles + rule_feeds tables. Defined at the
|
|
* end of the file so it can use the row helpers. */
|
|
static void legacy_migrate(webui_store *s);
|
|
|
|
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 sessions ("
|
|
" token_hash TEXT PRIMARY KEY,"
|
|
" username TEXT NOT NULL,"
|
|
" role TEXT NOT NULL DEFAULT 'user',"
|
|
" expires INTEGER NOT NULL);"
|
|
"CREATE INDEX IF NOT EXISTS sessions_user ON sessions(username);"
|
|
"CREATE INDEX IF NOT EXISTS sessions_expires ON sessions(expires);"
|
|
"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);"
|
|
"CREATE TABLE IF NOT EXISTS articles ("
|
|
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
" feed TEXT NOT NULL,"
|
|
" key TEXT NOT NULL,"
|
|
" title TEXT NOT NULL DEFAULT '',"
|
|
" magnet TEXT NOT NULL DEFAULT '',"
|
|
" torrent_url TEXT NOT NULL DEFAULT '',"
|
|
" link TEXT NOT NULL DEFAULT '',"
|
|
" size INTEGER NOT NULL DEFAULT 0,"
|
|
" pub_date TEXT NOT NULL DEFAULT '',"
|
|
" is_read INTEGER NOT NULL DEFAULT 0,"
|
|
" grabbed INTEGER NOT NULL DEFAULT 0,"
|
|
" seen_at INTEGER NOT NULL DEFAULT 0,"
|
|
" UNIQUE(feed, key));"
|
|
"CREATE INDEX IF NOT EXISTS articles_feed ON articles(feed);"
|
|
"CREATE INDEX IF NOT EXISTS articles_key ON articles(key);"
|
|
"CREATE INDEX IF NOT EXISTS articles_grabbed ON articles(grabbed);"
|
|
"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 '',"
|
|
" last_match INTEGER NOT NULL DEFAULT 0);"
|
|
"CREATE TABLE IF NOT EXISTS rule_feeds ("
|
|
" rule TEXT NOT NULL,"
|
|
" feed TEXT NOT NULL,"
|
|
" PRIMARY KEY(rule, feed));"
|
|
"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;
|
|
}
|
|
legacy_migrate(s); /* upgrade an older DB's RSS schema in place */
|
|
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;
|
|
}
|
|
|
|
/* --- sessions ------------------------------------------------------------- */
|
|
|
|
/* SHA-256 of a bearer token, hex-encoded. We persist only this, never the raw
|
|
* token, so a DB leak can't be replayed as a live cookie. */
|
|
static void sha256_hex(const char *token, char out[65]) {
|
|
unsigned char d[32];
|
|
unsigned int dl = 0;
|
|
EVP_Digest(token, strlen(token), d, &dl, EVP_sha256(), NULL);
|
|
to_hex(d, 32, out);
|
|
}
|
|
|
|
bool webui_store_session_create(webui_store *s, const char *token,
|
|
const char *user, const char *role,
|
|
long expires) {
|
|
if (!s || !token || !*token || !user || !*user) return false;
|
|
char th[65];
|
|
sha256_hex(token, th);
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db,
|
|
"INSERT OR REPLACE INTO sessions (token_hash,username,role,expires)"
|
|
" VALUES (?,?,?,?);", -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, th, -1, SQLITE_STATIC);
|
|
sqlite3_bind_text(st, 2, user, -1, SQLITE_STATIC);
|
|
sqlite3_bind_text(st, 3, role && *role ? role : "user", -1, SQLITE_STATIC);
|
|
sqlite3_bind_int64(st, 4, (sqlite3_int64)expires);
|
|
ok = sqlite3_step(st) == SQLITE_DONE;
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
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) {
|
|
if (!s || !token || !*token) return false;
|
|
char th[65];
|
|
sha256_hex(token, th);
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db,
|
|
"SELECT username, role, expires FROM sessions WHERE token_hash=?;",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, th, -1, SQLITE_STATIC);
|
|
if (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);
|
|
if (user) snprintf(user, user_sz, "%s", u ? u : "");
|
|
if (role) snprintf(role, role_sz, "%s", r ? r : "user");
|
|
if (expires_out) *expires_out = (long)sqlite3_column_int64(st, 2);
|
|
ok = true;
|
|
}
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
bool webui_store_session_touch(webui_store *s, const char *token, long expires) {
|
|
if (!s || !token) return false;
|
|
char th[65];
|
|
sha256_hex(token, th);
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db,
|
|
"UPDATE sessions SET expires=? WHERE token_hash=?;",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_int64(st, 1, (sqlite3_int64)expires);
|
|
sqlite3_bind_text(st, 2, th, -1, SQLITE_STATIC);
|
|
ok = sqlite3_step(st) == SQLITE_DONE;
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
bool webui_store_session_delete(webui_store *s, const char *token) {
|
|
if (!s || !token) return false;
|
|
char th[65];
|
|
sha256_hex(token, th);
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db, "DELETE FROM sessions WHERE token_hash=?;",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, th, -1, SQLITE_STATIC);
|
|
ok = sqlite3_step(st) == SQLITE_DONE;
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
bool webui_store_sessions_delete_user(webui_store *s, const char *user) {
|
|
if (!s || !user) return false;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db, "DELETE FROM sessions WHERE username=?;",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, user, -1, SQLITE_STATIC);
|
|
ok = sqlite3_step(st) == SQLITE_DONE;
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
void webui_store_sessions_prune(webui_store *s, long now) {
|
|
if (!s) return;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
if (sqlite3_prepare_v2(s->db, "DELETE FROM sessions WHERE expires<=?;",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_int64(st, 1, (sqlite3_int64)now);
|
|
sqlite3_step(st);
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
}
|
|
|
|
/* --- 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 (fully relational) ----------------------------------------------- */
|
|
|
|
static int int_of(json_t *o, const char *k) {
|
|
return json_boolean_value(json_object_get(o, k)) ? 1 : 0;
|
|
}
|
|
|
|
/* Parse a TEXT column holding a JSON array; returns a new array (never NULL).
|
|
* Only used by the legacy-schema migration. */
|
|
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();
|
|
}
|
|
|
|
/* ---- feeds ---- */
|
|
|
|
bool webui_store_feed_upsert(webui_store *s, const char *name, const char *url) {
|
|
if (!s || !name || !*name || !url || !*url) return false;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db,
|
|
"INSERT INTO feeds (name, url, last_update) VALUES (?,?,0)"
|
|
" ON CONFLICT(name) DO UPDATE SET url=excluded.url;",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
|
|
sqlite3_bind_text(st, 2, url, -1, SQLITE_STATIC);
|
|
ok = sqlite3_step(st) == SQLITE_DONE;
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
bool webui_store_feed_remove(webui_store *s, const char *name) {
|
|
if (!s || !name) return false;
|
|
pthread_mutex_lock(&s->lock);
|
|
bool ok = false;
|
|
sqlite3_stmt *st = NULL;
|
|
sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL);
|
|
if (sqlite3_prepare_v2(s->db, "DELETE FROM articles WHERE feed=?;", -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
|
|
sqlite3_step(st);
|
|
}
|
|
sqlite3_finalize(st); st = NULL;
|
|
if (sqlite3_prepare_v2(s->db, "DELETE FROM feeds WHERE name=?;", -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
|
|
ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0;
|
|
}
|
|
sqlite3_finalize(st);
|
|
sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
bool webui_store_feed_set_updated(webui_store *s, const char *name, long ts) {
|
|
if (!s || !name) return false;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db, "UPDATE feeds SET last_update=? WHERE name=?;",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_int64(st, 1, (sqlite3_int64)ts);
|
|
sqlite3_bind_text(st, 2, name, -1, SQLITE_STATIC);
|
|
ok = sqlite3_step(st) == SQLITE_DONE;
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
bool webui_store_feed_exists(webui_store *s, const char *name) {
|
|
if (!s || !name) return false;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool found = false;
|
|
if (sqlite3_prepare_v2(s->db, "SELECT 1 FROM feeds WHERE name=?;", -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
|
|
found = sqlite3_step(st) == SQLITE_ROW;
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return found;
|
|
}
|
|
|
|
bool webui_store_feed_targets(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 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}",
|
|
"name", n ? n : "", "url", u ? u : ""));
|
|
}
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
/* Build the article array for one feed (newest first). Caller holds the lock. */
|
|
static json_t *feed_articles_locked(webui_store *s, const char *feed) {
|
|
json_t *arr = json_array();
|
|
sqlite3_stmt *st = NULL;
|
|
if (sqlite3_prepare_v2(s->db,
|
|
"SELECT key,title,magnet,torrent_url,link,size,pub_date,is_read,grabbed"
|
|
" FROM articles WHERE feed=? ORDER BY id DESC;", -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, feed, -1, SQLITE_STATIC);
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
const char *k = (const char *)sqlite3_column_text(st, 0);
|
|
const char *t = (const char *)sqlite3_column_text(st, 1);
|
|
const char *m = (const char *)sqlite3_column_text(st, 2);
|
|
const char *tu = (const char *)sqlite3_column_text(st, 3);
|
|
const char *ln = (const char *)sqlite3_column_text(st, 4);
|
|
const char *pd = (const char *)sqlite3_column_text(st, 6);
|
|
json_array_append_new(arr, json_pack(
|
|
"{s:s,s:s,s:s,s:s,s:s,s:I,s:s,s:b,s:b}",
|
|
"key", k ? k : "", "title", t ? t : "", "magnet", m ? m : "",
|
|
"torrentUrl", tu ? tu : "", "link", ln ? ln : "",
|
|
"size", (json_int_t)sqlite3_column_int64(st, 5),
|
|
"pubDate", pd ? pd : "", "isRead", sqlite3_column_int(st, 7),
|
|
"grabbed", sqlite3_column_int(st, 8)));
|
|
}
|
|
}
|
|
sqlite3_finalize(st);
|
|
return arr;
|
|
}
|
|
|
|
bool webui_store_feed_list(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 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", feed_articles_locked(s, n ? n : "")));
|
|
}
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
/* ---- articles ---- */
|
|
|
|
int webui_store_article_add(webui_store *s, const char *feed, json_t *a) {
|
|
if (!s || !feed || !json_is_object(a)) return -1;
|
|
const char *key = str_or(a, "key", "");
|
|
if (!*key) return -1;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
int rc = -1;
|
|
if (sqlite3_prepare_v2(s->db,
|
|
"INSERT OR IGNORE INTO articles"
|
|
" (feed,key,title,magnet,torrent_url,link,size,pub_date,is_read,grabbed,seen_at)"
|
|
" VALUES (?,?,?,?,?,?,?,?,0,0,?);", -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, feed, -1, SQLITE_STATIC);
|
|
sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_text(st, 3, str_or(a, "title", ""), -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_text(st, 4, str_or(a, "magnet", ""), -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_text(st, 5, str_or(a, "torrentUrl", ""), -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_text(st, 6, str_or(a, "link", ""), -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_int64(st, 7, (sqlite3_int64)json_integer_value(json_object_get(a, "size")));
|
|
sqlite3_bind_text(st, 8, str_or(a, "pubDate", ""), -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_int64(st, 9, (sqlite3_int64)time(NULL));
|
|
if (sqlite3_step(st) == SQLITE_DONE) rc = sqlite3_changes(s->db) > 0 ? 1 : 0;
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return rc;
|
|
}
|
|
|
|
bool webui_store_article_trim(webui_store *s, const char *feed, int keep) {
|
|
if (!s || !feed || keep < 0) return false;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db,
|
|
"DELETE FROM articles WHERE feed=? AND id NOT IN"
|
|
" (SELECT id FROM articles WHERE feed=? ORDER BY id DESC LIMIT ?);",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, feed, -1, SQLITE_STATIC);
|
|
sqlite3_bind_text(st, 2, feed, -1, SQLITE_STATIC);
|
|
sqlite3_bind_int(st, 3, keep);
|
|
ok = sqlite3_step(st) == SQLITE_DONE;
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
bool webui_store_article_mark_grabbed(webui_store *s, const char *key) {
|
|
if (!s || !key || !*key) return false;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db, "UPDATE articles SET grabbed=1 WHERE key=?;",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, key, -1, SQLITE_STATIC);
|
|
ok = sqlite3_step(st) == SQLITE_DONE;
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
bool webui_store_articles_ungrabbed(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 feed,key,title,magnet,torrent_url FROM articles"
|
|
" WHERE grabbed=0 AND (magnet<>'' OR torrent_url<>'') ORDER BY id DESC;",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
ok = true;
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
const char *f = (const char *)sqlite3_column_text(st, 0);
|
|
const char *k = (const char *)sqlite3_column_text(st, 1);
|
|
const char *t = (const char *)sqlite3_column_text(st, 2);
|
|
const char *m = (const char *)sqlite3_column_text(st, 3);
|
|
const char *u = (const char *)sqlite3_column_text(st, 4);
|
|
json_array_append_new(out, json_pack("{s:s,s:s,s:s,s:s,s:s}",
|
|
"feed", f ? f : "", "key", k ? k : "", "title", t ? t : "",
|
|
"magnet", m ? m : "", "torrentUrl", u ? u : ""));
|
|
}
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
/* ---- rules ---- */
|
|
|
|
static void bind_rule_row(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);
|
|
sqlite3_bind_int64(st, 9, (sqlite3_int64)json_integer_value(json_object_get(r, "lastMatch")));
|
|
}
|
|
|
|
bool webui_store_rule_upsert(webui_store *s, json_t *r) {
|
|
if (!s || !json_is_object(r)) return false;
|
|
const char *name = str_or(r, "name", "");
|
|
if (!*name) return false;
|
|
pthread_mutex_lock(&s->lock);
|
|
bool ok = sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL) == SQLITE_OK;
|
|
sqlite3_stmt *st = NULL;
|
|
if (ok && sqlite3_prepare_v2(s->db,
|
|
"INSERT OR REPLACE INTO rules (name,enabled,use_regex,add_paused,"
|
|
"must_contain,must_not_contain,assigned_category,save_path,last_match)"
|
|
" VALUES (?,?,?,?,?,?,?,?,?);", -1, &st, NULL) == SQLITE_OK) {
|
|
bind_rule_row(st, r);
|
|
ok = sqlite3_step(st) == SQLITE_DONE;
|
|
} else ok = false;
|
|
sqlite3_finalize(st); st = NULL;
|
|
if (ok && sqlite3_prepare_v2(s->db, "DELETE FROM rule_feeds WHERE rule=?;",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
|
|
ok = sqlite3_step(st) == SQLITE_DONE;
|
|
} else ok = false;
|
|
sqlite3_finalize(st); st = NULL;
|
|
json_t *feeds = json_object_get(r, "affectedFeeds");
|
|
if (ok && json_is_array(feeds) && sqlite3_prepare_v2(s->db,
|
|
"INSERT OR IGNORE INTO rule_feeds (rule,feed) VALUES (?,?);",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
size_t i; json_t *v;
|
|
json_array_foreach(feeds, i, v) {
|
|
const char *fn = json_string_value(v);
|
|
if (!fn || !*fn) continue;
|
|
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
|
|
sqlite3_bind_text(st, 2, fn, -1, SQLITE_TRANSIENT);
|
|
if (sqlite3_step(st) != SQLITE_DONE) { ok = false; break; }
|
|
sqlite3_reset(st);
|
|
}
|
|
}
|
|
sqlite3_finalize(st);
|
|
sqlite3_exec(s->db, ok ? "COMMIT;" : "ROLLBACK;", NULL, NULL, NULL);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
bool webui_store_rule_remove(webui_store *s, const char *name) {
|
|
if (!s || !name) return false;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db, "DELETE FROM rule_feeds WHERE rule=?;", -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
|
|
sqlite3_step(st);
|
|
}
|
|
sqlite3_finalize(st); st = NULL;
|
|
if (sqlite3_prepare_v2(s->db, "DELETE FROM rules WHERE name=?;", -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
|
|
ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0;
|
|
}
|
|
sqlite3_finalize(st);
|
|
sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
/* Build the affectedFeeds array for one rule. Caller holds the lock. */
|
|
static json_t *rule_feeds_locked(webui_store *s, const char *rule) {
|
|
json_t *arr = json_array();
|
|
sqlite3_stmt *st = NULL;
|
|
if (sqlite3_prepare_v2(s->db,
|
|
"SELECT feed FROM rule_feeds WHERE rule=? ORDER BY feed;",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, rule, -1, SQLITE_STATIC);
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
const char *f = (const char *)sqlite3_column_text(st, 0);
|
|
json_array_append_new(arr, json_string(f ? f : ""));
|
|
}
|
|
}
|
|
sqlite3_finalize(st);
|
|
return arr;
|
|
}
|
|
|
|
static json_t *rule_row_to_json(sqlite3_stmt *st, webui_store *s) {
|
|
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);
|
|
return 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", rule_feeds_locked(s, n ? n : ""),
|
|
"lastMatch", (json_int_t)sqlite3_column_int64(st, 8));
|
|
}
|
|
|
|
static const char RULE_COLS[] =
|
|
"SELECT name,enabled,use_regex,add_paused,must_contain,must_not_contain,"
|
|
"assigned_category,save_path,last_match FROM rules";
|
|
|
|
bool webui_store_rule_list(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;
|
|
char sql[256];
|
|
snprintf(sql, sizeof sql, "%s ORDER BY name;", RULE_COLS);
|
|
if (sqlite3_prepare_v2(s->db, sql, -1, &st, NULL) == SQLITE_OK) {
|
|
ok = true;
|
|
while (sqlite3_step(st) == SQLITE_ROW)
|
|
json_array_append_new(out, rule_row_to_json(st, s));
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
json_t *webui_store_rule_get(webui_store *s, const char *name) {
|
|
if (!s || !name) return NULL;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
json_t *out = NULL;
|
|
char sql[256];
|
|
snprintf(sql, sizeof sql, "%s WHERE name=?;", RULE_COLS);
|
|
if (sqlite3_prepare_v2(s->db, sql, -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
|
|
if (sqlite3_step(st) == SQLITE_ROW) out = rule_row_to_json(st, s);
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return out;
|
|
}
|
|
|
|
bool webui_store_rule_set_match(webui_store *s, const char *name, long ts) {
|
|
if (!s || !name) return false;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db, "UPDATE rules SET last_match=? WHERE name=?;",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_int64(st, 1, (sqlite3_int64)ts);
|
|
sqlite3_bind_text(st, 2, name, -1, SQLITE_STATIC);
|
|
ok = sqlite3_step(st) == SQLITE_DONE;
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
/* ---- indexers ---- */
|
|
|
|
bool webui_store_indexer_upsert(webui_store *s, json_t *x) {
|
|
if (!s || !json_is_object(x)) return false;
|
|
const char *name = str_or(x, "name", "");
|
|
if (!*name) return false;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db,
|
|
"INSERT OR REPLACE INTO indexers (name,url,apikey,enabled) VALUES (?,?,?,?);",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, 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, json_object_get(x, "enabled") ? int_of(x, "enabled") : 1);
|
|
ok = sqlite3_step(st) == SQLITE_DONE;
|
|
}
|
|
sqlite3_finalize(st);
|
|
pthread_mutex_unlock(&s->lock);
|
|
return ok;
|
|
}
|
|
|
|
bool webui_store_indexer_remove(webui_store *s, const char *name) {
|
|
if (!s || !name) return false;
|
|
pthread_mutex_lock(&s->lock);
|
|
sqlite3_stmt *st = NULL;
|
|
bool ok = false;
|
|
if (sqlite3_prepare_v2(s->db, "DELETE FROM indexers WHERE name=?;", -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, name, -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_indexer_list(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;
|
|
}
|
|
|
|
/* --- legacy schema migration ---------------------------------------------- */
|
|
|
|
static bool table_has_column(sqlite3 *db, const char *table, const char *col) {
|
|
char sql[128];
|
|
snprintf(sql, sizeof sql, "PRAGMA table_info(%s);", table);
|
|
sqlite3_stmt *st = NULL;
|
|
bool found = false;
|
|
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK) {
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
const char *n = (const char *)sqlite3_column_text(st, 1); /* 1 = name */
|
|
if (n && strcmp(n, col) == 0) { found = true; break; }
|
|
}
|
|
}
|
|
sqlite3_finalize(st);
|
|
return found;
|
|
}
|
|
|
|
static void legacy_migrate(webui_store *s) {
|
|
/* The tell-tale of the old schema: feeds carried an inline articles blob. */
|
|
if (!table_has_column(s->db, "feeds", "articles")) return;
|
|
|
|
/* Snapshot the legacy blobs first, then finalize before mutating. */
|
|
json_t *feed_arts = json_object(); /* feed name -> articles array */
|
|
json_t *rule_feeds = json_object(); /* rule name -> affectedFeeds array */
|
|
sqlite3_stmt *st = NULL;
|
|
if (sqlite3_prepare_v2(s->db, "SELECT name, articles FROM feeds;", -1, &st, NULL) == SQLITE_OK)
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
const char *f = (const char *)sqlite3_column_text(st, 0);
|
|
json_object_set_new(feed_arts, f ? f : "", array_col(st, 1));
|
|
}
|
|
sqlite3_finalize(st); st = NULL;
|
|
if (table_has_column(s->db, "rules", "affected_feeds") &&
|
|
sqlite3_prepare_v2(s->db, "SELECT name, affected_feeds FROM rules;", -1, &st, NULL) == SQLITE_OK)
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
const char *n = (const char *)sqlite3_column_text(st, 0);
|
|
json_object_set_new(rule_feeds, n ? n : "", array_col(st, 1));
|
|
}
|
|
sqlite3_finalize(st); st = NULL;
|
|
|
|
sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL);
|
|
|
|
/* Articles: insert oldest-first so autoincrement id tracks recency (the
|
|
* legacy array is newest-first). Preserve is_read / grabbed flags. */
|
|
if (sqlite3_prepare_v2(s->db,
|
|
"INSERT OR IGNORE INTO articles"
|
|
" (feed,key,title,magnet,torrent_url,link,size,pub_date,is_read,grabbed,seen_at)"
|
|
" VALUES (?,?,?,?,?,?,?,?,?,?,?);", -1, &st, NULL) == SQLITE_OK) {
|
|
const char *feed; json_t *arts;
|
|
json_object_foreach(feed_arts, feed, arts) {
|
|
if (!json_is_array(arts)) continue;
|
|
for (long i = (long)json_array_size(arts) - 1; i >= 0; i--) {
|
|
json_t *a = json_array_get(arts, (size_t)i);
|
|
const char *key = str_or(a, "key", "");
|
|
if (!*key) continue;
|
|
sqlite3_bind_text(st, 1, feed, -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_text(st, 3, str_or(a, "title", ""), -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_text(st, 4, str_or(a, "magnet", ""), -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_text(st, 5, str_or(a, "torrentUrl", ""), -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_text(st, 6, str_or(a, "link", ""), -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_int64(st, 7, (sqlite3_int64)json_integer_value(json_object_get(a, "size")));
|
|
sqlite3_bind_text(st, 8, str_or(a, "pubDate", ""), -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_int(st, 9, int_of(a, "isRead"));
|
|
sqlite3_bind_int(st, 10, int_of(a, "grabbed"));
|
|
sqlite3_bind_int64(st, 11, (sqlite3_int64)time(NULL));
|
|
sqlite3_step(st);
|
|
sqlite3_reset(st);
|
|
}
|
|
}
|
|
}
|
|
sqlite3_finalize(st); st = NULL;
|
|
|
|
if (sqlite3_prepare_v2(s->db,
|
|
"INSERT OR IGNORE INTO rule_feeds (rule,feed) VALUES (?,?);",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
const char *rule; json_t *feeds;
|
|
json_object_foreach(rule_feeds, rule, feeds) {
|
|
if (!json_is_array(feeds)) continue;
|
|
size_t i; json_t *v;
|
|
json_array_foreach(feeds, i, v) {
|
|
const char *fn = json_string_value(v);
|
|
if (!fn || !*fn) continue;
|
|
sqlite3_bind_text(st, 1, rule, -1, SQLITE_TRANSIENT);
|
|
sqlite3_bind_text(st, 2, fn, -1, SQLITE_TRANSIENT);
|
|
sqlite3_step(st);
|
|
sqlite3_reset(st);
|
|
}
|
|
}
|
|
}
|
|
sqlite3_finalize(st); st = NULL;
|
|
|
|
/* Drop the legacy JSON columns by rebuilding feeds + rules. */
|
|
sqlite3_exec(s->db,
|
|
"CREATE TABLE feeds_new (name TEXT PRIMARY KEY, url TEXT NOT NULL,"
|
|
" last_update INTEGER NOT NULL DEFAULT 0);"
|
|
"INSERT INTO feeds_new (name,url,last_update) SELECT name,url,last_update FROM feeds;"
|
|
"DROP TABLE feeds;"
|
|
"ALTER TABLE feeds_new RENAME TO feeds;"
|
|
"CREATE TABLE rules_new (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 '',"
|
|
" last_match INTEGER NOT NULL DEFAULT 0);"
|
|
"INSERT INTO rules_new SELECT name,enabled,use_regex,add_paused,must_contain,"
|
|
"must_not_contain,assigned_category,save_path,last_match FROM rules;"
|
|
"DROP TABLE rules;"
|
|
"ALTER TABLE rules_new RENAME TO rules;",
|
|
NULL, NULL, NULL);
|
|
|
|
sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL);
|
|
json_decref(feed_arts);
|
|
json_decref(rule_feeds);
|
|
}
|