#include "naut/naut_plugin.h" #include "naut/http_client.h" #include "webui_store.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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 "" -> {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": { "": "", ... } }. */ 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 ... 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, "", 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, "", 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 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), " 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 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), "", 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 */ 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; }