#include "naut/naut_plugin.h" #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]; 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]; char auth_password[64]; 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 */ 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; } /* Constant-time equality so credential checks don't leak length/content via * timing. Returns true when both NUL-terminated strings match exactly. */ static bool constant_time_equal(const char *a, const char *b) { if (!a || !b) return false; size_t la = strlen(a), lb = strlen(b); size_t n = la > lb ? la : lb; unsigned diff = (unsigned)(la ^ lb); for (size_t i = 0; i < n; i++) { unsigned char ca = i < la ? (unsigned char)a[i] : 0; unsigned char cb = i < lb ? (unsigned char)b[i] : 0; diff |= (unsigned)(ca ^ cb); } return diff == 0; } /* 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; } static void init_auth(void) { 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); const char *password = getenv("NAUT_AUTH_PASSWORD"); if (!password || !*password) password = getenv("NAUT_PASSWORD"); if (password && *password) { snprintf(g_webui.auth_password, sizeof g_webui.auth_password, "%s", password); g_webui.generated_password = false; return; } if (!random_hex(g_webui.auth_password, sizeof g_webui.auth_password, 9)) { /* No CSPRNG: leave the password empty so login is impossible rather * than guessable. The operator must set NAUT_AUTH_PASSWORD. */ g_webui.auth_password[0] = 0; } g_webui.generated_password = true; } 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; } static bool current_user(const char *headers, const char *end) { 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; ok = true; break; } pthread_mutex_unlock(&g_webui.auth_lock); return ok; } static bool create_session(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); slot->expires = expires; slot->used = true; pthread_mutex_unlock(&g_webui.auth_lock); snprintf(out, out_size, "%s", token); return true; } 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(const char *method, json_t *params) { 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 (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 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 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) { 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, "stopped") == 0) return progress >= 1.0 ? "pausedUP" : "pausedDL"; if (strcmp(state, "stopping") == 0) return "pausedDL"; 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 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 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); } /* 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; } } const char *state = ui_state(json_string_value(json_object_get(torrent, "state")), progress); json_t *trackers = NULL; json_t *files = NULL; json_t *peers_list = NULL; json_t *hosts = NULL; if (detail) { trackers = json_array(); if (trackers) json_array_append_new(trackers, json_pack( "{s:s,s:i,s:s,s:i,s:i,s:i,s:i,s:s}", "url", "** [DHT] **", "tier", -1, "status", "working", "seeds", (int)json_u64(torrent, "peers_discovered"), "peers", (int)json_u64(torrent, "peers"), "leeches", -1, "downloaded", -1, "message", "")); files = json_array(); if (files) 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); hosts = tracker_hosts(trackers); } 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); if (detail) { json_decref(trackers); json_decref(files); json_decref(peers_list); json_decref(hosts); } 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)); json_object_set_new(out, "priority", json_integer(1)); 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_false()); 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)); /* category + tags come from the web-layer assignment store */ 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; } /* 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 *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) active++; total_rate += (uint64_t)dlspeed; total_data += done; json_array_append_new(items, mapped); } json_decref(torrents); json_t *server = json_pack( "{s:I,s:i,s:I,s:i,s:i,s:i,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", 0, "up_rate_limit", 0, "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); 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 void api_meta(int fd) { json_t *json = json_object(); json_t *preferences = json_object(); 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_object_set_new(json, "trackers", json_array()); json_object_set_new(preferences, "save_path", json_string(getenv("NAUT_WEBUI_SAVE_PATH") ? getenv("NAUT_WEBUI_SAVE_PATH") : ".")); json_object_set_new(preferences, "dl_limit", json_integer(0)); json_object_set_new(preferences, "up_limit", json_integer(0)); json_object_set_new(preferences, "alt_speed_enabled", json_false()); json_object_set_new(json, "preferences", preferences); json_object_set_new(json, "searchPlugins", json_array()); 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. */ 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); 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; } 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)); json_t *result = rpc_call_json("add_torrent", params); json_decref(params); json_decref(req); if (!result) { 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); 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(); } /* Category/tag assignment is web-layer state the plugin owns, so those verbs * are honored here. Engine-level verbs (pause/resume/recheck/queue/rate * limits) have no daemon support yet, so we return 501 instead of pretending * they worked; the front end shows that as an honest "Action failed" toast. */ 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; 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); } handled = true; } json_decref(req); if (handled) { json_t *json = json_pack("{s:b}", "ok", 1); 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); } /* 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); else store_add_category(name, json_string_value(json_object_get(req, "savePath"))); } 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); else store_add_tag(name); } 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); } 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) { strip_query(path); if (strcmp(path, "/api/auth/status") == 0 && strcmp(method, "GET") == 0) { json_t *json = json_pack("{s:b,s:s,s:b}", "authenticated", current_user(headers, headers_end), "user", g_webui.auth_user, "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")); bool user_ok = user && constant_time_equal(user, g_webui.auth_user); bool pass_ok = password && g_webui.auth_password[0] && constant_time_equal(password, g_webui.auth_password); if (!user_ok || !pass_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(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}", "ok", 1, "user", g_webui.auth_user); 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_user(headers, headers_end)) { 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_meta(fd); } else if (strcmp(path, "/api/altspeed") == 0 && strcmp(method, "POST") == 0) { json_t *json = json_pack("{s:b}", "alt_speed_enabled", 0); http_json(fd, 200, json); json_decref(json); } 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/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 (strncmp(path, "/api/rss", 8) == 0 && strcmp(method, "GET") == 0) { json_t *json = json_array(); http_json(fd, 200, json); json_decref(json); } else if (strncmp(path, "/api/search", 11) == 0 && strcmp(method, "GET") == 0) { json_t *json = json_array(); http_json(fd, 200, json); json_decref(json); } 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)"); snprintf(msg, sizeof msg, "webui: auth user %s", g_webui.auth_user); log_msg(2, msg); if (g_webui.generated_password && g_webui.auth_password[0]) { snprintf(msg, sizeof msg, "webui: generated password %s", g_webui.auth_password); log_msg(1, msg); } else if (!g_webui.auth_password[0]) { log_msg(0, "webui: no password available (CSPRNG unavailable); set " "NAUT_AUTH_PASSWORD to enable login"); } 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; 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; 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; 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; 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); return NAUT_OK; }