Address the review of the webui plugin: - Live download rates. A background sampler polls the daemon once per second, derives per-torrent dlspeed from successive byte counts (EWMA smoothed), and computes a real ETA. dl_info_speed now aggregates the fleet instead of reporting a hardcoded 0. - Single shared snapshot. The sampler publishes one cached snapshot that /api/snapshot, /api/torrents and every SSE stream serve, so N browser tabs no longer each poll the engine and race the speed table. SSE waiters block on a condition and wake promptly on shutdown. - Honest /api/action. The engine has no pause/resume/recheck/queue verbs, so the endpoint returns 501 with an explanatory message instead of claiming success. - Reject oversized uploads with 413 instead of silently truncating a torrent into garbage. - Auth hardening: constant-time credential comparison, CSPRNG-only token generation via getrandom (fail closed, no weak fallback), oldest-session eviction instead of clobbering slot 0, and a warning when bound to a non-loopback address. - Cap concurrent connections (503 beyond the limit) so a client can't spawn unbounded threads. - nautd: tear down plugins (joining the webui's threads) before freeing torrent tasks, closing a shutdown-time use-after-free window where an in-flight request could touch freed state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1418 lines
51 KiB
C
1418 lines
51 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/random.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/time.h>
|
|
#include <time.h>
|
|
#include <unistd.h>
|
|
|
|
#define DEFAULT_HOST "127.0.0.1"
|
|
#define DEFAULT_PORT 8080
|
|
#define READ_LIMIT (8u << 20)
|
|
#define SESSION_COOKIE "naut_session"
|
|
#define SESSION_TTL_SECONDS (60 * 60 * 24 * 7)
|
|
#define MAX_SESSIONS 64
|
|
#define MAX_CONNECTIONS 128
|
|
#define SPEED_SLOTS 256
|
|
#define ETA_INFINITY 8640000 /* torrent-ui renders >= this as the infinity glyph */
|
|
|
|
typedef struct {
|
|
char token[96];
|
|
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];
|
|
|
|
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 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 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;
|
|
}
|
|
|
|
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;
|
|
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 = json_array();
|
|
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", (json_int_t)dlspeed,
|
|
"upspeed", 0,
|
|
"eta", compute_eta(done, total, dlspeed),
|
|
"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", 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 if (detail) {
|
|
json_decref(trackers);
|
|
json_decref(files);
|
|
json_decref(peers_list);
|
|
json_decref(hosts);
|
|
}
|
|
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;
|
|
}
|
|
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);
|
|
/* 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++;
|
|
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();
|
|
}
|
|
|
|
/* The engine has no pause/resume/recheck/queue/category/limit verbs yet, so
|
|
* rather than claim success we tell the UI the action is unsupported. The
|
|
* front end surfaces a non-2xx 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 *action = json_string_value(json_object_get(req, "action"));
|
|
char message[128];
|
|
snprintf(message, sizeof message,
|
|
"action '%s' is not supported by the engine",
|
|
action ? action : "");
|
|
json_decref(req);
|
|
json_t *json = json_pack("{s:b,s:s}", "ok", 0, "error", message);
|
|
http_json(fd, 501, json);
|
|
json_decref(json);
|
|
}
|
|
|
|
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(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) {
|
|
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;
|
|
init_auth();
|
|
error = g_webui.host.set_plugin_name(g_webui.host.host_context,
|
|
"webui");
|
|
if (error != NAUT_OK) goto fail_named;
|
|
error = start_server();
|
|
if (error != NAUT_OK) goto fail_named;
|
|
return NAUT_OK;
|
|
|
|
fail_named:
|
|
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;
|
|
|
|
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;
|
|
}
|