Move RSS persistence out of the daemon blob store and into the webui's own SQLite DB (feeds, rules, indexers tables; articles + affectedFeeds held as JSON columns). Remove the now-unused daemon blob store (set/get_webui_blob, blob_lock, data_dir). With this, all webui-owned state — accounts, taxonomy, RSS — lives in the webui DB; the daemon only keeps naut's own data (per-torrent labels still flow through set_labels for Lua). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3362 lines
136 KiB
C
3362 lines
136 KiB
C
#include "naut/naut_plugin.h"
|
|
#include "naut/http_client.h"
|
|
#include "webui_store.h"
|
|
|
|
#include <jansson.h>
|
|
|
|
#include <arpa/inet.h>
|
|
#include <errno.h>
|
|
#include <fcntl.h>
|
|
#include <limits.h>
|
|
#include <regex.h>
|
|
#include <netinet/in.h>
|
|
#include <pthread.h>
|
|
#include <stdbool.h>
|
|
#include <stdint.h>
|
|
#include <stdatomic.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <strings.h>
|
|
#include <sys/random.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/time.h>
|
|
#include <time.h>
|
|
#include <unistd.h>
|
|
|
|
#define DEFAULT_HOST "127.0.0.1"
|
|
#define DEFAULT_PORT 8080
|
|
#define READ_LIMIT (8u << 20)
|
|
#define SESSION_COOKIE "naut_session"
|
|
#define SESSION_TTL_SECONDS (60 * 60 * 24 * 7)
|
|
#define MAX_SESSIONS 64
|
|
#define MAX_CONNECTIONS 128
|
|
#define SPEED_SLOTS 256
|
|
#define ETA_INFINITY 8640000 /* torrent-ui renders >= this as the infinity glyph */
|
|
|
|
typedef struct {
|
|
char token[96];
|
|
char user[64];
|
|
char role[16];
|
|
time_t expires;
|
|
bool used;
|
|
} webui_session;
|
|
|
|
/* Single-writer (sampler thread) running estimate of a torrent's download
|
|
* rate, derived from successive byte counts. */
|
|
typedef struct {
|
|
uint64_t id;
|
|
uint64_t last_bytes;
|
|
double last_time;
|
|
double dlspeed;
|
|
bool used;
|
|
} speed_slot;
|
|
|
|
typedef struct {
|
|
naut_host_api host;
|
|
char root[PATH_MAX];
|
|
char host_name[64];
|
|
char auth_user[64]; /* bootstrap admin name (for startup banner) */
|
|
char auth_password[64]; /* generated bootstrap password (banner only) */
|
|
webui_store *store; /* SQLite store: accounts, taxonomy, RSS */
|
|
int port;
|
|
int listener;
|
|
bool generated_password;
|
|
atomic_bool stopping;
|
|
bool thread_started;
|
|
pthread_t thread;
|
|
|
|
bool sampler_started;
|
|
pthread_t sampler;
|
|
|
|
pthread_mutex_t auth_lock;
|
|
|
|
pthread_mutex_t conn_lock;
|
|
pthread_cond_t conn_cond;
|
|
size_t active_connections;
|
|
|
|
/* Latest snapshot, published once per second by the sampler thread and
|
|
* shared by /api/snapshot, /api/torrents and every SSE stream. */
|
|
pthread_mutex_t snap_lock;
|
|
pthread_cond_t snap_cond;
|
|
char *snapshot_str;
|
|
char *torrents_str;
|
|
uint64_t snap_seq;
|
|
|
|
pthread_mutex_t speed_lock;
|
|
speed_slot speeds[SPEED_SLOTS];
|
|
|
|
/* Categories and tags are pure UI organization the engine knows nothing
|
|
* about, so the web layer owns them (in memory, like qBittorrent's own
|
|
* Web API does). assignments maps "<id>" -> {category, tags:[...]}. */
|
|
pthread_mutex_t meta_lock;
|
|
json_t *categories; /* array of {name, savePath} */
|
|
json_t *tags; /* array of tag name strings */
|
|
json_t *assignments; /* object keyed by stringified torrent id */
|
|
|
|
/* RSS: feeds + auto-download rules, polled by a background thread and
|
|
* persisted via the daemon blob store. Search indexers live here too. */
|
|
pthread_mutex_t rss_lock;
|
|
json_t *rss_feeds; /* array of {name,url,lastUpdate,articles:[...]} */
|
|
json_t *rss_rules; /* array of rule objects */
|
|
json_t *indexers; /* array of {name,url,apikey,enabled} (Torznab) */
|
|
pthread_t rss_thread;
|
|
bool rss_thread_started;
|
|
pthread_cond_t rss_cond; /* wakes the poller for an immediate refresh */
|
|
bool rss_wake; /* set with rss_cond to force an early re-poll */
|
|
|
|
webui_session sessions[MAX_SESSIONS];
|
|
} webui_state;
|
|
|
|
typedef struct {
|
|
int fd;
|
|
} conn_arg;
|
|
|
|
static webui_state g_webui;
|
|
|
|
static double monotonic_seconds(void) {
|
|
struct timespec ts;
|
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
|
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
|
|
}
|
|
|
|
static void log_msg(int level, const char *message) {
|
|
if (g_webui.host.log)
|
|
g_webui.host.log(g_webui.host.host_context, level, message);
|
|
}
|
|
|
|
static bool send_all_fd(int fd, const char *buf, size_t len) {
|
|
while (len) {
|
|
ssize_t n = send(fd, buf, len, MSG_NOSIGNAL);
|
|
if (n < 0) {
|
|
if (errno == EINTR) continue;
|
|
return false;
|
|
}
|
|
if (n == 0) return false;
|
|
buf += n;
|
|
len -= (size_t)n;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static void http_head_extra(int fd, int code, const char *status,
|
|
const char *ctype, size_t len,
|
|
const char *extra) {
|
|
char h[512];
|
|
int n = snprintf(h, sizeof h,
|
|
"HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\n"
|
|
"Cache-Control: no-cache\r\n%sConnection: close\r\n\r\n",
|
|
code, status, ctype, len, extra ? extra : "");
|
|
if (n > 0) send_all_fd(fd, h, (size_t)n);
|
|
}
|
|
|
|
static void http_head(int fd, int code, const char *status,
|
|
const char *ctype, size_t len) {
|
|
http_head_extra(fd, code, status, ctype, len, NULL);
|
|
}
|
|
|
|
static void http_text(int fd, int code, const char *status, const char *body) {
|
|
if (!body) body = "";
|
|
http_head(fd, code, status, "text/plain; charset=utf-8", strlen(body));
|
|
send_all_fd(fd, body, strlen(body));
|
|
}
|
|
|
|
static void http_json(int fd, int code, json_t *json) {
|
|
char *txt = json ? json_dumps(json, JSON_COMPACT | JSON_ENCODE_ANY) : NULL;
|
|
if (!txt) {
|
|
http_text(fd, 500, "Internal Server Error", "json encode failed");
|
|
return;
|
|
}
|
|
http_head(fd, code, code == 200 ? "OK" : "Error",
|
|
"application/json; charset=utf-8", strlen(txt));
|
|
send_all_fd(fd, txt, strlen(txt));
|
|
free(txt);
|
|
}
|
|
|
|
static void http_json_extra(int fd, int code, json_t *json, const char *extra) {
|
|
char *txt = json ? json_dumps(json, JSON_COMPACT | JSON_ENCODE_ANY) : NULL;
|
|
if (!txt) {
|
|
http_text(fd, 500, "Internal Server Error", "json encode failed");
|
|
return;
|
|
}
|
|
http_head_extra(fd, code, code == 200 ? "OK" : "Error",
|
|
"application/json; charset=utf-8", strlen(txt), extra);
|
|
send_all_fd(fd, txt, strlen(txt));
|
|
free(txt);
|
|
}
|
|
|
|
/* Serve an already-serialized JSON string under one lock copy. */
|
|
static void http_json_str(int fd, const char *json, const char *fallback) {
|
|
const char *body = json ? json : fallback;
|
|
http_head(fd, 200, "OK", "application/json; charset=utf-8", strlen(body));
|
|
send_all_fd(fd, body, strlen(body));
|
|
}
|
|
|
|
static const char *mime_type(const char *path) {
|
|
const char *dot = strrchr(path, '.');
|
|
if (!dot) return "application/octet-stream";
|
|
if (strcmp(dot, ".html") == 0) return "text/html; charset=utf-8";
|
|
if (strcmp(dot, ".js") == 0) return "text/javascript; charset=utf-8";
|
|
if (strcmp(dot, ".css") == 0) return "text/css; charset=utf-8";
|
|
if (strcmp(dot, ".json") == 0) return "application/json; charset=utf-8";
|
|
if (strcmp(dot, ".svg") == 0) return "image/svg+xml";
|
|
if (strcmp(dot, ".ico") == 0) return "image/x-icon";
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
static void strip_query(char *path) {
|
|
char *q = strchr(path, '?');
|
|
if (q) *q = 0;
|
|
char *hash = strchr(path, '#');
|
|
if (hash) *hash = 0;
|
|
}
|
|
|
|
/* Percent-decode a URL query component in place-style into `out`. */
|
|
static void url_decode(char *out, size_t outsz, const char *in, size_t n) {
|
|
size_t o = 0;
|
|
for (size_t i = 0; i < n && o + 1 < outsz; i++) {
|
|
if (in[i] == '%' && i + 2 < n) {
|
|
char hex[3] = { in[i+1], in[i+2], 0 };
|
|
char *e; long v = strtol(hex, &e, 16);
|
|
if (e == hex + 2) { out[o++] = (char)v; i += 2; continue; }
|
|
}
|
|
out[o++] = (in[i] == '+') ? ' ' : in[i];
|
|
}
|
|
out[o] = 0;
|
|
}
|
|
|
|
/* Extract a query parameter from a "k=v&k2=v2" string (the part after '?'),
|
|
* URL-decoding the value into `out`. Returns true if the key was present. */
|
|
static bool query_get(const char *query, const char *key, char *out, size_t outsz) {
|
|
if (!query) { if (outsz) out[0] = 0; return false; }
|
|
size_t klen = strlen(key);
|
|
for (const char *p = query; p && *p; ) {
|
|
const char *amp = strchr(p, '&');
|
|
size_t seg = amp ? (size_t)(amp - p) : strlen(p);
|
|
if (seg > klen && p[klen] == '=' && strncmp(p, key, klen) == 0) {
|
|
url_decode(out, outsz, p + klen + 1, seg - klen - 1);
|
|
return true;
|
|
}
|
|
p = amp ? amp + 1 : NULL;
|
|
}
|
|
if (outsz) out[0] = 0;
|
|
return false;
|
|
}
|
|
|
|
/* Cryptographically strong hex. Fails closed: if the kernel CSPRNG is
|
|
* unavailable we refuse rather than fall back to predictable bytes (these
|
|
* feed session tokens). */
|
|
static bool random_hex(char *out, size_t out_size, size_t bytes) {
|
|
static const char hex[] = "0123456789abcdef";
|
|
if (out_size < bytes * 2 + 1) return false;
|
|
unsigned char buf[64];
|
|
if (bytes > sizeof buf) return false;
|
|
size_t got = 0;
|
|
while (got < bytes) {
|
|
ssize_t n = getrandom(buf + got, bytes - got, 0);
|
|
if (n < 0) {
|
|
if (errno == EINTR) continue;
|
|
return false;
|
|
}
|
|
got += (size_t)n;
|
|
}
|
|
for (size_t i = 0; i < bytes; i++) {
|
|
out[i * 2] = hex[buf[i] >> 4];
|
|
out[i * 2 + 1] = hex[buf[i] & 15];
|
|
}
|
|
out[bytes * 2] = 0;
|
|
return true;
|
|
}
|
|
|
|
/* mkdir -p for the account DB's parent directory (0700). */
|
|
static int mkdir_p(const char *path, mode_t mode) {
|
|
char tmp[PATH_MAX];
|
|
size_t len = snprintf(tmp, sizeof tmp, "%s", path);
|
|
if (len == 0 || len >= sizeof tmp) return -1;
|
|
for (char *p = tmp + 1; *p; p++) {
|
|
if (*p == '/') {
|
|
*p = 0;
|
|
if (mkdir(tmp, mode) != 0 && errno != EEXIST) return -1;
|
|
*p = '/';
|
|
}
|
|
}
|
|
if (mkdir(tmp, mode) != 0 && errno != EEXIST) return -1;
|
|
return 0;
|
|
}
|
|
|
|
/* Resolve the account database path: NAUT_WEBUI_DB, else an XDG/HOME default
|
|
* under naut/. Creates the parent directory. */
|
|
static bool resolve_auth_db_path(char *out, size_t n) {
|
|
const char *env = getenv("NAUT_WEBUI_DB");
|
|
if (env && *env) return (size_t)snprintf(out, n, "%s", env) < n;
|
|
const char *xdg = getenv("XDG_DATA_HOME");
|
|
const char *home = getenv("HOME");
|
|
char dir[PATH_MAX];
|
|
if (xdg && *xdg) snprintf(dir, sizeof dir, "%s/naut", xdg);
|
|
else if (home && *home) snprintf(dir, sizeof dir, "%s/.local/share/naut", home);
|
|
else return false;
|
|
if (mkdir_p(dir, 0700) != 0) return false;
|
|
return (size_t)snprintf(out, n, "%s/webui.db", dir) < n;
|
|
}
|
|
|
|
/* Open the account store and, on first run (no accounts), bootstrap an admin
|
|
* from NAUT_AUTH_USER/PASSWORD or a generated password (logged once). */
|
|
static void init_auth(void) {
|
|
char db_path[PATH_MAX];
|
|
if (!resolve_auth_db_path(db_path, sizeof db_path)) {
|
|
log_msg(0, "webui: cannot resolve account DB path; set NAUT_WEBUI_DB");
|
|
return;
|
|
}
|
|
g_webui.store = webui_store_open(db_path);
|
|
if (!g_webui.store) {
|
|
log_msg(0, "webui: failed to open account database");
|
|
return;
|
|
}
|
|
const char *user = getenv("NAUT_AUTH_USER");
|
|
if (!user || !*user) user = getenv("NAUT_USER");
|
|
if (!user || !*user) user = "admin";
|
|
snprintf(g_webui.auth_user, sizeof g_webui.auth_user, "%s", user);
|
|
|
|
if (webui_store_user_count(g_webui.store) > 0) return; /* already set up */
|
|
|
|
/* No accounts yet — create the initial admin. */
|
|
const char *password = getenv("NAUT_AUTH_PASSWORD");
|
|
if (!password || !*password) password = getenv("NAUT_PASSWORD");
|
|
if (password && *password) {
|
|
g_webui.generated_password = false;
|
|
} else if (random_hex(g_webui.auth_password, sizeof g_webui.auth_password, 9)) {
|
|
password = g_webui.auth_password;
|
|
g_webui.generated_password = true;
|
|
} else {
|
|
log_msg(0, "webui: no CSPRNG; set NAUT_AUTH_PASSWORD to create the admin");
|
|
return;
|
|
}
|
|
if (!webui_store_create_user(g_webui.store, user, password, "admin"))
|
|
log_msg(0, "webui: failed to create the initial admin account");
|
|
}
|
|
|
|
static const char *header_value(const char *headers, const char *end,
|
|
const char *name) {
|
|
size_t name_len = strlen(name);
|
|
for (const char *p = headers; p && p < end;) {
|
|
const char *line_end = memmem(p, (size_t)(end - p), "\r\n", 2);
|
|
if (!line_end) line_end = end;
|
|
if ((size_t)(line_end - p) > name_len &&
|
|
strncasecmp(p, name, name_len) == 0 && p[name_len] == ':') {
|
|
const char *value = p + name_len + 1;
|
|
while (value < line_end && (*value == ' ' || *value == '\t'))
|
|
value++;
|
|
return value;
|
|
}
|
|
p = line_end + 2;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
static bool cookie_token(const char *headers, const char *end,
|
|
char *out, size_t out_size) {
|
|
const char *cookie = header_value(headers, end, "cookie");
|
|
if (!cookie) return false;
|
|
const char *line_end = memmem(cookie, (size_t)(end - cookie), "\r\n", 2);
|
|
if (!line_end) line_end = end;
|
|
const char *p = cookie;
|
|
size_t key_len = strlen(SESSION_COOKIE);
|
|
while (p < line_end) {
|
|
while (p < line_end && (*p == ' ' || *p == ';')) p++;
|
|
if ((size_t)(line_end - p) > key_len &&
|
|
strncmp(p, SESSION_COOKIE, key_len) == 0 &&
|
|
p[key_len] == '=') {
|
|
p += key_len + 1;
|
|
size_t len = strcspn(p, "; \r\n");
|
|
if (len >= out_size) len = out_size - 1;
|
|
memcpy(out, p, len);
|
|
out[len] = 0;
|
|
return true;
|
|
}
|
|
p = memchr(p, ';', (size_t)(line_end - p));
|
|
if (!p) break;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* Look up the session for this request. On a live session, refreshes its TTL
|
|
* and (optionally) copies the account's username and role. Returns true if a
|
|
* valid session was found. */
|
|
static bool current_identity(const char *headers, const char *end,
|
|
char *user, size_t user_sz,
|
|
char *role, size_t role_sz) {
|
|
char token[96];
|
|
if (!cookie_token(headers, end, token, sizeof token)) return false;
|
|
bool ok = false;
|
|
time_t now = time(NULL);
|
|
pthread_mutex_lock(&g_webui.auth_lock);
|
|
for (size_t i = 0; i < MAX_SESSIONS; i++) {
|
|
webui_session *session = &g_webui.sessions[i];
|
|
if (!session->used || strcmp(session->token, token) != 0) continue;
|
|
if (session->expires < now) {
|
|
session->used = false;
|
|
break;
|
|
}
|
|
session->expires = now + SESSION_TTL_SECONDS;
|
|
if (user) snprintf(user, user_sz, "%s", session->user);
|
|
if (role) snprintf(role, role_sz, "%s", session->role);
|
|
ok = true;
|
|
break;
|
|
}
|
|
pthread_mutex_unlock(&g_webui.auth_lock);
|
|
return ok;
|
|
}
|
|
|
|
static bool create_session(const char *user, const char *role,
|
|
char *out, size_t out_size) {
|
|
char token[96];
|
|
if (!random_hex(token, sizeof token, 24)) return false;
|
|
time_t now = time(NULL);
|
|
time_t expires = now + SESSION_TTL_SECONDS;
|
|
pthread_mutex_lock(&g_webui.auth_lock);
|
|
webui_session *slot = NULL;
|
|
for (size_t i = 0; i < MAX_SESSIONS; i++) {
|
|
webui_session *s = &g_webui.sessions[i];
|
|
if (!s->used || s->expires < now) { slot = s; break; }
|
|
/* Otherwise track the session that expires soonest, so a full table
|
|
* evicts the oldest rather than always clobbering slot 0. */
|
|
if (!slot || s->expires < slot->expires) slot = s;
|
|
}
|
|
snprintf(slot->token, sizeof slot->token, "%s", token);
|
|
snprintf(slot->user, sizeof slot->user, "%s", user ? user : "");
|
|
snprintf(slot->role, sizeof slot->role, "%s", role ? role : "user");
|
|
slot->expires = expires;
|
|
slot->used = true;
|
|
pthread_mutex_unlock(&g_webui.auth_lock);
|
|
snprintf(out, out_size, "%s", token);
|
|
return true;
|
|
}
|
|
|
|
/* Invalidate every session belonging to `user` (after delete / password reset
|
|
* by an admin). */
|
|
static void drop_user_sessions(const char *user) {
|
|
pthread_mutex_lock(&g_webui.auth_lock);
|
|
for (size_t i = 0; i < MAX_SESSIONS; i++)
|
|
if (g_webui.sessions[i].used &&
|
|
strcmp(g_webui.sessions[i].user, user) == 0)
|
|
g_webui.sessions[i].used = false;
|
|
pthread_mutex_unlock(&g_webui.auth_lock);
|
|
}
|
|
|
|
static void clear_session(const char *headers, const char *end) {
|
|
char token[96];
|
|
if (!cookie_token(headers, end, token, sizeof token)) return;
|
|
pthread_mutex_lock(&g_webui.auth_lock);
|
|
for (size_t i = 0; i < MAX_SESSIONS; i++)
|
|
if (g_webui.sessions[i].used &&
|
|
strcmp(g_webui.sessions[i].token, token) == 0)
|
|
g_webui.sessions[i].used = false;
|
|
pthread_mutex_unlock(&g_webui.auth_lock);
|
|
}
|
|
|
|
static bool bad_static_path(const char *path) {
|
|
return strstr(path, "..") || strchr(path, '\\');
|
|
}
|
|
|
|
static bool join_root_path(char *out, size_t out_size, const char *suffix) {
|
|
int n = snprintf(out, out_size, "%s%s", g_webui.root, suffix);
|
|
return n > 0 && (size_t)n < out_size;
|
|
}
|
|
|
|
static bool serve_file(int fd, const char *request_path) {
|
|
char clean[PATH_MAX];
|
|
snprintf(clean, sizeof clean, "%s", request_path && *request_path
|
|
? request_path : "/");
|
|
strip_query(clean);
|
|
if (strcmp(clean, "/") == 0) snprintf(clean, sizeof clean, "/index.html");
|
|
if (bad_static_path(clean)) {
|
|
http_text(fd, 403, "Forbidden", "forbidden");
|
|
return true;
|
|
}
|
|
|
|
char path[PATH_MAX];
|
|
if (!join_root_path(path, sizeof path, clean)) {
|
|
http_text(fd, 414, "URI Too Long", "path too long");
|
|
return true;
|
|
}
|
|
int file = open(path, O_RDONLY);
|
|
if (file < 0 && strchr(clean + 1, '/') == NULL) {
|
|
if (!join_root_path(path, sizeof path, "/index.html")) {
|
|
http_text(fd, 500, "Internal Server Error", "root too long");
|
|
return true;
|
|
}
|
|
file = open(path, O_RDONLY);
|
|
}
|
|
if (file < 0) return false;
|
|
struct stat st;
|
|
if (fstat(file, &st) != 0 || st.st_size < 0) {
|
|
close(file);
|
|
http_text(fd, 500, "Internal Server Error", "stat failed");
|
|
return true;
|
|
}
|
|
http_head(fd, 200, "OK", mime_type(path), (size_t)st.st_size);
|
|
char buf[16384];
|
|
for (;;) {
|
|
ssize_t n = read(file, buf, sizeof buf);
|
|
if (n < 0) {
|
|
if (errno == EINTR) continue;
|
|
break;
|
|
}
|
|
if (n == 0) break;
|
|
if (!send_all_fd(fd, buf, (size_t)n)) break;
|
|
}
|
|
close(file);
|
|
return true;
|
|
}
|
|
|
|
static json_t *rpc_call_json_err(const char *method, json_t *params,
|
|
naut_err *out_err) {
|
|
if (out_err) *out_err = NAUT_ERR_INVAL;
|
|
if (!g_webui.host.call_rpc) return NULL;
|
|
char *request = json_dumps(params ? params : json_null(),
|
|
JSON_COMPACT | JSON_ENCODE_ANY);
|
|
if (!request) return NULL;
|
|
char *response = NULL;
|
|
naut_err error = g_webui.host.call_rpc(g_webui.host.host_context,
|
|
method, request, &response);
|
|
free(request);
|
|
if (out_err) *out_err = error;
|
|
if (error != NAUT_OK || !response) {
|
|
free(response);
|
|
return NULL;
|
|
}
|
|
json_error_t json_error;
|
|
json_t *json = json_loads(response, JSON_REJECT_DUPLICATES |
|
|
JSON_DECODE_ANY, &json_error);
|
|
free(response);
|
|
return json;
|
|
}
|
|
|
|
static json_t *rpc_call_json(const char *method, json_t *params) {
|
|
return rpc_call_json_err(method, params, NULL);
|
|
}
|
|
|
|
static const char *json_string_or(const json_t *obj, const char *key,
|
|
const char *fallback) {
|
|
const char *value = json_string_value(json_object_get(obj, key));
|
|
return value ? value : fallback;
|
|
}
|
|
|
|
static uint64_t json_u64(const json_t *obj, const char *key) {
|
|
json_t *value = json_object_get(obj, key);
|
|
return json_is_integer(value) && json_integer_value(value) > 0
|
|
? (uint64_t)json_integer_value(value) : 0;
|
|
}
|
|
|
|
static int64_t json_i64_or(const json_t *obj, const char *key,
|
|
int64_t fallback) {
|
|
json_t *value = json_object_get(obj, key);
|
|
return json_is_integer(value) ? (int64_t)json_integer_value(value)
|
|
: fallback;
|
|
}
|
|
|
|
static double json_number_or(const json_t *obj, const char *key,
|
|
double fallback) {
|
|
json_t *value = json_object_get(obj, key);
|
|
return json_is_number(value) ? json_number_value(value) : fallback;
|
|
}
|
|
|
|
static const char *base_name(const char *path) {
|
|
if (!path || !*path) return "torrent";
|
|
const char *slash = strrchr(path, '/');
|
|
const char *name = slash ? slash + 1 : path;
|
|
return *name ? name : "torrent";
|
|
}
|
|
|
|
static char *torrent_name(const json_t *torrent) {
|
|
/* The daemon persists the display name captured at add time; prefer it so
|
|
* restored torrents (where the in-process name cache is empty) read right
|
|
* instead of falling back to an upload path's basename. */
|
|
const char *saved = json_string_value(json_object_get(torrent, "name"));
|
|
if (saved && *saved) return strdup(saved);
|
|
const char *source = json_string_or(torrent, "source", "torrent");
|
|
if (strncmp(source, "magnet:", 7) == 0) {
|
|
const char *dn = strstr(source, "dn=");
|
|
if (dn) {
|
|
dn += 3;
|
|
size_t len = strcspn(dn, "&");
|
|
char *name = malloc(len + 1);
|
|
if (!name) return NULL;
|
|
memcpy(name, dn, len);
|
|
name[len] = 0;
|
|
return name;
|
|
}
|
|
}
|
|
return strdup(base_name(source));
|
|
}
|
|
|
|
static const char *ui_state(const char *state, double progress) {
|
|
if (!state) return "stalledDL";
|
|
if (strcmp(state, "complete") == 0) return "uploading";
|
|
if (strcmp(state, "paused") == 0)
|
|
return progress >= 1.0 ? "pausedUP" : "pausedDL";
|
|
if (strcmp(state, "stopped") == 0)
|
|
return progress >= 1.0 ? "pausedUP" : "pausedDL";
|
|
if (strcmp(state, "stopping") == 0) return "pausedDL";
|
|
if (strcmp(state, "checking") == 0)
|
|
return progress >= 1.0 ? "checkingUP" : "checkingDL";
|
|
if (strcmp(state, "stalled") == 0)
|
|
return progress >= 1.0 ? "stalledUP" : "stalledDL";
|
|
if (strcmp(state, "queued") == 0) return "queuedDL";
|
|
if (strcmp(state, "error") == 0) return "error";
|
|
return "downloading";
|
|
}
|
|
|
|
/* ---- single-writer download-rate estimate keyed by torrent id ---- */
|
|
|
|
static double speed_sample(uint64_t id, uint64_t bytes) {
|
|
double now = monotonic_seconds();
|
|
double result = 0.0;
|
|
pthread_mutex_lock(&g_webui.speed_lock);
|
|
speed_slot *slot = NULL, *spare = NULL;
|
|
for (size_t i = 0; i < SPEED_SLOTS; i++) {
|
|
speed_slot *s = &g_webui.speeds[i];
|
|
if (s->used && s->id == id) { slot = s; break; }
|
|
if (!s->used && !spare) spare = s;
|
|
}
|
|
if (!slot) {
|
|
if (!spare) {
|
|
/* table full: evict least-recently-updated */
|
|
spare = &g_webui.speeds[0];
|
|
for (size_t i = 1; i < SPEED_SLOTS; i++)
|
|
if (g_webui.speeds[i].last_time < spare->last_time)
|
|
spare = &g_webui.speeds[i];
|
|
}
|
|
slot = spare;
|
|
slot->used = true;
|
|
slot->id = id;
|
|
slot->last_bytes = bytes;
|
|
slot->last_time = now;
|
|
slot->dlspeed = 0.0;
|
|
pthread_mutex_unlock(&g_webui.speed_lock);
|
|
return 0.0;
|
|
}
|
|
double dt = now - slot->last_time;
|
|
if (dt > 0.0) {
|
|
double delta = bytes >= slot->last_bytes
|
|
? (double)(bytes - slot->last_bytes) : 0.0;
|
|
double inst = delta / dt;
|
|
slot->dlspeed = slot->dlspeed * 0.6 + inst * 0.4;
|
|
if (slot->dlspeed < 0.0) slot->dlspeed = 0.0;
|
|
slot->last_bytes = bytes;
|
|
slot->last_time = now;
|
|
}
|
|
result = slot->dlspeed;
|
|
pthread_mutex_unlock(&g_webui.speed_lock);
|
|
return result;
|
|
}
|
|
|
|
static double speed_peek(uint64_t id) {
|
|
double result = 0.0;
|
|
pthread_mutex_lock(&g_webui.speed_lock);
|
|
for (size_t i = 0; i < SPEED_SLOTS; i++)
|
|
if (g_webui.speeds[i].used && g_webui.speeds[i].id == id) {
|
|
result = g_webui.speeds[i].dlspeed;
|
|
break;
|
|
}
|
|
pthread_mutex_unlock(&g_webui.speed_lock);
|
|
return result;
|
|
}
|
|
|
|
/* Drop slots for ids no longer present so a long-lived server doesn't hand a
|
|
* stale rate to a recycled id. */
|
|
static void speed_retain(json_t *torrents) {
|
|
pthread_mutex_lock(&g_webui.speed_lock);
|
|
for (size_t i = 0; i < SPEED_SLOTS; i++) {
|
|
speed_slot *s = &g_webui.speeds[i];
|
|
if (!s->used) continue;
|
|
bool found = false;
|
|
size_t index;
|
|
json_t *torrent;
|
|
json_array_foreach(torrents, index, torrent)
|
|
if (json_u64(torrent, "torrent_id") == s->id) { found = true; break; }
|
|
if (!found) s->used = false;
|
|
}
|
|
pthread_mutex_unlock(&g_webui.speed_lock);
|
|
}
|
|
|
|
static json_t *tracker_hosts(json_t *trackers) {
|
|
json_t *hosts = json_array();
|
|
if (!hosts || !json_is_array(trackers)) return hosts;
|
|
size_t index;
|
|
json_t *tracker;
|
|
json_array_foreach(trackers, index, tracker) {
|
|
const char *url = json_string_value(json_object_get(tracker, "url"));
|
|
if (!url || strstr(url, "**")) continue;
|
|
const char *start = strstr(url, "://");
|
|
start = start ? start + 3 : url;
|
|
size_t len = strcspn(start, "/");
|
|
char host[256];
|
|
snprintf(host, sizeof host, "%.*s", (int)len, start);
|
|
json_array_append_new(hosts, json_string(host));
|
|
}
|
|
return hosts;
|
|
}
|
|
|
|
static bool tracker_host(char out[256], const char *url) {
|
|
if (!url || strstr(url, "**")) return false;
|
|
const char *start = strstr(url, "://");
|
|
start = start ? start + 3 : url;
|
|
size_t len = strcspn(start, "/");
|
|
if (len == 0 || len >= 256) return false;
|
|
snprintf(out, 256, "%.*s", (int)len, start);
|
|
return true;
|
|
}
|
|
|
|
static json_t *tracker_summary(json_t *torrents) {
|
|
json_t *summary = json_array();
|
|
if (!summary || !json_is_array(torrents)) return summary;
|
|
size_t tindex;
|
|
json_t *torrent;
|
|
json_array_foreach(torrents, tindex, torrent) {
|
|
json_t *trackers = json_object_get(torrent, "trackers");
|
|
if (!json_is_array(trackers)) continue;
|
|
size_t index;
|
|
json_t *tracker;
|
|
json_array_foreach(trackers, index, tracker) {
|
|
int64_t tier = json_integer_value(json_object_get(tracker, "tier"));
|
|
if (tier < 0) continue;
|
|
char host[256];
|
|
if (!tracker_host(host, json_string_value(
|
|
json_object_get(tracker, "url"))))
|
|
continue;
|
|
bool found = false;
|
|
size_t hindex;
|
|
json_t *entry;
|
|
json_array_foreach(summary, hindex, entry) {
|
|
if (strcmp(json_string_or(entry, "host", ""), host) != 0)
|
|
continue;
|
|
json_int_t count =
|
|
json_integer_value(json_object_get(entry, "count"));
|
|
json_object_set_new(entry, "count", json_integer(count + 1));
|
|
found = true;
|
|
break;
|
|
}
|
|
if (!found)
|
|
json_array_append_new(summary, json_pack(
|
|
"{s:s,s:i}", "host", host, "count", 1));
|
|
}
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
static json_t *map_peer_list(json_t *torrent) {
|
|
json_t *out = json_array();
|
|
json_t *peers = json_object_get(torrent, "peer_list");
|
|
if (!out || !json_is_array(peers)) return out;
|
|
size_t index;
|
|
json_t *peer;
|
|
json_array_foreach(peers, index, peer) {
|
|
if (!json_is_object(peer)) continue;
|
|
json_t *item = json_pack(
|
|
"{s:s,s:s,s:i,s:s,s:s,s:s,s:f,s:i,s:i,s:I,s:I,s:f}",
|
|
"country", "",
|
|
"ip", json_string_or(peer, "ip", ""),
|
|
"port", (int)json_u64(peer, "port"),
|
|
"client", json_string_or(peer, "client", "Unknown"),
|
|
"connection", json_string_or(peer, "connection", "TCP"),
|
|
"flags", json_string_or(peer, "flags", ""),
|
|
"progress", json_number_or(peer, "progress", 0.0),
|
|
"dlspeed", (int)json_u64(peer, "dlspeed"),
|
|
"upspeed", (int)json_u64(peer, "upspeed"),
|
|
"downloaded", (json_int_t)json_u64(peer, "downloaded"),
|
|
"uploaded", (json_int_t)json_u64(peer, "uploaded"),
|
|
"relevance", json_number_or(peer, "relevance", 0.0));
|
|
if (item) json_array_append_new(out, item);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
static double torrent_progress_ratio(json_t *torrent, uint64_t *done_out,
|
|
uint64_t *total_out) {
|
|
uint64_t done = json_u64(torrent, "bytes_done");
|
|
uint64_t total = json_u64(torrent, "total_bytes");
|
|
uint64_t pieces = json_u64(torrent, "total_pieces");
|
|
uint64_t pieces_done = json_u64(torrent, "pieces_done");
|
|
double progress = total ? (double)done / (double)total :
|
|
(pieces ? (double)pieces_done / (double)pieces : 0.0);
|
|
if (progress > 1.0) progress = 1.0;
|
|
if (done_out) *done_out = done;
|
|
if (total_out) *total_out = total;
|
|
return progress;
|
|
}
|
|
|
|
static json_int_t compute_eta(uint64_t done, uint64_t total, double dlspeed) {
|
|
if (total > done && dlspeed >= 1.0)
|
|
return (json_int_t)((double)(total - done) / dlspeed);
|
|
return ETA_INFINITY;
|
|
}
|
|
|
|
/* ---- category / tag store (web-layer owned, guarded by meta_lock) ---- */
|
|
|
|
static int find_category(const char *name) {
|
|
size_t index;
|
|
json_t *value;
|
|
json_array_foreach(g_webui.categories, index, value)
|
|
if (strcmp(json_string_or(value, "name", ""), name) == 0)
|
|
return (int)index;
|
|
return -1;
|
|
}
|
|
|
|
static int find_tag(const char *name) {
|
|
size_t index;
|
|
json_t *value;
|
|
json_array_foreach(g_webui.tags, index, value)
|
|
if (strcmp(json_string_value(value), name) == 0) return (int)index;
|
|
return -1;
|
|
}
|
|
|
|
static void store_add_category(const char *name, const char *save_path) {
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
if (find_category(name) < 0)
|
|
json_array_append_new(g_webui.categories, json_pack(
|
|
"{s:s,s:s}", "name", name, "savePath", save_path ? save_path : ""));
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
}
|
|
|
|
static void store_remove_category(const char *name) {
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
int index = find_category(name);
|
|
if (index >= 0) json_array_remove(g_webui.categories, (size_t)index);
|
|
/* drop the category from any torrent that had it */
|
|
const char *key;
|
|
json_t *entry;
|
|
json_object_foreach(g_webui.assignments, key, entry)
|
|
if (strcmp(json_string_or(entry, "category", ""), name) == 0)
|
|
json_object_set_new(entry, "category", json_string(""));
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
}
|
|
|
|
static void store_add_tag(const char *name) {
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
if (find_tag(name) < 0)
|
|
json_array_append_new(g_webui.tags, json_string(name));
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
}
|
|
|
|
static void store_remove_tag(const char *name) {
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
int index = find_tag(name);
|
|
if (index >= 0) json_array_remove(g_webui.tags, (size_t)index);
|
|
const char *key;
|
|
json_t *entry;
|
|
json_object_foreach(g_webui.assignments, key, entry) {
|
|
json_t *tags = json_object_get(entry, "tags");
|
|
size_t i = 0;
|
|
while (i < json_array_size(tags)) {
|
|
if (strcmp(json_string_value(json_array_get(tags, i)), name) == 0)
|
|
json_array_remove(tags, i);
|
|
else
|
|
i++;
|
|
}
|
|
}
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
}
|
|
|
|
static json_t *assignment_locked(uint64_t id, bool create) {
|
|
char key[32];
|
|
snprintf(key, sizeof key, "%llu", (unsigned long long)id);
|
|
json_t *entry = json_object_get(g_webui.assignments, key);
|
|
if (!entry && create) {
|
|
entry = json_pack("{s:s,s:o}", "category", "", "tags", json_array());
|
|
json_object_set_new(g_webui.assignments, key, entry);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
static void store_set_category(uint64_t id, const char *category) {
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
json_t *entry = assignment_locked(id, true);
|
|
if (entry)
|
|
json_object_set_new(entry, "category",
|
|
json_string(category ? category : ""));
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
}
|
|
|
|
static void store_update_tags(uint64_t id, json_t *tags, bool add) {
|
|
if (!json_is_array(tags)) return;
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
json_t *entry = assignment_locked(id, true);
|
|
json_t *have = entry ? json_object_get(entry, "tags") : NULL;
|
|
if (have) {
|
|
size_t index;
|
|
json_t *value;
|
|
json_array_foreach(tags, index, value) {
|
|
const char *name = json_string_value(value);
|
|
if (!name) continue;
|
|
size_t pos = 0;
|
|
bool present = false;
|
|
for (; pos < json_array_size(have); pos++)
|
|
if (strcmp(json_string_value(json_array_get(have, pos)),
|
|
name) == 0) { present = true; break; }
|
|
if (add && !present)
|
|
json_array_append_new(have, json_string(name));
|
|
else if (!add && present)
|
|
json_array_remove(have, pos);
|
|
}
|
|
}
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
}
|
|
|
|
static void store_set_name(uint64_t id, const char *name) {
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
json_t *entry = assignment_locked(id, true);
|
|
if (entry) json_object_set_new(entry, "name", json_string(name));
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
}
|
|
|
|
static bool parse_id(const char *text, uint64_t *id);
|
|
|
|
/* Mirror a torrent's category + tags into the daemon (which persists them and
|
|
* exposes the flattened set to Lua via naut.get_labels). The web layer is the
|
|
* editing surface; the daemon is the source of truth. Snapshots the assignment
|
|
* under meta_lock, then RPCs without it held. */
|
|
static void webui_sync_labels(uint64_t id) {
|
|
char key[32];
|
|
snprintf(key, sizeof key, "%llu", (unsigned long long)id);
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
json_t *entry = json_object_get(g_webui.assignments, key);
|
|
char *category = strdup(entry ? json_string_or(entry, "category", "") : "");
|
|
json_t *tags_src = entry ? json_object_get(entry, "tags") : NULL;
|
|
json_t *tags = tags_src ? json_deep_copy(tags_src) : json_array();
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
|
|
json_t *params = json_pack("{s:I,s:s,s:o}", "torrent_id", (json_int_t)id,
|
|
"category", category ? category : "",
|
|
"tags", tags);
|
|
free(category);
|
|
if (!params) { json_decref(tags); return; }
|
|
json_t *reply = rpc_call_json("set_labels", params);
|
|
json_decref(params);
|
|
if (reply) json_decref(reply);
|
|
}
|
|
|
|
/* Re-push every torrent's labels (after a global category/tag removal that can
|
|
* touch many assignments at once). */
|
|
static void webui_sync_all_labels(void) {
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
size_t n = json_object_size(g_webui.assignments);
|
|
uint64_t *ids = n ? malloc(n * sizeof *ids) : NULL;
|
|
size_t count = 0;
|
|
if (ids) {
|
|
const char *key;
|
|
json_t *entry;
|
|
json_object_foreach(g_webui.assignments, key, entry) {
|
|
uint64_t id = 0;
|
|
if (parse_id(key, &id)) ids[count++] = id;
|
|
}
|
|
}
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
for (size_t i = 0; i < count; i++) webui_sync_labels(ids[i]);
|
|
free(ids);
|
|
}
|
|
|
|
/* Persist the full category + tag lists (including unassigned ones) to the
|
|
* daemon so they survive restarts. */
|
|
/* Persist the category + tag lists to the web-UI's own database. */
|
|
static void webui_sync_taxonomy(void) {
|
|
if (!g_webui.store) return;
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
json_t *cats = json_deep_copy(g_webui.categories);
|
|
json_t *tags = json_deep_copy(g_webui.tags);
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
if (cats) { webui_store_save_categories(g_webui.store, cats); json_decref(cats); }
|
|
if (tags) { webui_store_save_tags(g_webui.store, tags); json_decref(tags); }
|
|
}
|
|
|
|
/* Seed the category + tag lists from the database at startup. */
|
|
static void webui_load_taxonomy(void) {
|
|
if (!g_webui.store) return;
|
|
json_t *cats = json_array(), *tags = json_array();
|
|
bool ok_c = webui_store_load_categories(g_webui.store, cats);
|
|
bool ok_t = webui_store_load_tags(g_webui.store, tags);
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
if (ok_c) { json_decref(g_webui.categories); g_webui.categories = cats; }
|
|
else json_decref(cats);
|
|
if (ok_t) { json_decref(g_webui.tags); g_webui.tags = tags; }
|
|
else json_decref(tags);
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
}
|
|
|
|
static bool store_get_name(uint64_t id, char *out, size_t out_size) {
|
|
bool found = false;
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
json_t *entry = assignment_locked(id, false);
|
|
const char *name = entry ? json_string_value(json_object_get(entry, "name"))
|
|
: NULL;
|
|
if (name && *name) {
|
|
snprintf(out, out_size, "%s", name);
|
|
found = true;
|
|
}
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
return found;
|
|
}
|
|
|
|
static void store_forget(uint64_t id) {
|
|
char key[32];
|
|
snprintf(key, sizeof key, "%llu", (unsigned long long)id);
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
json_object_del(g_webui.assignments, key);
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
}
|
|
|
|
/* On first sight of a torrent (e.g. right after a restart, when the in-memory
|
|
* store is empty), seed its assignment + the global category/tag lists from the
|
|
* daemon's persisted category/tags. Only creates a missing entry, so live web
|
|
* edits are never clobbered. */
|
|
static void seed_assignment_from_daemon(uint64_t id, json_t *torrent) {
|
|
const char *category = json_string_or(torrent, "category", "");
|
|
json_t *tags = json_object_get(torrent, "tags");
|
|
char key[32];
|
|
snprintf(key, sizeof key, "%llu", (unsigned long long)id);
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
if (!json_object_get(g_webui.assignments, key)) {
|
|
json_t *entry = json_pack(
|
|
"{s:s,s:o}", "category", category,
|
|
"tags", json_is_array(tags) ? json_deep_copy(tags) : json_array());
|
|
if (entry) json_object_set_new(g_webui.assignments, key, entry);
|
|
if (category && *category && find_category(category) < 0)
|
|
json_array_append_new(g_webui.categories, json_pack(
|
|
"{s:s,s:s}", "name", category, "savePath", ""));
|
|
size_t i;
|
|
json_t *v;
|
|
json_array_foreach(tags, i, v) {
|
|
const char *t = json_string_value(v);
|
|
if (t && *t && find_tag(t) < 0)
|
|
json_array_append_new(g_webui.tags, json_string(t));
|
|
}
|
|
}
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
}
|
|
|
|
/* Fill in category + tags for a torrent from the assignment store. */
|
|
static void apply_assignment(json_t *out, uint64_t id) {
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
json_t *entry = assignment_locked(id, false);
|
|
const char *category = entry ? json_string_or(entry, "category", "") : "";
|
|
json_t *tags = entry ? json_object_get(entry, "tags") : NULL;
|
|
json_object_set_new(out, "category", json_string(category));
|
|
json_object_set_new(out, "tags",
|
|
tags ? json_deep_copy(tags) : json_array());
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
}
|
|
|
|
static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) {
|
|
uint64_t id = json_u64(torrent, "torrent_id");
|
|
uint64_t done = 0, total = 0;
|
|
double progress = torrent_progress_ratio(torrent, &done, &total);
|
|
uint64_t pieces = json_u64(torrent, "total_pieces");
|
|
uint64_t pieces_done = json_u64(torrent, "pieces_done");
|
|
|
|
char hash[32];
|
|
snprintf(hash, sizeof hash, "%llu", (unsigned long long)id);
|
|
char *name = torrent_name(torrent);
|
|
if (!name) return NULL;
|
|
/* Prefer the display name the UI captured at add time over the daemon's
|
|
* temp upload path. */
|
|
char override[256];
|
|
if (store_get_name(id, override, sizeof override)) {
|
|
char *better = strdup(override);
|
|
if (better) { free(name); name = better; }
|
|
}
|
|
bool force_start =
|
|
json_boolean_value(json_object_get(torrent, "force_start"));
|
|
const char *state = ui_state(json_string_value(json_object_get(torrent,
|
|
"state")),
|
|
progress);
|
|
if (force_start && strcmp(state, "downloading") == 0)
|
|
state = progress >= 1.0 ? "forcedUP" : "forcedDL";
|
|
|
|
json_t *trackers = NULL;
|
|
json_t *files = NULL;
|
|
json_t *peers_list = NULL;
|
|
json_t *hosts = NULL;
|
|
json_t *source_trackers = json_object_get(torrent, "trackers");
|
|
json_t *source_files = json_object_get(torrent, "files");
|
|
hosts = tracker_hosts(source_trackers);
|
|
if (detail) {
|
|
trackers = json_is_array(source_trackers)
|
|
? json_deep_copy(source_trackers) : json_array();
|
|
files = json_is_array(source_files)
|
|
? json_deep_copy(source_files) : json_array();
|
|
if (files && json_array_size(files) == 0 && total > 0)
|
|
json_array_append_new(files, json_pack(
|
|
"{s:s,s:I,s:f,s:i,s:f}", "name", name,
|
|
"size", (json_int_t)total, "progress", progress,
|
|
"priority", 1, "availability", 1.0));
|
|
peers_list = map_peer_list(torrent);
|
|
}
|
|
|
|
uint64_t discovered = json_u64(torrent, "peers_discovered");
|
|
const char *output = json_string_or(torrent, "output", "");
|
|
/* Built field-by-field on purpose: a single 30-key json_pack drifts out of
|
|
* sync with its argument list silently and then crashes on a type mismatch. */
|
|
json_t *out = json_object();
|
|
if (!out) {
|
|
free(name);
|
|
json_decref(hosts);
|
|
if (detail) {
|
|
json_decref(trackers);
|
|
json_decref(files);
|
|
json_decref(peers_list);
|
|
}
|
|
return NULL;
|
|
}
|
|
json_object_set_new(out, "hash", json_string(hash));
|
|
json_object_set_new(out, "name", json_string(name));
|
|
json_object_set_new(out, "size", json_integer((json_int_t)total));
|
|
json_object_set_new(out, "progress", json_real(progress));
|
|
json_object_set_new(out, "dlspeed", json_integer((json_int_t)dlspeed));
|
|
json_object_set_new(out, "upspeed", json_integer(0));
|
|
json_object_set_new(out, "eta", json_integer(compute_eta(done, total, dlspeed)));
|
|
json_object_set_new(out, "seeds",
|
|
json_integer((json_int_t)json_u64(torrent, "peers")));
|
|
json_object_set_new(out, "seedsTotal", json_integer((json_int_t)discovered));
|
|
json_object_set_new(out, "peers",
|
|
json_integer((json_int_t)json_u64(torrent, "peers_connecting")));
|
|
json_object_set_new(out, "peersTotal", json_integer((json_int_t)discovered));
|
|
json_object_set_new(out, "ratio", json_real(0.0));
|
|
json_object_set_new(out, "savePath", json_string(output));
|
|
json_object_set_new(out, "addedOn", json_integer(0));
|
|
json_object_set_new(out, "completionOn",
|
|
json_integer(progress >= 1.0 ? 0 : -1));
|
|
json_object_set_new(out, "lastActivity", json_integer(0));
|
|
json_object_set_new(out, "downloaded", json_integer((json_int_t)done));
|
|
json_object_set_new(out, "uploaded", json_integer(0));
|
|
json_object_set_new(out, "availability", json_real(1.0));
|
|
int64_t queue_pos = json_i64_or(torrent, "queue_pos", 0);
|
|
json_object_set_new(out, "priority",
|
|
json_integer((json_int_t)(queue_pos < 0
|
|
? 1 : queue_pos + 1)));
|
|
json_object_set_new(out, "queuePos", json_integer((json_int_t)queue_pos));
|
|
json_object_set_new(out, "trackerHosts", hosts ? hosts : json_array());
|
|
json_object_set_new(out, "seqDl", json_false());
|
|
json_object_set_new(out, "superSeeding", json_false());
|
|
json_object_set_new(out, "forceStart", json_boolean(force_start));
|
|
json_object_set_new(out, "timeActive",
|
|
json_integer((json_int_t)json_u64(torrent, "elapsed_seconds")));
|
|
json_object_set_new(out, "pieceSize",
|
|
json_integer(pieces ? (json_int_t)(total / pieces) : 0));
|
|
json_object_set_new(out, "state", json_string(state));
|
|
json_object_set_new(out, "contentPath", json_string(output));
|
|
/* Seed the web-layer store from the daemon's persisted category/tags the
|
|
* first time we see a torrent (survives restarts), then apply it. */
|
|
seed_assignment_from_daemon(id, torrent);
|
|
apply_assignment(out, id);
|
|
if (detail) {
|
|
json_object_set_new(out, "comment", json_string(""));
|
|
json_object_set_new(out, "createdBy", json_string("Naut"));
|
|
json_object_set_new(out, "creationDate", json_integer(0));
|
|
json_object_set_new(out, "private", json_false());
|
|
json_object_set_new(out, "magnetUri",
|
|
json_string(json_string_or(torrent, "source", "")));
|
|
json_object_set_new(out, "pieceCount", json_integer((json_int_t)pieces));
|
|
json_object_set_new(out, "piecesDone", json_integer((json_int_t)pieces_done));
|
|
json_t *piece_states = json_object_get(torrent, "piece_states");
|
|
json_object_set_new(out, "pieceStates",
|
|
json_is_array(piece_states)
|
|
? json_deep_copy(piece_states)
|
|
: json_array());
|
|
json_object_set_new(out, "trackers", trackers ? trackers : json_array());
|
|
json_object_set_new(out, "peersList", peers_list ? peers_list : json_array());
|
|
json_object_set_new(out, "files", files ? files : json_array());
|
|
}
|
|
free(name);
|
|
return out;
|
|
}
|
|
|
|
static json_t *preferences_json(void);
|
|
|
|
/* Build a fresh snapshot (grid + global stats) with live download rates. */
|
|
static json_t *build_snapshot(void) {
|
|
json_t *params = json_object();
|
|
json_t *torrents = rpc_call_json("torrents", params);
|
|
json_decref(params);
|
|
if (!json_is_array(torrents)) {
|
|
json_decref(torrents);
|
|
torrents = json_array();
|
|
}
|
|
speed_retain(torrents);
|
|
|
|
json_t *prefs = preferences_json();
|
|
json_t *items = json_array();
|
|
uint64_t active = 0;
|
|
uint64_t total_rate = 0;
|
|
uint64_t total_data = 0;
|
|
size_t index;
|
|
json_t *torrent;
|
|
json_array_foreach(torrents, index, torrent) {
|
|
uint64_t id = json_u64(torrent, "torrent_id");
|
|
uint64_t done = json_u64(torrent, "bytes_done");
|
|
double dlspeed = speed_sample(id, done);
|
|
json_t *mapped = map_torrent(torrent, false, dlspeed);
|
|
if (!mapped) continue;
|
|
const char *state = json_string_value(json_object_get(mapped, "state"));
|
|
if (state && (strcmp(state, "downloading") == 0 ||
|
|
strcmp(state, "forcedDL") == 0)) active++;
|
|
total_rate += (uint64_t)dlspeed;
|
|
total_data += done;
|
|
int64_t q = json_i64_or(mapped, "queuePos", 0);
|
|
size_t pos = 0;
|
|
for (; pos < json_array_size(items); pos++) {
|
|
json_t *cur = json_array_get(items, pos);
|
|
if (q < json_i64_or(cur, "queuePos", 0)) break;
|
|
}
|
|
if (json_array_insert_new(items, pos, mapped) != 0)
|
|
json_decref(mapped);
|
|
}
|
|
json_decref(torrents);
|
|
bool alt_speed = prefs &&
|
|
json_boolean_value(json_object_get(prefs, "alt_speed_enabled"));
|
|
uint64_t dl_limit = prefs ? json_u64(prefs, alt_speed ? "alt_dl_limit"
|
|
: "dl_limit") : 0;
|
|
uint64_t up_limit = prefs ? json_u64(prefs, alt_speed ? "alt_up_limit"
|
|
: "up_limit") : 0;
|
|
json_t *server = json_pack(
|
|
"{s:I,s:i,s:I,s:i,s:I,s:I,s:b,s:f,s:i,s:s,s:i,s:I,s:i,s:i,s:s,s:i}",
|
|
"dl_info_speed", (json_int_t)total_rate,
|
|
"up_info_speed", 0,
|
|
"dl_info_data", (json_int_t)total_data,
|
|
"up_info_data", 0,
|
|
"dl_rate_limit", (json_int_t)dl_limit,
|
|
"up_rate_limit", (json_int_t)up_limit,
|
|
"alt_speed_enabled", alt_speed,
|
|
"global_ratio", 0.0,
|
|
"dht_nodes", 0,
|
|
"connection_status", "connected",
|
|
"listen_port", g_webui.port,
|
|
"free_space", (json_int_t)0,
|
|
"active_torrents", (int)active,
|
|
"total_torrents", (int)json_array_size(items),
|
|
"read_cache_hits", "0.0",
|
|
"queued_io_jobs", 0);
|
|
json_decref(prefs);
|
|
return json_pack("{s:I,s:o,s:o}", "ts", (json_int_t)time(NULL) * 1000,
|
|
"server", server, "torrents", items);
|
|
}
|
|
|
|
/* Publish a newly built snapshot for all readers; wakes SSE waiters. */
|
|
static void publish_snapshot(void) {
|
|
json_t *snapshot = build_snapshot();
|
|
if (!snapshot) return;
|
|
char *full = json_dumps(snapshot, JSON_COMPACT | JSON_ENCODE_ANY);
|
|
json_t *torrents = json_object_get(snapshot, "torrents");
|
|
char *list = json_dumps(torrents ? torrents : json_array(),
|
|
JSON_COMPACT | JSON_ENCODE_ANY);
|
|
json_decref(snapshot);
|
|
if (!full || !list) {
|
|
free(full);
|
|
free(list);
|
|
return;
|
|
}
|
|
pthread_mutex_lock(&g_webui.snap_lock);
|
|
free(g_webui.snapshot_str);
|
|
free(g_webui.torrents_str);
|
|
g_webui.snapshot_str = full;
|
|
g_webui.torrents_str = list;
|
|
g_webui.snap_seq++;
|
|
pthread_cond_broadcast(&g_webui.snap_cond);
|
|
pthread_mutex_unlock(&g_webui.snap_lock);
|
|
}
|
|
|
|
static void *sampler_thread(void *arg) {
|
|
(void)arg;
|
|
while (!atomic_load(&g_webui.stopping)) {
|
|
publish_snapshot();
|
|
/* sleep ~1s but stay responsive to shutdown */
|
|
for (int i = 0; i < 10 && !atomic_load(&g_webui.stopping); i++) {
|
|
struct timespec ts = { .tv_sec = 0, .tv_nsec = 100 * 1000 * 1000 };
|
|
nanosleep(&ts, NULL);
|
|
}
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
static bool parse_id(const char *text, uint64_t *id) {
|
|
if (!text || !*text) return false;
|
|
char *end = NULL;
|
|
unsigned long long value = strtoull(text, &end, 10);
|
|
if (!end || (*end && *end != '/')) return false;
|
|
*id = (uint64_t)value;
|
|
return true;
|
|
}
|
|
|
|
static json_t *full_torrent_by_hash(const char *hash) {
|
|
uint64_t id = 0;
|
|
if (!parse_id(hash, &id)) return NULL;
|
|
json_t *params = json_pack("{s:I}", "torrent_id", (json_int_t)id);
|
|
json_t *torrent = rpc_call_json("torrent", params);
|
|
json_decref(params);
|
|
if (!torrent) return NULL;
|
|
json_t *mapped = map_torrent(torrent, true, speed_peek(id));
|
|
json_decref(torrent);
|
|
return mapped;
|
|
}
|
|
|
|
static json_t *preferences_json(void) {
|
|
json_t *params = json_object();
|
|
json_t *prefs = rpc_call_json("get_preferences", params);
|
|
json_decref(params);
|
|
if (!json_is_object(prefs)) {
|
|
json_decref(prefs);
|
|
prefs = json_object();
|
|
}
|
|
if (!prefs) return NULL;
|
|
|
|
json_t *max_active = json_object_get(prefs, "max_active");
|
|
if (json_is_integer(max_active) &&
|
|
!json_object_get(prefs, "max_active_downloads")) {
|
|
json_object_set_new(prefs, "max_active_downloads",
|
|
json_integer(json_integer_value(max_active)));
|
|
}
|
|
if (!json_object_get(prefs, "save_path"))
|
|
json_object_set_new(prefs, "save_path",
|
|
json_string(getenv("NAUT_WEBUI_SAVE_PATH")
|
|
? getenv("NAUT_WEBUI_SAVE_PATH") : "."));
|
|
if (!json_object_get(prefs, "dl_limit"))
|
|
json_object_set_new(prefs, "dl_limit", json_integer(0));
|
|
if (!json_object_get(prefs, "up_limit"))
|
|
json_object_set_new(prefs, "up_limit", json_integer(0));
|
|
if (!json_object_get(prefs, "alt_dl_limit"))
|
|
json_object_set_new(prefs, "alt_dl_limit", json_integer(0));
|
|
if (!json_object_get(prefs, "alt_up_limit"))
|
|
json_object_set_new(prefs, "alt_up_limit", json_integer(0));
|
|
if (!json_object_get(prefs, "alt_speed_enabled"))
|
|
json_object_set_new(prefs, "alt_speed_enabled", json_false());
|
|
json_object_set_new(prefs, "max_connec", json_integer(500));
|
|
json_object_set_new(prefs, "max_connec_per_torrent", json_integer(100));
|
|
json_object_set_new(prefs, "max_uploads", json_integer(20));
|
|
json_object_set_new(prefs, "max_active_uploads", json_integer(10));
|
|
json_object_set_new(prefs, "max_active_torrents",
|
|
json_integer((json_int_t)json_i64_or(
|
|
prefs, "max_active_downloads", 5)));
|
|
return prefs;
|
|
}
|
|
|
|
static void api_meta(int fd) {
|
|
json_t *json = json_object();
|
|
json_t *preferences = preferences_json();
|
|
if (!json || !preferences) {
|
|
json_decref(json);
|
|
json_decref(preferences);
|
|
http_text(fd, 500, "Internal Server Error", "oom");
|
|
return;
|
|
}
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
json_object_set_new(json, "categories",
|
|
json_deep_copy(g_webui.categories));
|
|
json_object_set_new(json, "tags", json_deep_copy(g_webui.tags));
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
json_t *params = json_object();
|
|
json_t *torrents = rpc_call_json("torrents", params);
|
|
json_decref(params);
|
|
json_t *trackers = tracker_summary(torrents);
|
|
json_decref(torrents);
|
|
json_object_set_new(json, "trackers", trackers ? trackers : json_array());
|
|
json_object_set_new(json, "preferences", preferences);
|
|
/* searchPlugins mirrors the configured Torznab indexers for the Search tab. */
|
|
json_t *plugins = json_array();
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
size_t ii; json_t *ix;
|
|
json_array_foreach(g_webui.indexers, ii, ix)
|
|
json_array_append_new(plugins, json_pack("{s:s,s:s,s:s,s:b}",
|
|
"name", json_string_or(ix, "name", ""),
|
|
"url", json_string_or(ix, "url", ""),
|
|
"apikey", json_string_or(ix, "apikey", ""),
|
|
"enabled", json_boolean_value(json_object_get(ix, "enabled"))));
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
json_object_set_new(json, "searchPlugins", plugins);
|
|
http_json(fd, 200, json);
|
|
json_decref(json);
|
|
}
|
|
|
|
static void api_plugins(int fd) {
|
|
char path[PATH_MAX];
|
|
if (!join_root_path(path, sizeof path, "/plugins/plugins.json")) {
|
|
http_text(fd, 500, "Internal Server Error", "root too long");
|
|
return;
|
|
}
|
|
json_error_t error;
|
|
json_t *manifest = json_load_file(path, JSON_REJECT_DUPLICATES, &error);
|
|
json_t *modules = json_array();
|
|
if (json_is_object(manifest)) {
|
|
json_t *raw = json_object_get(manifest, "modules");
|
|
if (json_is_array(raw)) {
|
|
size_t index;
|
|
json_t *value;
|
|
json_array_foreach(raw, index, value) {
|
|
const char *module = json_string_value(value);
|
|
if (module && strncmp(module, "/plugins/", 9) == 0 &&
|
|
strstr(module, ".js"))
|
|
json_array_append_new(modules, json_string(module));
|
|
}
|
|
}
|
|
}
|
|
json_decref(manifest);
|
|
json_t *reply = json_pack("{s:o}", "modules", modules);
|
|
http_json(fd, 200, reply);
|
|
json_decref(reply);
|
|
}
|
|
|
|
static json_t *read_body_json(const char *body, size_t len) {
|
|
if (!body || len == 0) return json_object();
|
|
json_error_t error;
|
|
json_t *json = json_loadb(body, len, JSON_REJECT_DUPLICATES, &error);
|
|
return json ? json : json_object();
|
|
}
|
|
|
|
static const char *path_after(const char *path, const char *prefix) {
|
|
size_t len = strlen(prefix);
|
|
return strncmp(path, prefix, len) == 0 ? path + len : NULL;
|
|
}
|
|
|
|
static void api_torrent_detail(int fd, const char *tail) {
|
|
char hash[64];
|
|
size_t n = strcspn(tail, "/?");
|
|
snprintf(hash, sizeof hash, "%.*s", (int)n, tail);
|
|
json_t *torrent = full_torrent_by_hash(hash);
|
|
if (!torrent) {
|
|
http_text(fd, 404, "Not Found", "not found");
|
|
return;
|
|
}
|
|
char tab[64] = {0};
|
|
if (tail[n] == '/')
|
|
snprintf(tab, sizeof tab, "%s", tail + n + 1);
|
|
strip_query(tab);
|
|
if (strcmp(tab, "trackers") == 0) {
|
|
json_t *value = json_incref(json_object_get(torrent, "trackers"));
|
|
http_json(fd, 200, value);
|
|
json_decref(value);
|
|
} else if (strcmp(tab, "peers") == 0) {
|
|
json_t *value = json_incref(json_object_get(torrent, "peersList"));
|
|
http_json(fd, 200, value);
|
|
json_decref(value);
|
|
} else if (strcmp(tab, "files") == 0) {
|
|
json_t *value = json_incref(json_object_get(torrent, "files"));
|
|
http_json(fd, 200, value);
|
|
json_decref(value);
|
|
} else if (strcmp(tab, "pieces") == 0) {
|
|
uint64_t count = json_u64(torrent, "pieceCount");
|
|
uint64_t done = json_u64(torrent, "piecesDone");
|
|
json_t *states = json_object_get(torrent, "pieceStates");
|
|
json_t *pieces = json_is_array(states) ? json_deep_copy(states)
|
|
: json_array();
|
|
if (pieces && json_array_size(pieces) == 0)
|
|
for (uint64_t i = 0; i < count && i < 4000; i++)
|
|
json_array_append_new(pieces, json_integer(i < done ? 2 : 0));
|
|
json_t *value = json_pack("{s:I,s:I,s:o}",
|
|
"pieceSize", json_u64(torrent, "pieceSize"),
|
|
"pieceCount", count, "pieces", pieces);
|
|
http_json(fd, 200, value);
|
|
json_decref(value);
|
|
} else {
|
|
http_json(fd, 200, torrent);
|
|
}
|
|
json_decref(torrent);
|
|
}
|
|
|
|
static void api_add(int fd, const char *body, size_t len) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *source = json_string_value(json_object_get(req, "source"));
|
|
const char *magnet = json_string_value(json_object_get(req, "magnet"));
|
|
const char *data = json_string_value(json_object_get(req, "data"));
|
|
const char *save_path = json_string_value(json_object_get(req, "savePath"));
|
|
/* The UI parses the .torrent client-side and sends a display name; the
|
|
* daemon only knows the temp upload path, so we keep the name here. Copy
|
|
* out of `req` since it is freed before these are used below. */
|
|
const char *display = json_string_value(json_object_get(req, "name"));
|
|
char display_name[256] = {0};
|
|
if (display && *display)
|
|
snprintf(display_name, sizeof display_name, "%s", display);
|
|
const char *category = json_string_value(json_object_get(req, "category"));
|
|
char category_name[256] = {0};
|
|
if (category && *category)
|
|
snprintf(category_name, sizeof category_name, "%s", category);
|
|
if (!save_path || !*save_path) save_path = ".";
|
|
if (!source) source = magnet;
|
|
if ((!source || !*source) && (!data || !*data)) {
|
|
/* A request carrying only parsed metadata (name/files) but no bytes is
|
|
* the tell-tale of a stale UI that predates base64 upload support. */
|
|
bool looks_stale = json_object_get(req, "name") ||
|
|
json_object_get(req, "files");
|
|
json_decref(req);
|
|
http_text(fd, 400, "Bad Request", looks_stale
|
|
? "no torrent bytes in request: the page is running an old UI. "
|
|
"Hard-reload the browser (Ctrl+Shift+R) and add the file again."
|
|
: "send a magnet, a source path, or uploaded torrent bytes");
|
|
return;
|
|
}
|
|
/* Own a copy of the tags (req is freed before they are applied below). */
|
|
json_t *tags = json_is_array(json_object_get(req, "tags"))
|
|
? json_deep_copy(json_object_get(req, "tags")) : NULL;
|
|
json_t *params = json_object();
|
|
json_object_set_new(params, "output", json_string(save_path));
|
|
if (source && *source) json_object_set_new(params, "source", json_string(source));
|
|
if (data && *data) json_object_set_new(params, "data", json_string(data));
|
|
if (json_object_get(req, "paused"))
|
|
json_object_set_new(params, "paused",
|
|
json_boolean(json_boolean_value(
|
|
json_object_get(req, "paused"))));
|
|
/* Forward the display name so the daemon persists it for restore. */
|
|
if (display_name[0])
|
|
json_object_set_new(params, "name", json_string(display_name));
|
|
/* An empty category means "Uncategorized"; only forward a real one. The
|
|
* daemon (spawn_torrent) reads "category" and persists it. */
|
|
if (category_name[0])
|
|
json_object_set_new(params, "category", json_string(category_name));
|
|
if (tags && json_array_size(tags) > 0)
|
|
json_object_set_new(params, "tags", json_deep_copy(tags));
|
|
naut_err add_err = NAUT_OK;
|
|
json_t *result = rpc_call_json_err("add_torrent", params, &add_err);
|
|
json_decref(params);
|
|
json_decref(req);
|
|
if (!result) {
|
|
json_decref(tags);
|
|
if (add_err == NAUT_ERR_EXIST)
|
|
http_text(fd, 409, "Conflict",
|
|
"this torrent's data would overlap an existing torrent; "
|
|
"choose a different save path");
|
|
else
|
|
http_text(fd, 502, "Bad Gateway", "add_torrent failed");
|
|
return;
|
|
}
|
|
uint64_t new_id = json_u64(result, "torrent_id");
|
|
if (display_name[0]) store_set_name(new_id, display_name);
|
|
if (category_name[0]) store_set_category(new_id, category_name);
|
|
if (tags && json_array_size(tags) > 0) {
|
|
store_update_tags(new_id, tags, true); /* assign to the new torrent */
|
|
size_t ti;
|
|
json_t *tv;
|
|
json_array_foreach(tags, ti, tv) { /* register any new tag names */
|
|
const char *t = json_string_value(tv);
|
|
if (t && *t) store_add_tag(t);
|
|
}
|
|
webui_sync_taxonomy(); /* persist the global tag list */
|
|
}
|
|
json_decref(tags);
|
|
char id[32];
|
|
snprintf(id, sizeof id, "%llu", (unsigned long long)new_id);
|
|
json_t *reply = json_pack("{s:b,s:s}", "ok", 1, "hash", id);
|
|
http_json(fd, 200, reply);
|
|
json_decref(reply);
|
|
json_decref(result);
|
|
/* refresh the shared snapshot so the new torrent shows up immediately */
|
|
publish_snapshot();
|
|
}
|
|
|
|
static void api_delete(int fd, const char *body, size_t len) {
|
|
json_t *req = read_body_json(body, len);
|
|
json_t *hashes = json_object_get(req, "hashes");
|
|
size_t removed = 0;
|
|
if (json_is_array(hashes)) {
|
|
size_t index;
|
|
json_t *hash;
|
|
json_array_foreach(hashes, index, hash) {
|
|
uint64_t id = 0;
|
|
if (!parse_id(json_string_value(hash), &id)) continue;
|
|
json_t *params = json_pack("{s:I}", "torrent_id", (json_int_t)id);
|
|
json_t *result = rpc_call_json("remove_torrent", params);
|
|
json_decref(params);
|
|
if (result) {
|
|
removed++;
|
|
store_forget(id);
|
|
json_decref(result);
|
|
}
|
|
}
|
|
}
|
|
json_decref(req);
|
|
json_t *reply = json_pack("{s:b,s:i}", "ok", 1, "removed", (int)removed);
|
|
http_json(fd, 200, reply);
|
|
json_decref(reply);
|
|
if (removed) publish_snapshot();
|
|
}
|
|
|
|
static bool rpc_for_torrent(const char *method, uint64_t id, json_t *extra) {
|
|
json_t *params = json_object();
|
|
if (!params) return false;
|
|
json_object_set_new(params, "torrent_id", json_integer((json_int_t)id));
|
|
if (json_is_object(extra)) {
|
|
const char *key;
|
|
json_t *value;
|
|
json_object_foreach(extra, key, value)
|
|
json_object_set(params, key, value);
|
|
}
|
|
json_t *result = rpc_call_json(method, params);
|
|
json_decref(params);
|
|
if (!result) return false;
|
|
json_decref(result);
|
|
return true;
|
|
}
|
|
|
|
static const char *queue_op_for_action(const char *action) {
|
|
if (strcmp(action, "topPriority") == 0) return "top";
|
|
if (strcmp(action, "bottomPriority") == 0) return "bottom";
|
|
if (strcmp(action, "increasePriority") == 0) return "up";
|
|
if (strcmp(action, "decreasePriority") == 0) return "down";
|
|
return NULL;
|
|
}
|
|
|
|
/* Category/tag assignment is web-layer state. Engine-backed verbs delegate to
|
|
* nautd RPCs so toolbar actions mutate the real queue/lifecycle state. */
|
|
static void api_action(int fd, const char *body, size_t len) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *raw_action = json_string_value(json_object_get(req, "action"));
|
|
char action[64];
|
|
snprintf(action, sizeof action, "%s", raw_action ? raw_action : "");
|
|
json_t *hashes = json_object_get(req, "hashes");
|
|
json_t *params = json_object_get(req, "params");
|
|
bool handled = false;
|
|
int affected = 0;
|
|
if (json_is_array(hashes) &&
|
|
(strcmp(action, "setCategory") == 0 ||
|
|
strcmp(action, "addTags") == 0 ||
|
|
strcmp(action, "removeTags") == 0)) {
|
|
size_t index;
|
|
json_t *hash;
|
|
json_array_foreach(hashes, index, hash) {
|
|
uint64_t id = 0;
|
|
if (!parse_id(json_string_value(hash), &id)) continue;
|
|
if (strcmp(action, "setCategory") == 0)
|
|
store_set_category(id,
|
|
json_string_value(json_object_get(params, "category")));
|
|
else
|
|
store_update_tags(id, json_object_get(params, "tags"),
|
|
strcmp(action, "addTags") == 0);
|
|
webui_sync_labels(id); /* mirror to the daemon (persist + Lua) */
|
|
affected++;
|
|
}
|
|
handled = true;
|
|
}
|
|
if (json_is_array(hashes) && !handled) {
|
|
const char *rpc = NULL;
|
|
json_t *extra = NULL;
|
|
if (strcmp(action, "pause") == 0) {
|
|
rpc = "pause_torrent";
|
|
} else if (strcmp(action, "resume") == 0) {
|
|
rpc = "resume_torrent";
|
|
} else if (strcmp(action, "forceStart") == 0) {
|
|
rpc = "resume_torrent";
|
|
extra = json_pack("{s:b}", "force", 1);
|
|
} else if (strcmp(action, "recheck") == 0) {
|
|
rpc = "recheck_torrent";
|
|
} else if (strcmp(action, "setSavePath") == 0) {
|
|
const char *sp =
|
|
json_string_value(json_object_get(params, "savePath"));
|
|
if (sp && *sp) {
|
|
rpc = "set_save_path";
|
|
extra = json_pack("{s:s,s:b}", "savePath", sp, "reset",
|
|
json_boolean_value(
|
|
json_object_get(params, "reset")));
|
|
}
|
|
} else {
|
|
const char *op = queue_op_for_action(action);
|
|
if (op) {
|
|
rpc = "queue_move";
|
|
extra = json_pack("{s:s}", "op", op);
|
|
}
|
|
}
|
|
if (rpc) {
|
|
size_t index;
|
|
json_t *hash;
|
|
json_array_foreach(hashes, index, hash) {
|
|
uint64_t id = 0;
|
|
if (!parse_id(json_string_value(hash), &id)) continue;
|
|
if (rpc_for_torrent(rpc, id, extra)) affected++;
|
|
}
|
|
json_decref(extra);
|
|
handled = true;
|
|
}
|
|
}
|
|
json_decref(req);
|
|
if (handled) {
|
|
json_t *json = json_pack("{s:b,s:i}", "ok", 1, "affected", affected);
|
|
http_json(fd, 200, json);
|
|
json_decref(json);
|
|
publish_snapshot();
|
|
return;
|
|
}
|
|
char message[128];
|
|
snprintf(message, sizeof message,
|
|
"action '%s' is not supported by the engine", action);
|
|
json_t *json = json_pack("{s:b,s:s}", "ok", 0, "error", message);
|
|
http_json(fd, 501, json);
|
|
json_decref(json);
|
|
}
|
|
|
|
static void api_preferences(int fd, const char *method,
|
|
const char *body, size_t len) {
|
|
if (strcmp(method, "GET") == 0) {
|
|
json_t *prefs = preferences_json();
|
|
if (!prefs) {
|
|
http_text(fd, 502, "Bad Gateway", "get_preferences failed");
|
|
return;
|
|
}
|
|
http_json(fd, 200, prefs);
|
|
json_decref(prefs);
|
|
return;
|
|
}
|
|
if (strcmp(method, "POST") != 0) {
|
|
http_text(fd, 405, "Method Not Allowed", "method not allowed");
|
|
return;
|
|
}
|
|
json_t *req = read_body_json(body, len);
|
|
json_t *params = json_object();
|
|
if (!req || !params) {
|
|
json_decref(req);
|
|
json_decref(params);
|
|
http_text(fd, 500, "Internal Server Error", "oom");
|
|
return;
|
|
}
|
|
const char *keys[] = {
|
|
"dl_limit", "up_limit", "alt_dl_limit", "alt_up_limit",
|
|
"alt_speed_enabled", "max_active"
|
|
};
|
|
for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) {
|
|
json_t *v = json_object_get(req, keys[i]);
|
|
if (v) json_object_set(params, keys[i], v);
|
|
}
|
|
json_t *max = json_object_get(req, "max_active_downloads");
|
|
if (max) json_object_set(params, "max_active", max);
|
|
json_t *result = rpc_call_json("set_preferences", params);
|
|
json_decref(params);
|
|
json_decref(req);
|
|
if (!json_is_object(result)) {
|
|
json_decref(result);
|
|
http_text(fd, 502, "Bad Gateway", "set_preferences failed");
|
|
return;
|
|
}
|
|
json_decref(result);
|
|
json_t *prefs = preferences_json();
|
|
http_json(fd, 200, prefs);
|
|
json_decref(prefs);
|
|
publish_snapshot();
|
|
}
|
|
|
|
static void api_altspeed(int fd) {
|
|
json_t *params = json_object();
|
|
json_t *result = rpc_call_json("toggle_altspeed", params);
|
|
json_decref(params);
|
|
if (!json_is_object(result)) {
|
|
json_decref(result);
|
|
http_text(fd, 502, "Bad Gateway", "toggle_altspeed failed");
|
|
return;
|
|
}
|
|
http_json(fd, 200, result);
|
|
json_decref(result);
|
|
publish_snapshot();
|
|
}
|
|
|
|
/* POST /api/script/settings — persist user-edited script setting values. Body:
|
|
* { "settings": { "<key>": "<value>", ... } }. */
|
|
static void api_script_settings(int fd, const char *method, const char *body,
|
|
size_t len) {
|
|
if (strcmp(method, "POST") != 0) {
|
|
http_text(fd, 405, "Method Not Allowed", "method not allowed");
|
|
return;
|
|
}
|
|
json_t *req = read_body_json(body, len);
|
|
json_t *settings = req ? json_object_get(req, "settings") : NULL;
|
|
if (!json_is_object(settings)) {
|
|
json_decref(req);
|
|
http_text(fd, 400, "Bad Request", "missing settings object");
|
|
return;
|
|
}
|
|
json_t *params = json_object();
|
|
json_object_set(params, "settings", settings);
|
|
json_decref(req);
|
|
json_t *result = rpc_call_json("set_script_settings", params);
|
|
json_decref(params);
|
|
if (!json_is_object(result)) {
|
|
json_decref(result);
|
|
http_text(fd, 502, "Bad Gateway", "set_script_settings failed");
|
|
return;
|
|
}
|
|
http_json(fd, 200, result);
|
|
json_decref(result);
|
|
}
|
|
|
|
static void api_script(int fd, const char *method, const char *body, size_t len) {
|
|
json_t *params = NULL;
|
|
json_t *result = NULL;
|
|
const char *rpc_name = "script_status";
|
|
if (strcmp(method, "GET") == 0) {
|
|
params = json_object();
|
|
result = rpc_call_json("script_status", params);
|
|
} else if (strcmp(method, "POST") == 0) {
|
|
rpc_name = "update_script";
|
|
json_t *req = read_body_json(body, len);
|
|
const char *source = json_string_value(json_object_get(req, "source"));
|
|
if (!source) {
|
|
json_decref(req);
|
|
http_text(fd, 400, "Bad Request", "missing source");
|
|
return;
|
|
}
|
|
params = json_object();
|
|
json_object_set(params, "source", json_object_get(req, "source"));
|
|
json_decref(req);
|
|
result = rpc_call_json("update_script", params);
|
|
} else {
|
|
http_text(fd, 405, "Method Not Allowed", "method not allowed");
|
|
return;
|
|
}
|
|
json_decref(params);
|
|
if (!json_is_object(result)) {
|
|
json_decref(result);
|
|
char msg[96];
|
|
snprintf(msg, sizeof msg, "%s failed", rpc_name);
|
|
http_text(fd, 502, "Bad Gateway", msg);
|
|
return;
|
|
}
|
|
http_json(fd, 200, result);
|
|
json_decref(result);
|
|
}
|
|
|
|
/* ======================= RSS + Torznab search engine ====================== *
|
|
* The web layer owns RSS feeds, auto-download rules and search indexers; the
|
|
* daemon just persists them (blob store) and adds the torrents we hand it. A
|
|
* background thread polls feeds, parses items, and fires matching rules. */
|
|
|
|
#define RSS_POLL_INTERVAL_SEC (15 * 60) /* re-poll each feed every 15 min */
|
|
#define RSS_MAX_ARTICLES 200 /* keep newest N per feed */
|
|
|
|
/* --- tiny XML helpers (scan, not a real parser; enough for RSS/Atom) ------ */
|
|
|
|
/* Decode the handful of XML entities feeds actually use, in place-ish. */
|
|
static void xml_unescape(char *dst, size_t dstsz, const char *src, size_t n) {
|
|
size_t o = 0;
|
|
for (size_t i = 0; i < n && o + 1 < dstsz; i++) {
|
|
if (src[i] == '&') {
|
|
if (i + 4 < n && strncmp(src + i, "&", 5) == 0) { dst[o++] = '&'; i += 4; continue; }
|
|
if (i + 3 < n && strncmp(src + i, "<", 4) == 0) { dst[o++] = '<'; i += 3; continue; }
|
|
if (i + 3 < n && strncmp(src + i, ">", 4) == 0) { dst[o++] = '>'; i += 3; continue; }
|
|
if (i + 5 < n && strncmp(src + i, """, 6) == 0){ dst[o++] = '"'; i += 5; continue; }
|
|
if (i + 5 < n && strncmp(src + i, "'", 6) == 0){ dst[o++] = '\''; i += 5; continue; }
|
|
if (i + 4 < n && strncmp(src + i, "'", 5) == 0) { dst[o++] = '\''; i += 4; continue; }
|
|
if (i + 1 < n && src[i + 1] == '#') { /* numeric &#NN; */
|
|
int base = 10, k = i + 2;
|
|
if (k < (int)n && (src[k] == 'x' || src[k] == 'X')) { base = 16; k++; }
|
|
long code = strtol(src + k, NULL, base);
|
|
const char *semi = memchr(src + i, ';', n - i);
|
|
if (semi && code > 0 && code < 128) {
|
|
dst[o++] = (char)code;
|
|
i = (size_t)(semi - src);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
dst[o++] = src[i];
|
|
}
|
|
dst[o] = 0;
|
|
}
|
|
|
|
/* Find <tag>...</tag> within [item, item+len) and write its decoded text to
|
|
* out. Handles a single CDATA section. Returns true if found. */
|
|
static bool xml_tag_text(const char *item, size_t len, const char *tag,
|
|
char *out, size_t outsz) {
|
|
char open[64];
|
|
int on = snprintf(open, sizeof open, "<%s", tag);
|
|
if (on < 0 || (size_t)on >= sizeof open) return false;
|
|
const char *p = item, *end = item + len;
|
|
while (p < end) {
|
|
const char *o = memmem(p, (size_t)(end - p), open, (size_t)on);
|
|
if (!o) return false;
|
|
const char *after = o + on;
|
|
if (after < end && *after != '>' && *after != ' ' &&
|
|
*after != '\t' && *after != '/' && *after != ':') { p = after; continue; }
|
|
const char *gt = memchr(o, '>', (size_t)(end - o));
|
|
if (!gt) return false;
|
|
const char *content = gt + 1;
|
|
char close[64];
|
|
snprintf(close, sizeof close, "</%s>", tag);
|
|
const char *c = memmem(content, (size_t)(end - content), close, strlen(close));
|
|
if (!c) return false;
|
|
const char *s = content; size_t slen = (size_t)(c - content);
|
|
if (slen >= 12 && strncmp(s, "<![CDATA[", 9) == 0) {
|
|
s += 9; slen -= 9;
|
|
const char *cd = memmem(s, slen, "]]>", 3);
|
|
if (cd) slen = (size_t)(cd - s);
|
|
}
|
|
while (slen && (*s == ' ' || *s == '\n' || *s == '\r' || *s == '\t')) { s++; slen--; }
|
|
while (slen && (s[slen-1]==' '||s[slen-1]=='\n'||s[slen-1]=='\r'||s[slen-1]=='\t')) slen--;
|
|
xml_unescape(out, outsz, s, slen);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* Pull attribute value attr="..." from the first <tag ...> element in range. */
|
|
static bool xml_attr(const char *item, size_t len, const char *tag,
|
|
const char *attr, char *out, size_t outsz) {
|
|
char open[64];
|
|
int on = snprintf(open, sizeof open, "<%s", tag);
|
|
if (on < 0 || (size_t)on >= sizeof open) return false;
|
|
const char *o = memmem(item, len, open, (size_t)on);
|
|
if (!o) return false;
|
|
const char *gt = memchr(o, '>', (size_t)(item + len - o));
|
|
if (!gt) return false;
|
|
char needle[64];
|
|
int nn = snprintf(needle, sizeof needle, "%s=\"", attr);
|
|
if (nn < 0 || (size_t)nn >= sizeof needle) return false;
|
|
const char *a = memmem(o, (size_t)(gt - o), needle, (size_t)nn);
|
|
if (!a) return false;
|
|
a += nn;
|
|
const char *q = memchr(a, '"', (size_t)(gt - a));
|
|
if (!q) return false;
|
|
xml_unescape(out, outsz, a, (size_t)(q - a));
|
|
return true;
|
|
}
|
|
|
|
/* Locate a magnet: URI anywhere inside the item element. */
|
|
static bool find_magnet(const char *item, size_t len, char *out, size_t outsz) {
|
|
const char *m = memmem(item, len, "magnet:?", 8);
|
|
if (!m) return false;
|
|
size_t i = 0;
|
|
while (m < item + len && *m && *m != '<' && *m != '"' && *m != '\'' &&
|
|
*m != ' ' && *m != '\n' && *m != '\r' && *m != '\t' && i + 1 < outsz)
|
|
out[i++] = *m++;
|
|
out[i] = 0;
|
|
/* decode & that often appears in magnet query separators */
|
|
char tmp[2048];
|
|
xml_unescape(tmp, sizeof tmp, out, strlen(out));
|
|
snprintf(out, outsz, "%s", tmp);
|
|
return i > 8;
|
|
}
|
|
|
|
/* --- base64 (for fetching .torrent enclosures and handing bytes to add) --- */
|
|
static char *base64_encode(const unsigned char *in, size_t len) {
|
|
static const char tbl[] =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
char *out = malloc((len + 2) / 3 * 4 + 1);
|
|
if (!out) return NULL;
|
|
size_t o = 0;
|
|
for (size_t i = 0; i < len; i += 3) {
|
|
unsigned v = in[i] << 16;
|
|
if (i + 1 < len) v |= in[i+1] << 8;
|
|
if (i + 2 < len) v |= in[i+2];
|
|
out[o++] = tbl[(v >> 18) & 63];
|
|
out[o++] = tbl[(v >> 12) & 63];
|
|
out[o++] = (i + 1 < len) ? tbl[(v >> 6) & 63] : '=';
|
|
out[o++] = (i + 2 < len) ? tbl[v & 63] : '=';
|
|
}
|
|
out[o] = 0;
|
|
return out;
|
|
}
|
|
|
|
/* --- RSS persistence (via the daemon blob store) -------------------------- */
|
|
|
|
/* Persist feeds/rules/indexers to the web-UI's own database. */
|
|
static void rss_save(void) {
|
|
if (!g_webui.store) return;
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
json_t *feeds = json_deep_copy(g_webui.rss_feeds);
|
|
json_t *rules = json_deep_copy(g_webui.rss_rules);
|
|
json_t *idx = json_deep_copy(g_webui.indexers);
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
if (feeds) { webui_store_save_feeds(g_webui.store, feeds); json_decref(feeds); }
|
|
if (rules) { webui_store_save_rules(g_webui.store, rules); json_decref(rules); }
|
|
if (idx) { webui_store_save_indexers(g_webui.store, idx); json_decref(idx); }
|
|
}
|
|
|
|
static void rss_load(void) {
|
|
if (!g_webui.store) return;
|
|
json_t *feeds = json_array(), *rules = json_array(), *idx = json_array();
|
|
bool of = webui_store_load_feeds(g_webui.store, feeds);
|
|
bool orr = webui_store_load_rules(g_webui.store, rules);
|
|
bool oi = webui_store_load_indexers(g_webui.store, idx);
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
if (of) { json_decref(g_webui.rss_feeds); g_webui.rss_feeds = feeds; } else json_decref(feeds);
|
|
if (orr) { json_decref(g_webui.rss_rules); g_webui.rss_rules = rules; } else json_decref(rules);
|
|
if (oi) { json_decref(g_webui.indexers); g_webui.indexers = idx; } else json_decref(idx);
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
}
|
|
|
|
/* --- auto-download: hand a matched article to the daemon ------------------ */
|
|
|
|
/* Add a torrent from a magnet, or by fetching a .torrent enclosure URL and
|
|
* uploading its bytes. Applies category/save path/paused, mirrors the label. */
|
|
static bool rss_download(const char *title, const char *magnet,
|
|
const char *torrent_url, const char *category,
|
|
const char *save_path, bool paused) {
|
|
json_t *params = json_object();
|
|
if (!params) return false;
|
|
json_object_set_new(params, "output",
|
|
json_string(save_path && *save_path ? save_path : "."));
|
|
if (paused) json_object_set_new(params, "paused", json_true());
|
|
if (category && *category) json_object_set_new(params, "category", json_string(category));
|
|
/* The article title is the real torrent name; without it the daemon falls
|
|
* back to the temp upload filename (upload-XXXXXX) for fetched .torrents. */
|
|
if (title && *title) json_object_set_new(params, "name", json_string(title));
|
|
|
|
char *fetched = NULL;
|
|
if (magnet && *magnet) {
|
|
json_object_set_new(params, "source", json_string(magnet));
|
|
} else if (torrent_url && *torrent_url) {
|
|
naut_http_response r;
|
|
if (naut_http_get(torrent_url, &r) != NAUT_OK || r.status / 100 != 2) {
|
|
naut_http_response_free(&r);
|
|
json_decref(params);
|
|
return false;
|
|
}
|
|
fetched = base64_encode((const unsigned char *)r.body, r.body_len);
|
|
naut_http_response_free(&r);
|
|
if (!fetched) { json_decref(params); return false; }
|
|
json_object_set_new(params, "data", json_string(fetched));
|
|
} else {
|
|
json_decref(params);
|
|
return false;
|
|
}
|
|
naut_err err = NAUT_OK;
|
|
json_t *result = rpc_call_json_err("add_torrent", params, &err);
|
|
json_decref(params);
|
|
free(fetched);
|
|
if (!result) return false;
|
|
uint64_t id = json_u64(result, "torrent_id");
|
|
if (id && title && *title) store_set_name(id, title);
|
|
if (id && category && *category) store_set_category(id, category);
|
|
json_decref(result);
|
|
publish_snapshot();
|
|
return true;
|
|
}
|
|
|
|
/* Does `article` satisfy `rule`? Substring or POSIX regex on the title. */
|
|
static bool rule_matches(json_t *rule, const char *feed_name, const char *title) {
|
|
if (!json_boolean_value(json_object_get(rule, "enabled"))) return false;
|
|
/* affectedFeeds: empty array means "all feeds". */
|
|
json_t *feeds = json_object_get(rule, "affectedFeeds");
|
|
if (json_is_array(feeds) && json_array_size(feeds) > 0) {
|
|
bool listed = false; size_t i; json_t *v;
|
|
json_array_foreach(feeds, i, v)
|
|
if (strcmp(json_string_value(v) ? json_string_value(v) : "", feed_name) == 0) { listed = true; break; }
|
|
if (!listed) return false;
|
|
}
|
|
const char *must = json_string_or(rule, "mustContain", "");
|
|
const char *mustnot = json_string_or(rule, "mustNotContain", "");
|
|
bool regex = json_boolean_value(json_object_get(rule, "useRegex"));
|
|
if (regex) {
|
|
if (*must) {
|
|
regex_t re;
|
|
if (regcomp(&re, must, REG_EXTENDED | REG_ICASE | REG_NOSUB) != 0) return false;
|
|
int m = regexec(&re, title, 0, NULL, 0);
|
|
regfree(&re);
|
|
if (m != 0) return false;
|
|
}
|
|
if (*mustnot) {
|
|
regex_t re;
|
|
if (regcomp(&re, mustnot, REG_EXTENDED | REG_ICASE | REG_NOSUB) == 0) {
|
|
int m = regexec(&re, title, 0, NULL, 0);
|
|
regfree(&re);
|
|
if (m == 0) return false;
|
|
}
|
|
}
|
|
} else {
|
|
if (*must && !strcasestr(title, must)) return false;
|
|
if (*mustnot && strcasestr(title, mustnot)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* Mark the article with this key as grabbed (across all feeds), so an
|
|
* auto-download rule re-run won't fetch it again. */
|
|
static void rss_mark_grabbed(const char *key) {
|
|
if (!key || !*key) return;
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
size_t fi; json_t *feed;
|
|
json_array_foreach(g_webui.rss_feeds, fi, feed) {
|
|
json_t *articles = json_object_get(feed, "articles");
|
|
size_t ai; json_t *a;
|
|
json_array_foreach(articles, ai, a)
|
|
if (strcmp(json_string_or(a, "key", ""), key) == 0)
|
|
json_object_set_new(a, "grabbed", json_true());
|
|
}
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
}
|
|
|
|
/* Download an article and, on success, flag it grabbed by key. */
|
|
static bool rss_grab_article(const char *key, const char *title,
|
|
const char *magnet, const char *torrent_url,
|
|
const char *cat, const char *path, bool paused) {
|
|
bool ok = rss_download(title, magnet, torrent_url, cat, path, paused);
|
|
if (ok) rss_mark_grabbed(key);
|
|
return ok;
|
|
}
|
|
|
|
/* Run every rule against a freshly-seen article; download the first match. */
|
|
static void rss_run_rules(const char *feed_name, const char *key,
|
|
const char *title, const char *magnet,
|
|
const char *torrent_url) {
|
|
size_t i; json_t *rule;
|
|
json_t *fire = NULL; char cat[128] = {0}, path[1024] = {0}; bool paused = false;
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
json_array_foreach(g_webui.rss_rules, i, rule) {
|
|
if (rule_matches(rule, feed_name, title)) {
|
|
snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", ""));
|
|
snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", ""));
|
|
paused = json_boolean_value(json_object_get(rule, "addPaused"));
|
|
json_object_set_new(rule, "lastMatch", json_integer((json_int_t)time(NULL)));
|
|
fire = rule;
|
|
break;
|
|
}
|
|
}
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
if (!fire) return;
|
|
if (rss_grab_article(key, title, magnet, torrent_url, cat, path, paused))
|
|
log_msg(2, "rss: auto-downloaded a match");
|
|
}
|
|
|
|
/* Parse a feed body into article objects and merge new ones into `feed`.
|
|
* Newly-seen articles are appended to `out_new` (as {title,magnet,torrentUrl})
|
|
* so the caller can fire auto-download rules AFTER releasing rss_lock — running
|
|
* them here would re-enter the lock (and do network I/O while holding it).
|
|
* Returns the number of newly-seen articles. */
|
|
static int rss_ingest(json_t *feed, const char *xml, size_t len, json_t *out_new) {
|
|
json_t *articles = json_object_get(feed, "articles");
|
|
if (!json_is_array(articles)) {
|
|
articles = json_array();
|
|
json_object_set_new(feed, "articles", articles);
|
|
}
|
|
int added = 0;
|
|
const char *p = xml, *end = xml + len;
|
|
for (;;) {
|
|
const char *open = memmem(p, (size_t)(end - p), "<item", 5);
|
|
const char *close_tag = "</item>";
|
|
if (!open) { open = memmem(p, (size_t)(end - p), "<entry", 6); /* Atom */
|
|
close_tag = "</entry>"; }
|
|
if (!open) break;
|
|
const char *close = memmem(open, (size_t)(end - open), close_tag, strlen(close_tag));
|
|
if (!close) break;
|
|
size_t ilen = (size_t)(close - open);
|
|
|
|
char title[512] = {0}, link[1024] = {0}, magnet[2048] = {0};
|
|
char enclosure[1024] = {0}, lenstr[64] = {0}, pub[128] = {0};
|
|
xml_tag_text(open, ilen, "title", title, sizeof title);
|
|
xml_tag_text(open, ilen, "link", link, sizeof link);
|
|
/* Atom (and some RSS) carry the URL as <link href="..."> instead. */
|
|
if (!link[0]) xml_attr(open, ilen, "link", "href", link, sizeof link);
|
|
xml_tag_text(open, ilen, "pubDate", pub, sizeof pub);
|
|
if (!pub[0]) xml_tag_text(open, ilen, "published", pub, sizeof pub);
|
|
find_magnet(open, ilen, magnet, sizeof magnet);
|
|
xml_attr(open, ilen, "enclosure", "url", enclosure, sizeof enclosure);
|
|
if (!xml_attr(open, ilen, "enclosure", "length", lenstr, sizeof lenstr))
|
|
xml_tag_text(open, ilen, "contentLength", lenstr, sizeof lenstr);
|
|
if (!magnet[0] && strncmp(link, "magnet:", 7) == 0)
|
|
snprintf(magnet, sizeof magnet, "%s", link);
|
|
/* A bare <link> to a .torrent is a valid download source too. */
|
|
char dl_url[1024] = {0};
|
|
if (enclosure[0]) snprintf(dl_url, sizeof dl_url, "%s", enclosure);
|
|
else if (strncmp(link, "http", 4) == 0 && strncmp(magnet, "magnet:", 7) != 0)
|
|
snprintf(dl_url, sizeof dl_url, "%s", link);
|
|
|
|
const char *key = magnet[0] ? magnet : (enclosure[0] ? enclosure : link);
|
|
if (title[0] && key && *key) {
|
|
/* dedupe against existing articles by their key */
|
|
bool seen = false; size_t ai; json_t *a;
|
|
json_array_foreach(articles, ai, a) {
|
|
if (strcmp(json_string_or(a, "key", ""), key) == 0) { seen = true; break; }
|
|
}
|
|
if (!seen) {
|
|
json_t *art = json_pack(
|
|
"{s:s,s:s,s:s,s:s,s:s,s:I,s:s,s:b,s:b}",
|
|
"title", title, "key", key,
|
|
"magnet", magnet, "torrentUrl", dl_url, "link", link,
|
|
"size", (json_int_t)strtoll(lenstr, NULL, 10),
|
|
"pubDate", pub, "isRead", 0, "grabbed", 0);
|
|
json_array_insert_new(articles, 0, art);
|
|
added++;
|
|
if (out_new)
|
|
json_array_append_new(out_new, json_pack(
|
|
"{s:s,s:s,s:s,s:s}", "title", title, "key", key,
|
|
"magnet", magnet, "torrentUrl", dl_url));
|
|
}
|
|
}
|
|
p = close + strlen(close_tag);
|
|
}
|
|
/* trim to the newest RSS_MAX_ARTICLES */
|
|
while (json_array_size(articles) > RSS_MAX_ARTICLES)
|
|
json_array_remove(articles, json_array_size(articles) - 1);
|
|
json_object_set_new(feed, "lastUpdate", json_integer((json_int_t)time(NULL)));
|
|
return added;
|
|
}
|
|
|
|
/* Poll one feed (network I/O done without rss_lock held). */
|
|
static void rss_poll_feed_by_index(size_t idx) {
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
json_t *feed = json_array_get(g_webui.rss_feeds, idx);
|
|
char url[1024] = {0};
|
|
if (feed) snprintf(url, sizeof url, "%s", json_string_or(feed, "url", ""));
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
if (!url[0]) return;
|
|
|
|
naut_http_response r;
|
|
if (naut_http_get(url, &r) != NAUT_OK || r.status / 100 != 2 || !r.body) {
|
|
naut_http_response_free(&r);
|
|
return;
|
|
}
|
|
char feed_name[256] = {0};
|
|
json_t *new_articles = json_array();
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
feed = json_array_get(g_webui.rss_feeds, idx); /* re-fetch under lock */
|
|
int added = feed ? rss_ingest(feed, r.body, r.body_len, new_articles) : 0;
|
|
if (feed) snprintf(feed_name, sizeof feed_name, "%s", json_string_or(feed, "name", ""));
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
naut_http_response_free(&r);
|
|
|
|
/* fire auto-download rules now that rss_lock is released */
|
|
size_t i; json_t *a;
|
|
json_array_foreach(new_articles, i, a)
|
|
rss_run_rules(feed_name, json_string_or(a, "key", ""),
|
|
json_string_or(a, "title", ""),
|
|
json_string_or(a, "magnet", ""),
|
|
json_string_or(a, "torrentUrl", ""));
|
|
json_decref(new_articles);
|
|
if (added > 0) rss_save();
|
|
}
|
|
|
|
static void rss_poll_all(void) {
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
size_t n = json_array_size(g_webui.rss_feeds);
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
for (size_t i = 0; i < n && !atomic_load(&g_webui.stopping); i++)
|
|
rss_poll_feed_by_index(i);
|
|
}
|
|
|
|
static void *rss_thread_fn(void *arg) {
|
|
(void)arg;
|
|
rss_load();
|
|
while (!atomic_load(&g_webui.stopping)) {
|
|
rss_poll_all();
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
g_webui.rss_wake = false;
|
|
struct timespec ts;
|
|
clock_gettime(CLOCK_REALTIME, &ts);
|
|
ts.tv_sec += RSS_POLL_INTERVAL_SEC;
|
|
while (!atomic_load(&g_webui.stopping) && !g_webui.rss_wake)
|
|
if (pthread_cond_timedwait(&g_webui.rss_cond, &g_webui.rss_lock, &ts) == ETIMEDOUT)
|
|
break;
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* --- RSS HTTP API --------------------------------------------------------- */
|
|
|
|
/* GET /api/rss → array of feeds (with their articles). */
|
|
static void api_rss_list(int fd) {
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
json_t *reply = json_deep_copy(g_webui.rss_feeds);
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
http_json(fd, 200, reply ? reply : json_array());
|
|
json_decref(reply);
|
|
}
|
|
|
|
/* POST /api/rss {name,url} adds a feed; POST /api/rss/delete {name} removes. */
|
|
static void api_rss_feed(int fd, const char *body, size_t len, bool remove) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *name = json_string_value(json_object_get(req, "name"));
|
|
const char *url = json_string_value(json_object_get(req, "url"));
|
|
bool changed = false;
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
if (remove && name) {
|
|
size_t i; json_t *f;
|
|
json_array_foreach(g_webui.rss_feeds, i, f)
|
|
if (strcmp(json_string_or(f, "name", ""), name) == 0) {
|
|
json_array_remove(g_webui.rss_feeds, i); changed = true; break;
|
|
}
|
|
} else if (name && *name && url && *url) {
|
|
/* upsert by name */
|
|
size_t i; json_t *f; bool found = false;
|
|
json_array_foreach(g_webui.rss_feeds, i, f)
|
|
if (strcmp(json_string_or(f, "name", ""), name) == 0) {
|
|
json_object_set_new(f, "url", json_string(url)); found = true; break;
|
|
}
|
|
if (!found)
|
|
json_array_append_new(g_webui.rss_feeds, json_pack(
|
|
"{s:s,s:s,s:i,s:[]}", "name", name, "url", url,
|
|
"lastUpdate", 0, "articles"));
|
|
changed = true;
|
|
}
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
json_decref(req);
|
|
if (changed) {
|
|
rss_save();
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
g_webui.rss_wake = true;
|
|
pthread_cond_signal(&g_webui.rss_cond); /* re-poll the new feed now */
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
}
|
|
api_rss_list(fd);
|
|
}
|
|
|
|
/* GET /api/rss/rules → array of rules. */
|
|
static void api_rss_rules_list(int fd) {
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
json_t *reply = json_deep_copy(g_webui.rss_rules);
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
http_json(fd, 200, reply ? reply : json_array());
|
|
json_decref(reply);
|
|
}
|
|
|
|
/* POST /api/rss/rules upserts a rule; POST /api/rss/rules/delete removes one. */
|
|
static void api_rss_rule(int fd, const char *body, size_t len, bool remove) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *name = json_string_value(json_object_get(req, "name"));
|
|
bool changed = false;
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
if (name && *name) {
|
|
size_t i; json_t *r; int at = -1;
|
|
json_array_foreach(g_webui.rss_rules, i, r)
|
|
if (strcmp(json_string_or(r, "name", ""), name) == 0) { at = (int)i; break; }
|
|
if (remove) {
|
|
if (at >= 0) { json_array_remove(g_webui.rss_rules, (size_t)at); changed = true; }
|
|
} else {
|
|
json_t *rule = json_pack("{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:O,s:i}",
|
|
"name", name,
|
|
"enabled", json_boolean_value(json_object_get(req, "enabled")),
|
|
"useRegex", json_boolean_value(json_object_get(req, "useRegex")),
|
|
"addPaused", json_boolean_value(json_object_get(req, "addPaused")),
|
|
"mustContain", json_string_or(req, "mustContain", ""),
|
|
"mustNotContain", json_string_or(req, "mustNotContain", ""),
|
|
"assignedCategory", json_string_or(req, "assignedCategory", ""),
|
|
"savePath", json_string_or(req, "savePath", ""),
|
|
"affectedFeeds", json_is_array(json_object_get(req, "affectedFeeds"))
|
|
? json_object_get(req, "affectedFeeds") : json_array(),
|
|
"lastMatch", 0);
|
|
if (rule) {
|
|
if (at >= 0) json_array_set_new(g_webui.rss_rules, (size_t)at, rule);
|
|
else json_array_append_new(g_webui.rss_rules, rule);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
json_decref(req);
|
|
if (changed) rss_save();
|
|
api_rss_rules_list(fd);
|
|
}
|
|
|
|
/* POST /api/rss/rules/run {name} — re-apply a rule to every article already in
|
|
* the feeds (not just newly-seen ones), downloading matches not yet grabbed.
|
|
* Used after editing a rule. Runs regardless of the rule's enabled flag. */
|
|
static void api_rss_rule_run(int fd, const char *body, size_t len) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *name = json_string_value(json_object_get(req, "name"));
|
|
char cat[128] = {0}, path[1024] = {0}; bool paused = false;
|
|
json_t *todo = json_array(); /* {key,magnet,torrentUrl} to grab */
|
|
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
json_t *rule = NULL; size_t i; json_t *r;
|
|
if (name) json_array_foreach(g_webui.rss_rules, i, r)
|
|
if (strcmp(json_string_or(r, "name", ""), name) == 0) { rule = r; break; }
|
|
if (rule) {
|
|
snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", ""));
|
|
snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", ""));
|
|
paused = json_boolean_value(json_object_get(rule, "addPaused"));
|
|
/* match regardless of the enabled flag (explicit manual run) */
|
|
json_t *probe = json_deep_copy(rule);
|
|
json_object_set_new(probe, "enabled", json_true());
|
|
size_t fi; json_t *feed;
|
|
json_array_foreach(g_webui.rss_feeds, fi, feed) {
|
|
const char *fname = json_string_or(feed, "name", "");
|
|
json_t *articles = json_object_get(feed, "articles");
|
|
size_t ai; json_t *a;
|
|
json_array_foreach(articles, ai, a) {
|
|
if (json_boolean_value(json_object_get(a, "grabbed"))) continue;
|
|
const char *mag = json_string_or(a, "magnet", "");
|
|
const char *url = json_string_or(a, "torrentUrl", "");
|
|
if (!*mag && !*url) continue;
|
|
if (rule_matches(probe, fname, json_string_or(a, "title", "")))
|
|
json_array_append_new(todo, json_pack("{s:s,s:s,s:s,s:s}",
|
|
"key", json_string_or(a, "key", ""),
|
|
"title", json_string_or(a, "title", ""),
|
|
"magnet", mag, "torrentUrl", url));
|
|
}
|
|
}
|
|
json_decref(probe);
|
|
if (json_array_size(todo))
|
|
json_object_set_new(rule, "lastMatch", json_integer((json_int_t)time(NULL)));
|
|
}
|
|
bool found = rule != NULL;
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
json_decref(req);
|
|
|
|
int grabbed = 0;
|
|
size_t j; json_t *t;
|
|
json_array_foreach(todo, j, t)
|
|
if (rss_grab_article(json_string_or(t, "key", ""), json_string_or(t, "title", ""),
|
|
json_string_or(t, "magnet", ""),
|
|
json_string_or(t, "torrentUrl", ""), cat, path, paused))
|
|
grabbed++;
|
|
size_t matched = json_array_size(todo);
|
|
json_decref(todo);
|
|
if (grabbed > 0) rss_save();
|
|
|
|
if (!found) { http_text(fd, 404, "Not Found", "no such rule"); return; }
|
|
json_t *reply = json_pack("{s:b,s:i,s:i}", "ok", 1,
|
|
"matched", (int)matched, "grabbed", grabbed);
|
|
http_json(fd, 200, reply);
|
|
json_decref(reply);
|
|
}
|
|
|
|
/* POST /api/rss/download {magnet|torrentUrl, category, savePath, paused}
|
|
* Manually grab a torrent from a feed article or search result. Reuses the
|
|
* same add path as the auto-downloader (handles magnets and .torrent URLs). */
|
|
static void api_rss_download(int fd, const char *body, size_t len) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *magnet = json_string_or(req, "magnet", "");
|
|
const char *url = json_string_or(req, "torrentUrl", "");
|
|
const char *cat = json_string_or(req, "category", "");
|
|
const char *path = json_string_or(req, "savePath", "");
|
|
const char *key = json_string_or(req, "key", "");
|
|
const char *title = json_string_or(req, "title", "");
|
|
bool paused = json_boolean_value(json_object_get(req, "paused"));
|
|
bool ok = rss_grab_article(key, title, magnet, url, cat, path, paused);
|
|
if (ok && *key) rss_save(); /* persist the grabbed flag */
|
|
json_decref(req);
|
|
if (ok) {
|
|
json_t *reply = json_pack("{s:b}", "ok", 1);
|
|
http_json(fd, 200, reply);
|
|
json_decref(reply);
|
|
} else {
|
|
http_text(fd, 502, "Bad Gateway", "could not add torrent from this item");
|
|
}
|
|
}
|
|
|
|
/* POST /api/rss/refresh {name?} — re-poll a feed now (or all feeds), running
|
|
* the network fetch synchronously so the response reflects fresh articles. */
|
|
static void api_rss_refresh(int fd, const char *body, size_t len) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *name = json_string_value(json_object_get(req, "name"));
|
|
/* find matching index(es) under the lock, then poll outside it */
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
size_t n = json_array_size(g_webui.rss_feeds);
|
|
int target = -1;
|
|
if (name && *name) {
|
|
size_t i; json_t *fd_j;
|
|
json_array_foreach(g_webui.rss_feeds, i, fd_j)
|
|
if (strcmp(json_string_or(fd_j, "name", ""), name) == 0) { target = (int)i; break; }
|
|
}
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
json_decref(req);
|
|
if (target >= 0) {
|
|
rss_poll_feed_by_index((size_t)target);
|
|
} else if (!name || !*name) {
|
|
for (size_t i = 0; i < n && !atomic_load(&g_webui.stopping); i++)
|
|
rss_poll_feed_by_index(i);
|
|
}
|
|
api_rss_list(fd);
|
|
}
|
|
|
|
/* POST /api/indexers upserts a Torznab indexer; .../delete removes one. */
|
|
static void api_indexer(int fd, const char *body, size_t len, bool remove) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *name = json_string_value(json_object_get(req, "name"));
|
|
bool changed = false;
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
if (name && *name) {
|
|
size_t i; json_t *ix; int at = -1;
|
|
json_array_foreach(g_webui.indexers, i, ix)
|
|
if (strcmp(json_string_or(ix, "name", ""), name) == 0) { at = (int)i; break; }
|
|
if (remove) {
|
|
if (at >= 0) { json_array_remove(g_webui.indexers, (size_t)at); changed = true; }
|
|
} else {
|
|
json_t *e = json_pack("{s:s,s:s,s:s,s:b}", "name", name,
|
|
"url", json_string_or(req, "url", ""),
|
|
"apikey", json_string_or(req, "apikey", ""),
|
|
"enabled", json_object_get(req, "enabled")
|
|
? json_boolean_value(json_object_get(req, "enabled")) : true);
|
|
if (e) {
|
|
if (at >= 0) json_array_set_new(g_webui.indexers, (size_t)at, e);
|
|
else json_array_append_new(g_webui.indexers, e);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
json_decref(req);
|
|
if (changed) rss_save();
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
json_t *reply = json_deep_copy(g_webui.indexers);
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
http_json(fd, 200, reply ? reply : json_array());
|
|
json_decref(reply);
|
|
}
|
|
|
|
/* ----------------------------- Torznab search ----------------------------- */
|
|
|
|
/* Parse Torznab/newznab XML results into the UI's row schema. */
|
|
static json_t *torznab_parse(const char *xml, size_t len, const char *engine) {
|
|
json_t *rows = json_array();
|
|
const char *p = xml, *end = xml + len;
|
|
for (;;) {
|
|
const char *open = memmem(p, (size_t)(end - p), "<item", 5);
|
|
if (!open) break;
|
|
const char *close = memmem(open, (size_t)(end - open), "</item>", 7);
|
|
if (!close) break;
|
|
size_t ilen = (size_t)(close - open);
|
|
char title[512] = {0}, magnet[2048] = {0}, enclosure[1024] = {0};
|
|
char pub[128] = {0}, lenstr[64] = {0};
|
|
xml_tag_text(open, ilen, "title", title, sizeof title);
|
|
xml_tag_text(open, ilen, "pubDate", pub, sizeof pub);
|
|
find_magnet(open, ilen, magnet, sizeof magnet);
|
|
xml_attr(open, ilen, "enclosure", "url", enclosure, sizeof enclosure);
|
|
if (!xml_attr(open, ilen, "enclosure", "length", lenstr, sizeof lenstr))
|
|
xml_tag_text(open, ilen, "size", lenstr, sizeof lenstr);
|
|
/* Torznab seeders/peers live in <torznab:attr name="seeders" value=.. /> */
|
|
long seeds = 0, leech = 0;
|
|
const char *ap = open;
|
|
while (ap < close) {
|
|
const char *attr = memmem(ap, (size_t)(close - ap), "name=\"", 6);
|
|
if (!attr) break;
|
|
char an[32] = {0}, av[32] = {0};
|
|
const char *aq = attr + 6;
|
|
const char *aqe = memchr(aq, '"', (size_t)(close - aq));
|
|
if (!aqe) break;
|
|
snprintf(an, sizeof an, "%.*s", (int)(aqe - aq) < 31 ? (int)(aqe - aq) : 31, aq);
|
|
const char *valk = memmem(aqe, (size_t)(close - aqe), "value=\"", 7);
|
|
const char *gt = (const char *)memchr(aqe, '>', (size_t)(close - aqe));
|
|
if (valk && (!gt || valk < gt)) {
|
|
const char *vs = valk + 7;
|
|
const char *ve = memchr(vs, '"', (size_t)(close - vs));
|
|
if (ve) snprintf(av, sizeof av, "%.*s", (int)(ve - vs) < 31 ? (int)(ve - vs) : 31, vs);
|
|
}
|
|
if (strcmp(an, "seeders") == 0) seeds = strtol(av, NULL, 10);
|
|
else if (strcmp(an, "peers") == 0 || strcmp(an, "leechers") == 0) leech = strtol(av, NULL, 10);
|
|
ap = aqe + 1;
|
|
}
|
|
if (title[0]) {
|
|
json_array_append_new(rows, json_pack(
|
|
"{s:s,s:I,s:i,s:i,s:s,s:s,s:s,s:s}",
|
|
"name", title, "size", (json_int_t)strtoll(lenstr, NULL, 10),
|
|
"seeds", (int)seeds, "leeches", (int)leech,
|
|
"engine", engine, "pubDate", pub,
|
|
"magnet", magnet, "torrentUrl", enclosure));
|
|
}
|
|
p = close + 7;
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/* GET /api/search?q=… queries every enabled Torznab indexer and merges rows. */
|
|
static void api_search(int fd, const char *query) {
|
|
json_t *results = json_array();
|
|
/* snapshot the indexer list under the lock */
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
json_t *indexers = json_deep_copy(g_webui.indexers);
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
|
|
size_t i; json_t *ix;
|
|
json_array_foreach(indexers, i, ix) {
|
|
if (!json_boolean_value(json_object_get(ix, "enabled"))) continue;
|
|
const char *base = json_string_or(ix, "url", "");
|
|
const char *key = json_string_or(ix, "apikey", "");
|
|
const char *engine = json_string_or(ix, "name", "indexer");
|
|
if (!*base) continue;
|
|
char url[2048];
|
|
snprintf(url, sizeof url, "%s%st=search&q=%s%s%s",
|
|
base, strchr(base, '?') ? "&" : "?",
|
|
query ? query : "",
|
|
*key ? "&apikey=" : "", key);
|
|
naut_http_response r;
|
|
if (naut_http_get(url, &r) == NAUT_OK && r.status / 100 == 2 && r.body) {
|
|
json_t *rows = torznab_parse(r.body, r.body_len, engine);
|
|
size_t j; json_t *row;
|
|
json_array_foreach(rows, j, row) json_array_append(results, row);
|
|
json_decref(rows);
|
|
}
|
|
naut_http_response_free(&r);
|
|
}
|
|
json_decref(indexers);
|
|
http_json(fd, 200, results);
|
|
json_decref(results);
|
|
}
|
|
|
|
/* POST /api/categories and /api/categories/delete */
|
|
static void api_categories(int fd, const char *body, size_t len, bool remove) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *name = json_string_value(json_object_get(req, "name"));
|
|
if (name && *name) {
|
|
if (remove) { store_remove_category(name); webui_sync_all_labels(); }
|
|
else store_add_category(name,
|
|
json_string_value(json_object_get(req, "savePath")));
|
|
webui_sync_taxonomy(); /* persist the category list via the daemon */
|
|
}
|
|
json_decref(req);
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
json_t *reply = json_deep_copy(g_webui.categories);
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
http_json(fd, 200, reply);
|
|
json_decref(reply);
|
|
}
|
|
|
|
/* POST /api/categories/edit — rename a category and/or change its save path,
|
|
* reassigning every torrent that referenced the old name. The empty-named
|
|
* "Uncategorized" pseudo-category can have its savePath set but not renamed. */
|
|
static void api_category_edit(int fd, const char *body, size_t len) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *name = json_string_value(json_object_get(req, "name"));
|
|
const char *new_name = json_string_value(json_object_get(req, "newName"));
|
|
const char *save_path = json_string_value(json_object_get(req, "savePath"));
|
|
if (!name) name = "";
|
|
if (!save_path) save_path = "";
|
|
bool rename = new_name && *new_name && *name && strcmp(new_name, name) != 0;
|
|
const char *target = rename ? new_name : name;
|
|
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
int idx = find_category(name);
|
|
if (idx >= 0) {
|
|
json_t *cat = json_array_get(g_webui.categories, (size_t)idx);
|
|
json_object_set_new(cat, "name", json_string(target));
|
|
json_object_set_new(cat, "savePath", json_string(save_path));
|
|
} else if (*target || *save_path) {
|
|
/* The category didn't exist yet (e.g. setting Uncategorized's path, or
|
|
* editing a name that was only implied by assignments). An empty name
|
|
* is allowed here: it holds Uncategorized's default save path. */
|
|
json_array_append_new(g_webui.categories, json_pack(
|
|
"{s:s,s:s}", "name", target, "savePath", save_path));
|
|
}
|
|
if (rename) {
|
|
const char *key;
|
|
json_t *entry;
|
|
json_object_foreach(g_webui.assignments, key, entry)
|
|
if (strcmp(json_string_or(entry, "category", ""), name) == 0)
|
|
json_object_set_new(entry, "category", json_string(target));
|
|
}
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
|
|
if (rename) webui_sync_all_labels();
|
|
webui_sync_taxonomy();
|
|
|
|
json_decref(req);
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
json_t *reply = json_deep_copy(g_webui.categories);
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
http_json(fd, 200, reply);
|
|
json_decref(reply);
|
|
}
|
|
|
|
/* POST /api/tags and /api/tags/delete */
|
|
static void api_tags(int fd, const char *body, size_t len, bool remove) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *name = json_string_value(json_object_get(req, "name"));
|
|
if (name && *name) {
|
|
if (remove) { store_remove_tag(name); webui_sync_all_labels(); }
|
|
else store_add_tag(name);
|
|
webui_sync_taxonomy(); /* persist the tag list via the daemon */
|
|
}
|
|
json_decref(req);
|
|
pthread_mutex_lock(&g_webui.meta_lock);
|
|
json_t *reply = json_deep_copy(g_webui.tags);
|
|
pthread_mutex_unlock(&g_webui.meta_lock);
|
|
http_json(fd, 200, reply);
|
|
json_decref(reply);
|
|
}
|
|
|
|
static void api_stream(int fd) {
|
|
const char *head =
|
|
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n"
|
|
"Cache-Control: no-cache\r\nConnection: keep-alive\r\n\r\n"
|
|
"retry: 2000\n\n";
|
|
if (!send_all_fd(fd, head, strlen(head))) return;
|
|
uint64_t seen = 0;
|
|
while (!atomic_load(&g_webui.stopping)) {
|
|
char *payload = NULL;
|
|
pthread_mutex_lock(&g_webui.snap_lock);
|
|
while (!atomic_load(&g_webui.stopping) && g_webui.snap_seq == seen) {
|
|
struct timespec ts;
|
|
clock_gettime(CLOCK_REALTIME, &ts);
|
|
ts.tv_nsec += 250 * 1000 * 1000;
|
|
if (ts.tv_nsec >= 1000000000) { ts.tv_sec++; ts.tv_nsec -= 1000000000; }
|
|
pthread_cond_timedwait(&g_webui.snap_cond, &g_webui.snap_lock, &ts);
|
|
}
|
|
if (!atomic_load(&g_webui.stopping) && g_webui.snapshot_str) {
|
|
payload = strdup(g_webui.snapshot_str);
|
|
seen = g_webui.snap_seq;
|
|
}
|
|
pthread_mutex_unlock(&g_webui.snap_lock);
|
|
if (!payload) break;
|
|
bool ok = send_all_fd(fd, "event: snapshot\ndata: ", 22) &&
|
|
send_all_fd(fd, payload, strlen(payload)) &&
|
|
send_all_fd(fd, "\n\n", 2);
|
|
free(payload);
|
|
if (!ok) break;
|
|
}
|
|
}
|
|
|
|
static void serve_cached_snapshot(int fd) {
|
|
pthread_mutex_lock(&g_webui.snap_lock);
|
|
char *copy = g_webui.snapshot_str ? strdup(g_webui.snapshot_str) : NULL;
|
|
pthread_mutex_unlock(&g_webui.snap_lock);
|
|
http_json_str(fd, copy, "{\"server\":{},\"torrents\":[]}");
|
|
free(copy);
|
|
}
|
|
|
|
static void serve_cached_torrents(int fd) {
|
|
pthread_mutex_lock(&g_webui.snap_lock);
|
|
char *copy = g_webui.torrents_str ? strdup(g_webui.torrents_str) : NULL;
|
|
pthread_mutex_unlock(&g_webui.snap_lock);
|
|
http_json_str(fd, copy, "[]");
|
|
free(copy);
|
|
}
|
|
|
|
/* ============================ account management ========================== *
|
|
* Admin-only user CRUD plus a self-service password change. The web layer owns
|
|
* everything via webui_store; the daemon is not involved. */
|
|
|
|
static bool valid_username(const char *u) {
|
|
if (!u || !*u || strlen(u) >= 64) return false;
|
|
for (const char *p = u; *p; p++)
|
|
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') ||
|
|
(*p >= '0' && *p <= '9') || *p == '_' || *p == '-' || *p == '.'))
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
/* GET /api/users → [{username, role, createdAt}] (admin only). */
|
|
static void api_users_list(int fd) {
|
|
json_t *users = json_array();
|
|
if (g_webui.store) webui_store_list_users(g_webui.store, users);
|
|
http_json(fd, 200, users);
|
|
json_decref(users);
|
|
}
|
|
|
|
/* POST /api/users {username, password, role} (admin only). */
|
|
static void api_user_create(int fd, const char *body, size_t len) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *user = json_string_value(json_object_get(req, "username"));
|
|
const char *pass = json_string_value(json_object_get(req, "password"));
|
|
const char *role = json_string_value(json_object_get(req, "role"));
|
|
if (!valid_username(user) || !pass || !*pass) {
|
|
json_decref(req);
|
|
http_text(fd, 400, "Bad Request",
|
|
"username (letters/digits/._-) and password are required");
|
|
return;
|
|
}
|
|
bool ok = g_webui.store &&
|
|
webui_store_create_user(g_webui.store, user, pass,
|
|
role && *role ? role : "user");
|
|
json_decref(req);
|
|
if (!ok) { http_text(fd, 409, "Conflict", "user already exists"); return; }
|
|
api_users_list(fd);
|
|
}
|
|
|
|
/* POST /api/users/delete {username} (admin only). Refuses to remove the last
|
|
* admin so the instance can't lock everyone out. */
|
|
static void api_user_delete(int fd, const char *body, size_t len,
|
|
const char *actor) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *uname = json_string_value(json_object_get(req, "username"));
|
|
if (!uname || !*uname) { json_decref(req); http_text(fd, 400, "Bad Request", "username required"); return; }
|
|
char user[64];
|
|
snprintf(user, sizeof user, "%s", uname); /* own it before decref */
|
|
char role[16] = {0};
|
|
/* Look up the target's role to guard the last-admin rule. */
|
|
json_t *list = json_array();
|
|
if (g_webui.store) webui_store_list_users(g_webui.store, list);
|
|
size_t i; json_t *u;
|
|
json_array_foreach(list, i, u)
|
|
if (strcasecmp(json_string_or(u, "username", ""), user) == 0)
|
|
snprintf(role, sizeof role, "%s", json_string_or(u, "role", ""));
|
|
json_decref(list);
|
|
if (strcmp(role, "admin") == 0 && webui_store_admin_count(g_webui.store) <= 1) {
|
|
json_decref(req);
|
|
http_text(fd, 409, "Conflict", "cannot delete the last admin");
|
|
return;
|
|
}
|
|
bool ok = g_webui.store && webui_store_delete_user(g_webui.store, user);
|
|
json_decref(req);
|
|
if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; }
|
|
drop_user_sessions(user);
|
|
(void)actor;
|
|
api_users_list(fd);
|
|
}
|
|
|
|
/* POST /api/users/password {username, password} — admin reset. */
|
|
static void api_user_set_password(int fd, const char *body, size_t len) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *uname = json_string_value(json_object_get(req, "username"));
|
|
const char *pass = json_string_value(json_object_get(req, "password"));
|
|
if (!uname || !*uname || !pass || !*pass) {
|
|
json_decref(req);
|
|
http_text(fd, 400, "Bad Request", "username and password required");
|
|
return;
|
|
}
|
|
char user[64];
|
|
snprintf(user, sizeof user, "%s", uname);
|
|
bool ok = g_webui.store && webui_store_set_password(g_webui.store, user, pass);
|
|
json_decref(req);
|
|
if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; }
|
|
drop_user_sessions(user); /* force re-login with the new password */
|
|
json_t *reply = json_pack("{s:b}", "ok", 1);
|
|
http_json(fd, 200, reply);
|
|
json_decref(reply);
|
|
}
|
|
|
|
/* POST /api/users/role {username, role} — admin; keeps at least one admin. */
|
|
static void api_user_set_role(int fd, const char *body, size_t len) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *user = json_string_value(json_object_get(req, "username"));
|
|
const char *role = json_string_value(json_object_get(req, "role"));
|
|
if (!user || !*user || (strcmp(role ? role : "", "admin") && strcmp(role ? role : "", "user"))) {
|
|
json_decref(req);
|
|
http_text(fd, 400, "Bad Request", "username and role (admin|user) required");
|
|
return;
|
|
}
|
|
if (strcmp(role, "user") == 0 && webui_store_admin_count(g_webui.store) <= 1) {
|
|
/* Only block if the target is currently the sole admin. */
|
|
char cur[16] = {0};
|
|
json_t *list = json_array();
|
|
if (g_webui.store) webui_store_list_users(g_webui.store, list);
|
|
size_t i; json_t *u;
|
|
json_array_foreach(list, i, u)
|
|
if (strcasecmp(json_string_or(u, "username", ""), user) == 0)
|
|
snprintf(cur, sizeof cur, "%s", json_string_or(u, "role", ""));
|
|
json_decref(list);
|
|
if (strcmp(cur, "admin") == 0) {
|
|
json_decref(req);
|
|
http_text(fd, 409, "Conflict", "cannot demote the last admin");
|
|
return;
|
|
}
|
|
}
|
|
bool ok = g_webui.store && webui_store_set_role(g_webui.store, user, role);
|
|
json_decref(req);
|
|
if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; }
|
|
api_users_list(fd);
|
|
}
|
|
|
|
/* POST /api/account/password {oldPassword, newPassword} — change own password. */
|
|
static void api_account_password(int fd, const char *body, size_t len,
|
|
const char *actor) {
|
|
json_t *req = read_body_json(body, len);
|
|
const char *oldp = json_string_value(json_object_get(req, "oldPassword"));
|
|
const char *newp = json_string_value(json_object_get(req, "newPassword"));
|
|
if (!oldp || !newp || !*newp) {
|
|
json_decref(req);
|
|
http_text(fd, 400, "Bad Request", "oldPassword and newPassword required");
|
|
return;
|
|
}
|
|
char role[16] = {0};
|
|
if (!g_webui.store || !webui_store_verify(g_webui.store, actor, oldp, role, sizeof role)) {
|
|
json_decref(req);
|
|
http_text(fd, 403, "Forbidden", "current password is incorrect");
|
|
return;
|
|
}
|
|
bool ok = webui_store_set_password(g_webui.store, actor, newp);
|
|
json_decref(req);
|
|
if (!ok) { http_text(fd, 500, "Internal Server Error", "could not update password"); return; }
|
|
json_t *reply = json_pack("{s:b}", "ok", 1);
|
|
http_json(fd, 200, reply);
|
|
json_decref(reply);
|
|
}
|
|
|
|
static void handle_api(int fd, const char *method, char *path,
|
|
const char *headers, const char *headers_end,
|
|
const char *body, size_t body_len) {
|
|
/* Snapshot the query string before strip_query() truncates it. */
|
|
char query_str[1024] = {0};
|
|
const char *qmark = strchr(path, '?');
|
|
if (qmark) snprintf(query_str, sizeof query_str, "%s", qmark + 1);
|
|
strip_query(path);
|
|
char cur_user[64] = {0}, cur_role[16] = {0};
|
|
if (strcmp(path, "/api/auth/status") == 0 && strcmp(method, "GET") == 0) {
|
|
bool authed = current_identity(headers, headers_end, cur_user,
|
|
sizeof cur_user, cur_role, sizeof cur_role);
|
|
json_t *json = json_pack("{s:b,s:s,s:s,s:b}",
|
|
"authenticated", authed,
|
|
"user", authed ? cur_user : "",
|
|
"role", authed ? cur_role : "",
|
|
"generatedPassword", g_webui.generated_password);
|
|
http_json(fd, 200, json);
|
|
json_decref(json);
|
|
} else if (strcmp(path, "/api/login") == 0 && strcmp(method, "POST") == 0) {
|
|
json_t *req = read_body_json(body, body_len);
|
|
const char *user = json_string_value(json_object_get(req, "username"));
|
|
const char *password =
|
|
json_string_value(json_object_get(req, "password"));
|
|
char role[16] = {0};
|
|
bool ok = g_webui.store && user && password &&
|
|
webui_store_verify(g_webui.store, user, password, role, sizeof role);
|
|
if (!ok) {
|
|
json_t *json = json_pack("{s:b,s:s}", "ok", 0,
|
|
"error", "invalid credentials");
|
|
http_json(fd, 401, json);
|
|
json_decref(json);
|
|
json_decref(req);
|
|
return;
|
|
}
|
|
char token[96];
|
|
if (!create_session(user, role, token, sizeof token)) {
|
|
json_decref(req);
|
|
http_text(fd, 500, "Internal Server Error", "session failed");
|
|
return;
|
|
}
|
|
char cookie[256];
|
|
snprintf(cookie, sizeof cookie,
|
|
"Set-Cookie: %s=%s; Path=/; HttpOnly; SameSite=Lax; "
|
|
"Max-Age=%d\r\n",
|
|
SESSION_COOKIE, token, SESSION_TTL_SECONDS);
|
|
json_t *json = json_pack("{s:b,s:s,s:s}", "ok", 1,
|
|
"user", user, "role", role);
|
|
http_json_extra(fd, 200, json, cookie);
|
|
json_decref(json);
|
|
json_decref(req);
|
|
} else if (strcmp(path, "/api/logout") == 0 && strcmp(method, "POST") == 0) {
|
|
clear_session(headers, headers_end);
|
|
json_t *json = json_pack("{s:b}", "ok", 1);
|
|
http_json_extra(fd, 200, json,
|
|
"Set-Cookie: naut_session=; Path=/; HttpOnly; "
|
|
"SameSite=Lax; Max-Age=0\r\n");
|
|
json_decref(json);
|
|
} else if (!current_identity(headers, headers_end, cur_user, sizeof cur_user,
|
|
cur_role, sizeof cur_role)) {
|
|
json_t *json = json_pack("{s:s}", "error",
|
|
"authentication required");
|
|
http_json(fd, 401, json);
|
|
json_decref(json);
|
|
} else if (strcmp(path, "/api/plugins") == 0 && strcmp(method, "GET") == 0) {
|
|
api_plugins(fd);
|
|
} else if (strcmp(path, "/api/stream") == 0 && strcmp(method, "GET") == 0) {
|
|
api_stream(fd);
|
|
} else if (strcmp(path, "/api/snapshot") == 0 && strcmp(method, "GET") == 0) {
|
|
serve_cached_snapshot(fd);
|
|
} else if (strcmp(path, "/api/meta") == 0 && strcmp(method, "GET") == 0) {
|
|
api_meta(fd);
|
|
} else if (strcmp(path, "/api/preferences") == 0) {
|
|
api_preferences(fd, method, body, body_len);
|
|
} else if (strcmp(path, "/api/altspeed") == 0 && strcmp(method, "POST") == 0) {
|
|
api_altspeed(fd);
|
|
} else if (strcmp(path, "/api/script/settings") == 0) {
|
|
api_script_settings(fd, method, body, body_len);
|
|
} else if (strcmp(path, "/api/script") == 0) {
|
|
api_script(fd, method, body, body_len);
|
|
} else if (strcmp(path, "/api/categories") == 0 &&
|
|
strcmp(method, "POST") == 0) {
|
|
api_categories(fd, body, body_len, false);
|
|
} else if (strcmp(path, "/api/categories/delete") == 0 &&
|
|
strcmp(method, "POST") == 0) {
|
|
api_categories(fd, body, body_len, true);
|
|
} else if (strcmp(path, "/api/categories/edit") == 0 &&
|
|
strcmp(method, "POST") == 0) {
|
|
api_category_edit(fd, body, body_len);
|
|
} else if (strcmp(path, "/api/tags") == 0 &&
|
|
strcmp(method, "POST") == 0) {
|
|
api_tags(fd, body, body_len, false);
|
|
} else if (strcmp(path, "/api/tags/delete") == 0 &&
|
|
strcmp(method, "POST") == 0) {
|
|
api_tags(fd, body, body_len, true);
|
|
} else if (strcmp(path, "/api/torrents") == 0 && strcmp(method, "GET") == 0) {
|
|
serve_cached_torrents(fd);
|
|
} else if (path_after(path, "/api/torrents/") && strcmp(method, "GET") == 0) {
|
|
api_torrent_detail(fd, path_after(path, "/api/torrents/"));
|
|
} else if (strcmp(path, "/api/add") == 0 && strcmp(method, "POST") == 0) {
|
|
api_add(fd, body, body_len);
|
|
} else if (strcmp(path, "/api/delete") == 0 && strcmp(method, "POST") == 0) {
|
|
api_delete(fd, body, body_len);
|
|
} else if (strcmp(path, "/api/action") == 0 && strcmp(method, "POST") == 0) {
|
|
api_action(fd, body, body_len);
|
|
} else if (strcmp(path, "/api/account/password") == 0 && strcmp(method, "POST") == 0) {
|
|
api_account_password(fd, body, body_len, cur_user);
|
|
} else if (strncmp(path, "/api/users", 10) == 0) {
|
|
/* All user-management endpoints are admin-only. */
|
|
if (strcmp(cur_role, "admin") != 0) {
|
|
http_text(fd, 403, "Forbidden", "admin privileges required");
|
|
} else if (strcmp(path, "/api/users") == 0 && strcmp(method, "GET") == 0) {
|
|
api_users_list(fd);
|
|
} else if (strcmp(path, "/api/users") == 0 && strcmp(method, "POST") == 0) {
|
|
api_user_create(fd, body, body_len);
|
|
} else if (strcmp(path, "/api/users/delete") == 0 && strcmp(method, "POST") == 0) {
|
|
api_user_delete(fd, body, body_len, cur_user);
|
|
} else if (strcmp(path, "/api/users/password") == 0 && strcmp(method, "POST") == 0) {
|
|
api_user_set_password(fd, body, body_len);
|
|
} else if (strcmp(path, "/api/users/role") == 0 && strcmp(method, "POST") == 0) {
|
|
api_user_set_role(fd, body, body_len);
|
|
} else {
|
|
http_text(fd, 404, "Not Found", "not found");
|
|
}
|
|
} else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "GET") == 0) {
|
|
api_rss_list(fd);
|
|
} else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "POST") == 0) {
|
|
api_rss_feed(fd, body, body_len, false);
|
|
} else if (strcmp(path, "/api/rss/delete") == 0 && strcmp(method, "POST") == 0) {
|
|
api_rss_feed(fd, body, body_len, true);
|
|
} else if (strcmp(path, "/api/rss/rules") == 0 && strcmp(method, "GET") == 0) {
|
|
api_rss_rules_list(fd);
|
|
} else if (strcmp(path, "/api/rss/rules") == 0 && strcmp(method, "POST") == 0) {
|
|
api_rss_rule(fd, body, body_len, false);
|
|
} else if (strcmp(path, "/api/rss/rules/delete") == 0 && strcmp(method, "POST") == 0) {
|
|
api_rss_rule(fd, body, body_len, true);
|
|
} else if (strcmp(path, "/api/rss/rules/run") == 0 && strcmp(method, "POST") == 0) {
|
|
api_rss_rule_run(fd, body, body_len);
|
|
} else if (strcmp(path, "/api/rss/refresh") == 0 && strcmp(method, "POST") == 0) {
|
|
api_rss_refresh(fd, body, body_len);
|
|
} else if (strcmp(path, "/api/rss/download") == 0 && strcmp(method, "POST") == 0) {
|
|
api_rss_download(fd, body, body_len);
|
|
} else if (strcmp(path, "/api/indexers") == 0 && strcmp(method, "POST") == 0) {
|
|
api_indexer(fd, body, body_len, false);
|
|
} else if (strcmp(path, "/api/indexers/delete") == 0 && strcmp(method, "POST") == 0) {
|
|
api_indexer(fd, body, body_len, true);
|
|
} else if (strcmp(path, "/api/search") == 0 && strcmp(method, "GET") == 0) {
|
|
char q[512] = {0};
|
|
query_get(query_str, "q", q, sizeof q);
|
|
/* re-encode spaces for the upstream query (decode happened above) */
|
|
char enc[1024]; size_t eo = 0;
|
|
for (size_t i = 0; q[i] && eo + 4 < sizeof enc; i++) {
|
|
unsigned char c = (unsigned char)q[i];
|
|
if ((c >= 'a'&&c<='z')||(c>='A'&&c<='Z')||(c>='0'&&c<='9')||
|
|
c=='-'||c=='_'||c=='.'||c=='~') enc[eo++] = (char)c;
|
|
else eo += (size_t)snprintf(enc + eo, sizeof enc - eo, "%%%02X", c);
|
|
}
|
|
enc[eo] = 0;
|
|
api_search(fd, enc);
|
|
} else {
|
|
http_text(fd, 404, "Not Found", "not found");
|
|
}
|
|
}
|
|
|
|
static void handle_conn(int fd) {
|
|
char *request = malloc(READ_LIMIT + 1);
|
|
if (!request) {
|
|
http_text(fd, 500, "Internal Server Error", "oom");
|
|
return;
|
|
}
|
|
size_t len = 0;
|
|
char *hdrend = NULL;
|
|
while (len < READ_LIMIT) {
|
|
ssize_t n = recv(fd, request + len, READ_LIMIT - len, 0);
|
|
if (n < 0) {
|
|
if (errno == EINTR) continue;
|
|
free(request);
|
|
return;
|
|
}
|
|
if (n == 0) break;
|
|
len += (size_t)n;
|
|
request[len] = 0;
|
|
hdrend = memmem(request, len, "\r\n\r\n", 4);
|
|
if (hdrend) break;
|
|
}
|
|
if (!hdrend) {
|
|
free(request);
|
|
http_text(fd, 400, "Bad Request", "malformed request");
|
|
return;
|
|
}
|
|
char method[8] = {0};
|
|
char path[PATH_MAX] = {0};
|
|
if (sscanf(request, "%7s %4095s", method, path) != 2) {
|
|
free(request);
|
|
http_text(fd, 400, "Bad Request", "malformed request line");
|
|
return;
|
|
}
|
|
size_t header_len = (size_t)(hdrend - request) + 4;
|
|
size_t content_length = 0;
|
|
char *cl = strcasestr(request, "content-length:");
|
|
if (cl && cl < hdrend) content_length = strtoull(cl + 15, NULL, 10);
|
|
/* Reject bodies we can't buffer instead of silently truncating an upload
|
|
* into a corrupt torrent. */
|
|
if (content_length > READ_LIMIT - header_len) {
|
|
free(request);
|
|
http_text(fd, 413, "Payload Too Large",
|
|
"request body exceeds the 8 MiB limit");
|
|
return;
|
|
}
|
|
while (len - header_len < content_length && len < READ_LIMIT) {
|
|
ssize_t n = recv(fd, request + len, READ_LIMIT - len, 0);
|
|
if (n < 0) {
|
|
if (errno == EINTR) continue;
|
|
break;
|
|
}
|
|
if (n == 0) break;
|
|
len += (size_t)n;
|
|
request[len] = 0;
|
|
}
|
|
char *body = request + header_len;
|
|
size_t body_len = len > header_len ? len - header_len : 0;
|
|
if (strncmp(path, "/api/", 5) == 0)
|
|
handle_api(fd, method, path, request, hdrend, body, body_len);
|
|
else if (!serve_file(fd, path))
|
|
http_text(fd, 404, "Not Found", "not found");
|
|
free(request);
|
|
}
|
|
|
|
static void finish_connection(void) {
|
|
pthread_mutex_lock(&g_webui.conn_lock);
|
|
if (g_webui.active_connections > 0) g_webui.active_connections--;
|
|
pthread_cond_signal(&g_webui.conn_cond);
|
|
pthread_mutex_unlock(&g_webui.conn_lock);
|
|
}
|
|
|
|
static void *conn_thread(void *arg) {
|
|
conn_arg *conn = arg;
|
|
handle_conn(conn->fd);
|
|
close(conn->fd);
|
|
free(conn);
|
|
finish_connection();
|
|
return NULL;
|
|
}
|
|
|
|
static void *server_thread(void *arg) {
|
|
(void)arg;
|
|
for (;;) {
|
|
int fd = accept(g_webui.listener, NULL, NULL);
|
|
if (fd < 0) {
|
|
if (errno == EINTR) continue;
|
|
if (atomic_load(&g_webui.stopping)) break;
|
|
continue;
|
|
}
|
|
struct timeval timeout = { .tv_sec = 5, .tv_usec = 0 };
|
|
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof timeout);
|
|
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof timeout);
|
|
|
|
/* Bound concurrent connections so a client can't spawn unlimited
|
|
* threads (each SSE stream parks one). */
|
|
pthread_mutex_lock(&g_webui.conn_lock);
|
|
bool full = g_webui.active_connections >= MAX_CONNECTIONS;
|
|
if (!full) g_webui.active_connections++;
|
|
pthread_mutex_unlock(&g_webui.conn_lock);
|
|
if (full) {
|
|
http_text(fd, 503, "Service Unavailable", "too many connections");
|
|
close(fd);
|
|
continue;
|
|
}
|
|
|
|
conn_arg *conn = malloc(sizeof(*conn));
|
|
if (!conn) {
|
|
close(fd);
|
|
finish_connection();
|
|
continue;
|
|
}
|
|
conn->fd = fd;
|
|
pthread_t thread;
|
|
if (pthread_create(&thread, NULL, conn_thread, conn) != 0) {
|
|
close(fd);
|
|
free(conn);
|
|
finish_connection();
|
|
continue;
|
|
}
|
|
pthread_detach(thread);
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
static bool dir_exists(const char *path) {
|
|
struct stat st;
|
|
return path && stat(path, &st) == 0 && S_ISDIR(st.st_mode);
|
|
}
|
|
|
|
static const char *find_root(void) {
|
|
const char *env = getenv("NAUT_WEBUI_ROOT");
|
|
if (dir_exists(env)) return env;
|
|
static const char *candidates[] = {
|
|
"../torrent-ui/public",
|
|
"torrent-ui/public",
|
|
"./public",
|
|
"/usr/share/naut/torrent-ui/public",
|
|
};
|
|
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++)
|
|
if (dir_exists(candidates[i])) return candidates[i];
|
|
return NULL;
|
|
}
|
|
|
|
static int parse_port(void) {
|
|
const char *env = getenv("NAUT_WEBUI_PORT");
|
|
if (!env || !*env) return DEFAULT_PORT;
|
|
char *end = NULL;
|
|
long port = strtol(env, &end, 10);
|
|
return end && !*end && port > 0 && port <= 65535 ? (int)port : DEFAULT_PORT;
|
|
}
|
|
|
|
static naut_err start_server(void) {
|
|
const char *root = find_root();
|
|
if (!root) {
|
|
log_msg(0, "webui: could not find torrent-ui public assets; set NAUT_WEBUI_ROOT");
|
|
return NAUT_ERR_NOTFOUND;
|
|
}
|
|
snprintf(g_webui.root, sizeof g_webui.root, "%s", root);
|
|
const char *host = getenv("NAUT_WEBUI_HOST");
|
|
if (!host || !*host || strcmp(host, "localhost") == 0) host = DEFAULT_HOST;
|
|
snprintf(g_webui.host_name, sizeof g_webui.host_name, "%s", host);
|
|
g_webui.port = parse_port();
|
|
|
|
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
|
if (fd < 0) return NAUT_ERR_IO;
|
|
int one = 1;
|
|
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
|
struct sockaddr_in addr;
|
|
memset(&addr, 0, sizeof addr);
|
|
addr.sin_family = AF_INET;
|
|
addr.sin_port = htons((uint16_t)g_webui.port);
|
|
if (inet_pton(AF_INET, g_webui.host_name, &addr.sin_addr) != 1) {
|
|
close(fd);
|
|
return NAUT_ERR_INVAL;
|
|
}
|
|
if (bind(fd, (struct sockaddr *)&addr, sizeof addr) != 0 ||
|
|
listen(fd, 64) != 0) {
|
|
close(fd);
|
|
return NAUT_ERR_IO;
|
|
}
|
|
g_webui.listener = fd;
|
|
|
|
/* Prime the cache so the first request doesn't see an empty snapshot. */
|
|
publish_snapshot();
|
|
if (pthread_create(&g_webui.sampler, NULL, sampler_thread, NULL) != 0) {
|
|
close(fd);
|
|
g_webui.listener = -1;
|
|
return NAUT_ERR_NOMEM;
|
|
}
|
|
g_webui.sampler_started = true;
|
|
if (pthread_create(&g_webui.thread, NULL, server_thread, NULL) != 0) {
|
|
atomic_store(&g_webui.stopping, true);
|
|
pthread_join(g_webui.sampler, NULL);
|
|
g_webui.sampler_started = false;
|
|
close(fd);
|
|
g_webui.listener = -1;
|
|
return NAUT_ERR_NOMEM;
|
|
}
|
|
g_webui.thread_started = true;
|
|
char msg[PATH_MAX + 128];
|
|
snprintf(msg, sizeof msg, "webui: serving http://%s:%d from %s",
|
|
g_webui.host_name, g_webui.port, g_webui.root);
|
|
log_msg(2, msg);
|
|
if (strcmp(g_webui.host_name, DEFAULT_HOST) != 0)
|
|
log_msg(1, "webui: bound to a non-loopback address; credentials cross "
|
|
"the network in plaintext (set NAUT_AUTH_PASSWORD)");
|
|
if (!g_webui.store) {
|
|
log_msg(0, "webui: account store unavailable; logins will fail");
|
|
} else if (g_webui.generated_password && g_webui.auth_password[0]) {
|
|
/* First run: surface the generated admin credentials once. */
|
|
snprintf(msg, sizeof msg,
|
|
"webui: created initial admin '%s' with generated password %s",
|
|
g_webui.auth_user, g_webui.auth_password);
|
|
log_msg(1, msg);
|
|
}
|
|
return NAUT_OK;
|
|
}
|
|
|
|
naut_err naut_plugin_register(const naut_host_api *host) {
|
|
if (!host || host->abi_version != NAUT_PLUGIN_ABI_VERSION ||
|
|
host->struct_size < sizeof(*host) || !host->call_rpc)
|
|
return NAUT_ERR_INVAL;
|
|
naut_err error = NAUT_ERR_NOMEM;
|
|
memset(&g_webui, 0, sizeof g_webui);
|
|
g_webui.listener = -1;
|
|
g_webui.host = *host;
|
|
if (pthread_mutex_init(&g_webui.auth_lock, NULL) != 0)
|
|
return NAUT_ERR_NOMEM;
|
|
if (pthread_mutex_init(&g_webui.conn_lock, NULL) != 0)
|
|
goto fail_conn_lock;
|
|
if (pthread_cond_init(&g_webui.conn_cond, NULL) != 0)
|
|
goto fail_conn_cond;
|
|
if (pthread_mutex_init(&g_webui.snap_lock, NULL) != 0)
|
|
goto fail_snap_lock;
|
|
if (pthread_cond_init(&g_webui.snap_cond, NULL) != 0)
|
|
goto fail_snap_cond;
|
|
if (pthread_mutex_init(&g_webui.speed_lock, NULL) != 0)
|
|
goto fail_speed_lock;
|
|
if (pthread_mutex_init(&g_webui.meta_lock, NULL) != 0)
|
|
goto fail_meta_lock;
|
|
g_webui.categories = json_array();
|
|
g_webui.tags = json_array();
|
|
g_webui.assignments = json_object();
|
|
if (!g_webui.categories || !g_webui.tags || !g_webui.assignments)
|
|
goto fail_store;
|
|
pthread_mutex_init(&g_webui.rss_lock, NULL);
|
|
pthread_cond_init(&g_webui.rss_cond, NULL);
|
|
g_webui.rss_feeds = json_array();
|
|
g_webui.rss_rules = json_array();
|
|
g_webui.indexers = json_array();
|
|
init_auth();
|
|
error = g_webui.host.set_plugin_name(g_webui.host.host_context,
|
|
"webui");
|
|
if (error != NAUT_OK) goto fail_store;
|
|
error = start_server();
|
|
if (error != NAUT_OK) goto fail_store;
|
|
webui_load_taxonomy(); /* restore category + tag lists from the daemon */
|
|
/* RSS poller: loads feeds/rules from the blob store and polls in the bg. */
|
|
if (pthread_create(&g_webui.rss_thread, NULL, rss_thread_fn, NULL) == 0)
|
|
g_webui.rss_thread_started = true;
|
|
return NAUT_OK;
|
|
|
|
fail_store:
|
|
json_decref(g_webui.categories);
|
|
json_decref(g_webui.tags);
|
|
json_decref(g_webui.assignments);
|
|
pthread_mutex_destroy(&g_webui.meta_lock);
|
|
fail_meta_lock:
|
|
pthread_mutex_destroy(&g_webui.speed_lock);
|
|
fail_speed_lock:
|
|
pthread_cond_destroy(&g_webui.snap_cond);
|
|
fail_snap_cond:
|
|
pthread_mutex_destroy(&g_webui.snap_lock);
|
|
fail_snap_lock:
|
|
pthread_cond_destroy(&g_webui.conn_cond);
|
|
fail_conn_cond:
|
|
pthread_mutex_destroy(&g_webui.conn_lock);
|
|
fail_conn_lock:
|
|
pthread_mutex_destroy(&g_webui.auth_lock);
|
|
return error;
|
|
}
|
|
|
|
naut_err naut_plugin_shutdown(void) {
|
|
atomic_store(&g_webui.stopping, true);
|
|
/* wake any SSE streams parked on the snapshot condition */
|
|
pthread_mutex_lock(&g_webui.snap_lock);
|
|
pthread_cond_broadcast(&g_webui.snap_cond);
|
|
pthread_mutex_unlock(&g_webui.snap_lock);
|
|
if (g_webui.listener >= 0) {
|
|
shutdown(g_webui.listener, SHUT_RDWR);
|
|
close(g_webui.listener);
|
|
g_webui.listener = -1;
|
|
}
|
|
if (g_webui.thread_started)
|
|
pthread_join(g_webui.thread, NULL);
|
|
g_webui.thread_started = false;
|
|
if (g_webui.sampler_started)
|
|
pthread_join(g_webui.sampler, NULL);
|
|
g_webui.sampler_started = false;
|
|
if (g_webui.rss_thread_started) {
|
|
pthread_mutex_lock(&g_webui.rss_lock);
|
|
pthread_cond_signal(&g_webui.rss_cond); /* wake the poller to exit */
|
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
pthread_join(g_webui.rss_thread, NULL);
|
|
g_webui.rss_thread_started = false;
|
|
}
|
|
pthread_mutex_lock(&g_webui.conn_lock);
|
|
while (g_webui.active_connections > 0)
|
|
pthread_cond_wait(&g_webui.conn_cond, &g_webui.conn_lock);
|
|
pthread_mutex_unlock(&g_webui.conn_lock);
|
|
|
|
free(g_webui.snapshot_str);
|
|
free(g_webui.torrents_str);
|
|
g_webui.snapshot_str = NULL;
|
|
g_webui.torrents_str = NULL;
|
|
|
|
json_decref(g_webui.categories);
|
|
json_decref(g_webui.tags);
|
|
json_decref(g_webui.assignments);
|
|
g_webui.categories = NULL;
|
|
g_webui.tags = NULL;
|
|
g_webui.assignments = NULL;
|
|
|
|
json_decref(g_webui.rss_feeds);
|
|
json_decref(g_webui.rss_rules);
|
|
json_decref(g_webui.indexers);
|
|
g_webui.rss_feeds = NULL;
|
|
g_webui.rss_rules = NULL;
|
|
g_webui.indexers = NULL;
|
|
pthread_cond_destroy(&g_webui.rss_cond);
|
|
pthread_mutex_destroy(&g_webui.rss_lock);
|
|
|
|
pthread_mutex_destroy(&g_webui.meta_lock);
|
|
pthread_mutex_destroy(&g_webui.speed_lock);
|
|
pthread_cond_destroy(&g_webui.snap_cond);
|
|
pthread_mutex_destroy(&g_webui.snap_lock);
|
|
pthread_cond_destroy(&g_webui.conn_cond);
|
|
pthread_mutex_destroy(&g_webui.conn_lock);
|
|
pthread_mutex_destroy(&g_webui.auth_lock);
|
|
webui_store_close(g_webui.store);
|
|
g_webui.store = NULL;
|
|
return NAUT_OK;
|
|
}
|