Naut/plugins/webui/webui.c
ookami125 8dde48c05a webui: replace nautctl web server with a loadable plugin
Drop the web UI that was compiled into nautctl and serve the
torrent-ui front end (../torrent-ui/public) from a native plugin
(plugins/webui) loaded via `nautd --plugin`. The plugin talks to the
engine only through the host call_rpc ABI and adapts the daemon's RPC
surface to the qBittorrent-style contract the UI expects (snapshot/SSE,
torrent detail tabs, add/delete, cookie auth).

Also folds in the daemon refactor that owns per-torrent worker threads
and the swarm engine (naut_swarm) used by the plugin's data source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:21:48 -04:00

1117 lines
40 KiB
C

#include "naut/naut_plugin.h"
#include <jansson.h>
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#define DEFAULT_HOST "127.0.0.1"
#define DEFAULT_PORT 8080
#define READ_LIMIT (8u << 20)
#define SESSION_COOKIE "naut_session"
#define SESSION_TTL_SECONDS (60 * 60 * 24 * 7)
#define MAX_SESSIONS 64
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;
}