#include "naut/naut_plugin.h" #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 typedef struct { char token[96]; time_t expires; bool used; } webui_session; 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; pthread_mutex_t auth_lock; pthread_mutex_t conn_lock; pthread_cond_t conn_cond; size_t active_connections; webui_session sessions[MAX_SESSIONS]; } webui_state; typedef struct { int fd; } conn_arg; static webui_state g_webui; 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); } 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; } 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; int fd = open("/dev/urandom", O_RDONLY); ssize_t got = fd >= 0 ? read(fd, buf, bytes) : -1; if (fd >= 0) close(fd); if (got != (ssize_t)bytes) { unsigned seed = (unsigned)time(NULL) ^ (unsigned)getpid(); for (size_t i = 0; i < bytes; i++) { seed = seed * 1103515245u + 12345u; buf[i] = (unsigned char)(seed >> 16); } } 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; } random_hex(g_webui.auth_password, sizeof g_webui.auth_password, 9); 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 expires = time(NULL) + SESSION_TTL_SECONDS; pthread_mutex_lock(&g_webui.auth_lock); webui_session *slot = NULL; for (size_t i = 0; i < MAX_SESSIONS; i++) { if (!g_webui.sessions[i].used) { slot = &g_webui.sessions[i]; break; } } if (!slot) slot = &g_webui.sessions[0]; 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 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"; } 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_torrent(json_t *torrent, bool detail) { uint64_t id = json_u64(torrent, "torrent_id"); 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; char hash[32]; snprintf(hash, sizeof hash, "%llu", (unsigned long long)id); char *name = torrent_name(torrent); if (!name) return NULL; const char *state = ui_state(json_string_value(json_object_get(torrent, "state")), progress); json_t *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", "")); } json_t *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)); } json_t *peers_list = json_array(); json_t *tags = json_array(); json_t *hosts = tracker_hosts(trackers); json_t *out = json_pack( "{s:s,s:s,s:I,s:f,s:i,s:i,s:i,s:i,s:i,s:i,s:f,s:s,s:o,s:s," "s:I,s:I,s:I,s:I,s:f,s:i,s:o,s:b,s:b,s:b,s:I,s:I,s:s,s:s}", "hash", hash, "name", name, "size", (json_int_t)total, "progress", progress, "dlspeed", 0, "upspeed", 0, "eta", progress > 0.0 && progress < 1.0 ? 8640000 : 0, "seeds", (int)json_u64(torrent, "peers"), "seedsTotal", (int)json_u64(torrent, "peers_discovered"), "peers", (int)json_u64(torrent, "peers_connecting"), "peersTotal", (int)json_u64(torrent, "peers_discovered"), "ratio", 0.0, "category", "", "tags", tags ? tags : json_array(), "savePath", json_string_or(torrent, "output", ""), "addedOn", (json_int_t)0, "completionOn", progress >= 1.0 ? (json_int_t)0 : (json_int_t)-1, "lastActivity", (json_int_t)0, "downloaded", (json_int_t)done, "uploaded", (json_int_t)0, "availability", 1.0, "priority", 1, "trackerHosts", hosts ? hosts : json_array(), "seqDl", false, "superSeeding", false, "forceStart", false, "timeActive", (json_int_t)json_u64(torrent, "elapsed_seconds"), "pieceSize", pieces ? (json_int_t)(total / pieces) : (json_int_t)0, "state", state, "contentPath", json_string_or(torrent, "output", "")); if (out && 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_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()); } else { json_decref(trackers); json_decref(files); json_decref(peers_list); } free(name); return out; } static json_t *snapshot_json(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(); } json_t *items = json_array(); uint64_t active = 0; size_t index; json_t *torrent; json_array_foreach(torrents, index, torrent) { json_t *mapped = map_torrent(torrent, false); if (!mapped) continue; const char *state = json_string_value(json_object_get(mapped, "state")); if (state && strcmp(state, "downloading") == 0) active++; 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", 0, "up_info_speed", 0, "dl_info_data", 0, "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); } 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); 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; } json_object_set_new(json, "categories", json_array()); json_object_set_new(json, "tags", json_array()); 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 *pieces = json_array(); for (uint64_t i = 0; pieces && 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")); if (!save_path || !*save_path) save_path = "."; if (!source) source = magnet; if ((!source || !*source) && (!data || !*data)) { json_decref(req); http_text(fd, 400, "Bad Request", "torrent-ui must send a magnet, source, or torrent data"); 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; } char id[32]; snprintf(id, sizeof id, "%llu", (unsigned long long)json_u64(result, "torrent_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); } 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++; 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); } static void api_noop(int fd) { json_t *reply = json_pack("{s:b,s:i}", "ok", 1, "affected", 0); 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; while (!atomic_load(&g_webui.stopping)) { json_t *snapshot = snapshot_json(); char *text = json_dumps(snapshot, JSON_COMPACT | JSON_ENCODE_ANY); json_decref(snapshot); if (!text) break; bool ok = send_all_fd(fd, "event: snapshot\ndata: ", 22) && send_all_fd(fd, text, strlen(text)) && send_all_fd(fd, "\n\n", 2); free(text); if (!ok) break; sleep(1); } } 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")); if (!user || !password || strcmp(user, g_webui.auth_user) != 0 || strcmp(password, g_webui.auth_password) != 0) { 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) { json_t *json = snapshot_json(); http_json(fd, 200, json); json_decref(json); } 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(path, "/api/categories/delete") == 0) && strcmp(method, "POST") == 0) { json_t *json = json_array(); http_json(fd, 200, json); json_decref(json); } else if ((strcmp(path, "/api/tags") == 0 || strcmp(path, "/api/tags/delete") == 0) && strcmp(method, "POST") == 0) { json_t *json = json_array(); http_json(fd, 200, json); json_decref(json); } else if (strcmp(path, "/api/torrents") == 0 && strcmp(method, "GET") == 0) { json_t *snapshot = snapshot_json(); json_t *torrents = json_incref(json_object_get(snapshot, "torrents")); http_json(fd, 200, torrents); json_decref(torrents); json_decref(snapshot); } 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_noop(fd); } 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); if (content_length > READ_LIMIT - header_len) content_length = READ_LIMIT - header_len; 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); conn_arg *conn = malloc(sizeof(*conn)); if (!conn) { close(fd); continue; } conn->fd = fd; pthread_mutex_lock(&g_webui.conn_lock); g_webui.active_connections++; pthread_mutex_unlock(&g_webui.conn_lock); 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; if (pthread_create(&g_webui.thread, NULL, server_thread, NULL) != 0) { 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); snprintf(msg, sizeof msg, "webui: auth user %s", g_webui.auth_user); log_msg(2, msg); if (g_webui.generated_password) { snprintf(msg, sizeof msg, "webui: generated password %s", 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; 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) { pthread_mutex_destroy(&g_webui.auth_lock); return NAUT_ERR_NOMEM; } if (pthread_cond_init(&g_webui.conn_cond, NULL) != 0) { pthread_mutex_destroy(&g_webui.conn_lock); pthread_mutex_destroy(&g_webui.auth_lock); return NAUT_ERR_NOMEM; } init_auth(); naut_err error = g_webui.host.set_plugin_name(g_webui.host.host_context, "webui"); if (error != NAUT_OK) { pthread_cond_destroy(&g_webui.conn_cond); pthread_mutex_destroy(&g_webui.conn_lock); pthread_mutex_destroy(&g_webui.auth_lock); return error; } error = start_server(); if (error != NAUT_OK) { pthread_cond_destroy(&g_webui.conn_cond); pthread_mutex_destroy(&g_webui.conn_lock); pthread_mutex_destroy(&g_webui.auth_lock); } return error; } naut_err naut_plugin_shutdown(void) { atomic_store(&g_webui.stopping, true); 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; 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); pthread_cond_destroy(&g_webui.conn_cond); pthread_mutex_destroy(&g_webui.conn_lock); pthread_mutex_destroy(&g_webui.auth_lock); return NAUT_OK; }