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