Naut/plugins/webui/webui.c
ookami125 b633b7d216 nautd/webui: scripting, labels, settings, set-location, pause fix
Session checkpoint on webui-plugin:
- engine dump (nautctl dump) + engine endgame integration
- per-file move locations persistence; torrent-level "Set location"
  with reset/keep-relative/leave-separate handling + residual prune
- Lua: naut.get_labels, define_settings/get_setting (script_host struct)
- daemon-owned labels (category+tags) + taxonomy persistence; webui write-through
- fix: pausing a completed/seeding torrent now sticks (stop wins over result)
- automation tab responsive layout; anime_sort label gating + settings

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

2184 lines
82 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];
/* Categories and tags are pure UI organization the engine knows nothing
* about, so the web layer owns them (in memory, like qBittorrent's own
* Web API does). assignments maps "<id>" -> {category, tags:[...]}. */
pthread_mutex_t meta_lock;
json_t *categories; /* array of {name, savePath} */
json_t *tags; /* array of tag name strings */
json_t *assignments; /* object keyed by stringified torrent id */
webui_session sessions[MAX_SESSIONS];
} webui_state;
typedef struct {
int fd;
} conn_arg;
static webui_state g_webui;
static double monotonic_seconds(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}
static void log_msg(int level, const char *message) {
if (g_webui.host.log)
g_webui.host.log(g_webui.host.host_context, level, message);
}
static bool send_all_fd(int fd, const char *buf, size_t len) {
while (len) {
ssize_t n = send(fd, buf, len, MSG_NOSIGNAL);
if (n < 0) {
if (errno == EINTR) continue;
return false;
}
if (n == 0) return false;
buf += n;
len -= (size_t)n;
}
return true;
}
static void http_head_extra(int fd, int code, const char *status,
const char *ctype, size_t len,
const char *extra) {
char h[512];
int n = snprintf(h, sizeof h,
"HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\n"
"Cache-Control: no-cache\r\n%sConnection: close\r\n\r\n",
code, status, ctype, len, extra ? extra : "");
if (n > 0) send_all_fd(fd, h, (size_t)n);
}
static void http_head(int fd, int code, const char *status,
const char *ctype, size_t len) {
http_head_extra(fd, code, status, ctype, len, NULL);
}
static void http_text(int fd, int code, const char *status, const char *body) {
if (!body) body = "";
http_head(fd, code, status, "text/plain; charset=utf-8", strlen(body));
send_all_fd(fd, body, strlen(body));
}
static void http_json(int fd, int code, json_t *json) {
char *txt = json ? json_dumps(json, JSON_COMPACT | JSON_ENCODE_ANY) : NULL;
if (!txt) {
http_text(fd, 500, "Internal Server Error", "json encode failed");
return;
}
http_head(fd, code, code == 200 ? "OK" : "Error",
"application/json; charset=utf-8", strlen(txt));
send_all_fd(fd, txt, strlen(txt));
free(txt);
}
static void http_json_extra(int fd, int code, json_t *json, const char *extra) {
char *txt = json ? json_dumps(json, JSON_COMPACT | JSON_ENCODE_ANY) : NULL;
if (!txt) {
http_text(fd, 500, "Internal Server Error", "json encode failed");
return;
}
http_head_extra(fd, code, code == 200 ? "OK" : "Error",
"application/json; charset=utf-8", strlen(txt), extra);
send_all_fd(fd, txt, strlen(txt));
free(txt);
}
/* Serve an already-serialized JSON string under one lock copy. */
static void http_json_str(int fd, const char *json, const char *fallback) {
const char *body = json ? json : fallback;
http_head(fd, 200, "OK", "application/json; charset=utf-8", strlen(body));
send_all_fd(fd, body, strlen(body));
}
static const char *mime_type(const char *path) {
const char *dot = strrchr(path, '.');
if (!dot) return "application/octet-stream";
if (strcmp(dot, ".html") == 0) return "text/html; charset=utf-8";
if (strcmp(dot, ".js") == 0) return "text/javascript; charset=utf-8";
if (strcmp(dot, ".css") == 0) return "text/css; charset=utf-8";
if (strcmp(dot, ".json") == 0) return "application/json; charset=utf-8";
if (strcmp(dot, ".svg") == 0) return "image/svg+xml";
if (strcmp(dot, ".ico") == 0) return "image/x-icon";
return "application/octet-stream";
}
static void strip_query(char *path) {
char *q = strchr(path, '?');
if (q) *q = 0;
char *hash = strchr(path, '#');
if (hash) *hash = 0;
}
/* Constant-time equality so credential checks don't leak length/content via
* timing. Returns true when both NUL-terminated strings match exactly. */
static bool constant_time_equal(const char *a, const char *b) {
if (!a || !b) return false;
size_t la = strlen(a), lb = strlen(b);
size_t n = la > lb ? la : lb;
unsigned diff = (unsigned)(la ^ lb);
for (size_t i = 0; i < n; i++) {
unsigned char ca = i < la ? (unsigned char)a[i] : 0;
unsigned char cb = i < lb ? (unsigned char)b[i] : 0;
diff |= (unsigned)(ca ^ cb);
}
return diff == 0;
}
/* Cryptographically strong hex. Fails closed: if the kernel CSPRNG is
* unavailable we refuse rather than fall back to predictable bytes (these
* feed session tokens). */
static bool random_hex(char *out, size_t out_size, size_t bytes) {
static const char hex[] = "0123456789abcdef";
if (out_size < bytes * 2 + 1) return false;
unsigned char buf[64];
if (bytes > sizeof buf) return false;
size_t got = 0;
while (got < bytes) {
ssize_t n = getrandom(buf + got, bytes - got, 0);
if (n < 0) {
if (errno == EINTR) continue;
return false;
}
got += (size_t)n;
}
for (size_t i = 0; i < bytes; i++) {
out[i * 2] = hex[buf[i] >> 4];
out[i * 2 + 1] = hex[buf[i] & 15];
}
out[bytes * 2] = 0;
return true;
}
static void init_auth(void) {
const char *user = getenv("NAUT_AUTH_USER");
if (!user || !*user) user = getenv("NAUT_USER");
if (!user || !*user) user = "admin";
snprintf(g_webui.auth_user, sizeof g_webui.auth_user, "%s", user);
const char *password = getenv("NAUT_AUTH_PASSWORD");
if (!password || !*password) password = getenv("NAUT_PASSWORD");
if (password && *password) {
snprintf(g_webui.auth_password, sizeof g_webui.auth_password, "%s",
password);
g_webui.generated_password = false;
return;
}
if (!random_hex(g_webui.auth_password, sizeof g_webui.auth_password, 9)) {
/* No CSPRNG: leave the password empty so login is impossible rather
* than guessable. The operator must set NAUT_AUTH_PASSWORD. */
g_webui.auth_password[0] = 0;
}
g_webui.generated_password = true;
}
static const char *header_value(const char *headers, const char *end,
const char *name) {
size_t name_len = strlen(name);
for (const char *p = headers; p && p < end;) {
const char *line_end = memmem(p, (size_t)(end - p), "\r\n", 2);
if (!line_end) line_end = end;
if ((size_t)(line_end - p) > name_len &&
strncasecmp(p, name, name_len) == 0 && p[name_len] == ':') {
const char *value = p + name_len + 1;
while (value < line_end && (*value == ' ' || *value == '\t'))
value++;
return value;
}
p = line_end + 2;
}
return NULL;
}
static bool cookie_token(const char *headers, const char *end,
char *out, size_t out_size) {
const char *cookie = header_value(headers, end, "cookie");
if (!cookie) return false;
const char *line_end = memmem(cookie, (size_t)(end - cookie), "\r\n", 2);
if (!line_end) line_end = end;
const char *p = cookie;
size_t key_len = strlen(SESSION_COOKIE);
while (p < line_end) {
while (p < line_end && (*p == ' ' || *p == ';')) p++;
if ((size_t)(line_end - p) > key_len &&
strncmp(p, SESSION_COOKIE, key_len) == 0 &&
p[key_len] == '=') {
p += key_len + 1;
size_t len = strcspn(p, "; \r\n");
if (len >= out_size) len = out_size - 1;
memcpy(out, p, len);
out[len] = 0;
return true;
}
p = memchr(p, ';', (size_t)(line_end - p));
if (!p) break;
}
return false;
}
static bool current_user(const char *headers, const char *end) {
char token[96];
if (!cookie_token(headers, end, token, sizeof token)) return false;
bool ok = false;
time_t now = time(NULL);
pthread_mutex_lock(&g_webui.auth_lock);
for (size_t i = 0; i < MAX_SESSIONS; i++) {
webui_session *session = &g_webui.sessions[i];
if (!session->used || strcmp(session->token, token) != 0) continue;
if (session->expires < now) {
session->used = false;
break;
}
session->expires = now + SESSION_TTL_SECONDS;
ok = true;
break;
}
pthread_mutex_unlock(&g_webui.auth_lock);
return ok;
}
static bool create_session(char *out, size_t out_size) {
char token[96];
if (!random_hex(token, sizeof token, 24)) return false;
time_t now = time(NULL);
time_t expires = now + SESSION_TTL_SECONDS;
pthread_mutex_lock(&g_webui.auth_lock);
webui_session *slot = NULL;
for (size_t i = 0; i < MAX_SESSIONS; i++) {
webui_session *s = &g_webui.sessions[i];
if (!s->used || s->expires < now) { slot = s; break; }
/* Otherwise track the session that expires soonest, so a full table
* evicts the oldest rather than always clobbering slot 0. */
if (!slot || s->expires < slot->expires) slot = s;
}
snprintf(slot->token, sizeof slot->token, "%s", token);
slot->expires = expires;
slot->used = true;
pthread_mutex_unlock(&g_webui.auth_lock);
snprintf(out, out_size, "%s", token);
return true;
}
static void clear_session(const char *headers, const char *end) {
char token[96];
if (!cookie_token(headers, end, token, sizeof token)) return;
pthread_mutex_lock(&g_webui.auth_lock);
for (size_t i = 0; i < MAX_SESSIONS; i++)
if (g_webui.sessions[i].used &&
strcmp(g_webui.sessions[i].token, token) == 0)
g_webui.sessions[i].used = false;
pthread_mutex_unlock(&g_webui.auth_lock);
}
static bool bad_static_path(const char *path) {
return strstr(path, "..") || strchr(path, '\\');
}
static bool join_root_path(char *out, size_t out_size, const char *suffix) {
int n = snprintf(out, out_size, "%s%s", g_webui.root, suffix);
return n > 0 && (size_t)n < out_size;
}
static bool serve_file(int fd, const char *request_path) {
char clean[PATH_MAX];
snprintf(clean, sizeof clean, "%s", request_path && *request_path
? request_path : "/");
strip_query(clean);
if (strcmp(clean, "/") == 0) snprintf(clean, sizeof clean, "/index.html");
if (bad_static_path(clean)) {
http_text(fd, 403, "Forbidden", "forbidden");
return true;
}
char path[PATH_MAX];
if (!join_root_path(path, sizeof path, clean)) {
http_text(fd, 414, "URI Too Long", "path too long");
return true;
}
int file = open(path, O_RDONLY);
if (file < 0 && strchr(clean + 1, '/') == NULL) {
if (!join_root_path(path, sizeof path, "/index.html")) {
http_text(fd, 500, "Internal Server Error", "root too long");
return true;
}
file = open(path, O_RDONLY);
}
if (file < 0) return false;
struct stat st;
if (fstat(file, &st) != 0 || st.st_size < 0) {
close(file);
http_text(fd, 500, "Internal Server Error", "stat failed");
return true;
}
http_head(fd, 200, "OK", mime_type(path), (size_t)st.st_size);
char buf[16384];
for (;;) {
ssize_t n = read(file, buf, sizeof buf);
if (n < 0) {
if (errno == EINTR) continue;
break;
}
if (n == 0) break;
if (!send_all_fd(fd, buf, (size_t)n)) break;
}
close(file);
return true;
}
static json_t *rpc_call_json(const char *method, json_t *params) {
if (!g_webui.host.call_rpc) return NULL;
char *request = json_dumps(params ? params : json_null(),
JSON_COMPACT | JSON_ENCODE_ANY);
if (!request) return NULL;
char *response = NULL;
naut_err error = g_webui.host.call_rpc(g_webui.host.host_context,
method, request, &response);
free(request);
if (error != NAUT_OK || !response) {
free(response);
return NULL;
}
json_error_t json_error;
json_t *json = json_loads(response, JSON_REJECT_DUPLICATES |
JSON_DECODE_ANY, &json_error);
free(response);
return json;
}
static const char *json_string_or(const json_t *obj, const char *key,
const char *fallback) {
const char *value = json_string_value(json_object_get(obj, key));
return value ? value : fallback;
}
static uint64_t json_u64(const json_t *obj, const char *key) {
json_t *value = json_object_get(obj, key);
return json_is_integer(value) && json_integer_value(value) > 0
? (uint64_t)json_integer_value(value) : 0;
}
static int64_t json_i64_or(const json_t *obj, const char *key,
int64_t fallback) {
json_t *value = json_object_get(obj, key);
return json_is_integer(value) ? (int64_t)json_integer_value(value)
: fallback;
}
static double json_number_or(const json_t *obj, const char *key,
double fallback) {
json_t *value = json_object_get(obj, key);
return json_is_number(value) ? json_number_value(value) : fallback;
}
static const char *base_name(const char *path) {
if (!path || !*path) return "torrent";
const char *slash = strrchr(path, '/');
const char *name = slash ? slash + 1 : path;
return *name ? name : "torrent";
}
static char *torrent_name(const json_t *torrent) {
/* The daemon persists the display name captured at add time; prefer it so
* restored torrents (where the in-process name cache is empty) read right
* instead of falling back to an upload path's basename. */
const char *saved = json_string_value(json_object_get(torrent, "name"));
if (saved && *saved) return strdup(saved);
const char *source = json_string_or(torrent, "source", "torrent");
if (strncmp(source, "magnet:", 7) == 0) {
const char *dn = strstr(source, "dn=");
if (dn) {
dn += 3;
size_t len = strcspn(dn, "&");
char *name = malloc(len + 1);
if (!name) return NULL;
memcpy(name, dn, len);
name[len] = 0;
return name;
}
}
return strdup(base_name(source));
}
static const char *ui_state(const char *state, double progress) {
if (!state) return "stalledDL";
if (strcmp(state, "complete") == 0) return "uploading";
if (strcmp(state, "paused") == 0)
return progress >= 1.0 ? "pausedUP" : "pausedDL";
if (strcmp(state, "stopped") == 0)
return progress >= 1.0 ? "pausedUP" : "pausedDL";
if (strcmp(state, "stopping") == 0) return "pausedDL";
if (strcmp(state, "stalled") == 0)
return progress >= 1.0 ? "stalledUP" : "stalledDL";
if (strcmp(state, "queued") == 0) return "queuedDL";
if (strcmp(state, "error") == 0) return "error";
return "downloading";
}
/* ---- single-writer download-rate estimate keyed by torrent id ---- */
static double speed_sample(uint64_t id, uint64_t bytes) {
double now = monotonic_seconds();
double result = 0.0;
pthread_mutex_lock(&g_webui.speed_lock);
speed_slot *slot = NULL, *spare = NULL;
for (size_t i = 0; i < SPEED_SLOTS; i++) {
speed_slot *s = &g_webui.speeds[i];
if (s->used && s->id == id) { slot = s; break; }
if (!s->used && !spare) spare = s;
}
if (!slot) {
if (!spare) {
/* table full: evict least-recently-updated */
spare = &g_webui.speeds[0];
for (size_t i = 1; i < SPEED_SLOTS; i++)
if (g_webui.speeds[i].last_time < spare->last_time)
spare = &g_webui.speeds[i];
}
slot = spare;
slot->used = true;
slot->id = id;
slot->last_bytes = bytes;
slot->last_time = now;
slot->dlspeed = 0.0;
pthread_mutex_unlock(&g_webui.speed_lock);
return 0.0;
}
double dt = now - slot->last_time;
if (dt > 0.0) {
double delta = bytes >= slot->last_bytes
? (double)(bytes - slot->last_bytes) : 0.0;
double inst = delta / dt;
slot->dlspeed = slot->dlspeed * 0.6 + inst * 0.4;
if (slot->dlspeed < 0.0) slot->dlspeed = 0.0;
slot->last_bytes = bytes;
slot->last_time = now;
}
result = slot->dlspeed;
pthread_mutex_unlock(&g_webui.speed_lock);
return result;
}
static double speed_peek(uint64_t id) {
double result = 0.0;
pthread_mutex_lock(&g_webui.speed_lock);
for (size_t i = 0; i < SPEED_SLOTS; i++)
if (g_webui.speeds[i].used && g_webui.speeds[i].id == id) {
result = g_webui.speeds[i].dlspeed;
break;
}
pthread_mutex_unlock(&g_webui.speed_lock);
return result;
}
/* Drop slots for ids no longer present so a long-lived server doesn't hand a
* stale rate to a recycled id. */
static void speed_retain(json_t *torrents) {
pthread_mutex_lock(&g_webui.speed_lock);
for (size_t i = 0; i < SPEED_SLOTS; i++) {
speed_slot *s = &g_webui.speeds[i];
if (!s->used) continue;
bool found = false;
size_t index;
json_t *torrent;
json_array_foreach(torrents, index, torrent)
if (json_u64(torrent, "torrent_id") == s->id) { found = true; break; }
if (!found) s->used = false;
}
pthread_mutex_unlock(&g_webui.speed_lock);
}
static json_t *tracker_hosts(json_t *trackers) {
json_t *hosts = json_array();
if (!hosts || !json_is_array(trackers)) return hosts;
size_t index;
json_t *tracker;
json_array_foreach(trackers, index, tracker) {
const char *url = json_string_value(json_object_get(tracker, "url"));
if (!url || strstr(url, "**")) continue;
const char *start = strstr(url, "://");
start = start ? start + 3 : url;
size_t len = strcspn(start, "/");
char host[256];
snprintf(host, sizeof host, "%.*s", (int)len, start);
json_array_append_new(hosts, json_string(host));
}
return hosts;
}
static bool tracker_host(char out[256], const char *url) {
if (!url || strstr(url, "**")) return false;
const char *start = strstr(url, "://");
start = start ? start + 3 : url;
size_t len = strcspn(start, "/");
if (len == 0 || len >= 256) return false;
snprintf(out, 256, "%.*s", (int)len, start);
return true;
}
static json_t *tracker_summary(json_t *torrents) {
json_t *summary = json_array();
if (!summary || !json_is_array(torrents)) return summary;
size_t tindex;
json_t *torrent;
json_array_foreach(torrents, tindex, torrent) {
json_t *trackers = json_object_get(torrent, "trackers");
if (!json_is_array(trackers)) continue;
size_t index;
json_t *tracker;
json_array_foreach(trackers, index, tracker) {
int64_t tier = json_integer_value(json_object_get(tracker, "tier"));
if (tier < 0) continue;
char host[256];
if (!tracker_host(host, json_string_value(
json_object_get(tracker, "url"))))
continue;
bool found = false;
size_t hindex;
json_t *entry;
json_array_foreach(summary, hindex, entry) {
if (strcmp(json_string_or(entry, "host", ""), host) != 0)
continue;
json_int_t count =
json_integer_value(json_object_get(entry, "count"));
json_object_set_new(entry, "count", json_integer(count + 1));
found = true;
break;
}
if (!found)
json_array_append_new(summary, json_pack(
"{s:s,s:i}", "host", host, "count", 1));
}
}
return summary;
}
static json_t *map_peer_list(json_t *torrent) {
json_t *out = json_array();
json_t *peers = json_object_get(torrent, "peer_list");
if (!out || !json_is_array(peers)) return out;
size_t index;
json_t *peer;
json_array_foreach(peers, index, peer) {
if (!json_is_object(peer)) continue;
json_t *item = json_pack(
"{s:s,s:s,s:i,s:s,s:s,s:s,s:f,s:i,s:i,s:I,s:I,s:f}",
"country", "",
"ip", json_string_or(peer, "ip", ""),
"port", (int)json_u64(peer, "port"),
"client", json_string_or(peer, "client", "Unknown"),
"connection", json_string_or(peer, "connection", "TCP"),
"flags", json_string_or(peer, "flags", ""),
"progress", json_number_or(peer, "progress", 0.0),
"dlspeed", (int)json_u64(peer, "dlspeed"),
"upspeed", (int)json_u64(peer, "upspeed"),
"downloaded", (json_int_t)json_u64(peer, "downloaded"),
"uploaded", (json_int_t)json_u64(peer, "uploaded"),
"relevance", json_number_or(peer, "relevance", 0.0));
if (item) json_array_append_new(out, item);
}
return out;
}
static double torrent_progress_ratio(json_t *torrent, uint64_t *done_out,
uint64_t *total_out) {
uint64_t done = json_u64(torrent, "bytes_done");
uint64_t total = json_u64(torrent, "total_bytes");
uint64_t pieces = json_u64(torrent, "total_pieces");
uint64_t pieces_done = json_u64(torrent, "pieces_done");
double progress = total ? (double)done / (double)total :
(pieces ? (double)pieces_done / (double)pieces : 0.0);
if (progress > 1.0) progress = 1.0;
if (done_out) *done_out = done;
if (total_out) *total_out = total;
return progress;
}
static json_int_t compute_eta(uint64_t done, uint64_t total, double dlspeed) {
if (total > done && dlspeed >= 1.0)
return (json_int_t)((double)(total - done) / dlspeed);
return ETA_INFINITY;
}
/* ---- category / tag store (web-layer owned, guarded by meta_lock) ---- */
static int find_category(const char *name) {
size_t index;
json_t *value;
json_array_foreach(g_webui.categories, index, value)
if (strcmp(json_string_or(value, "name", ""), name) == 0)
return (int)index;
return -1;
}
static int find_tag(const char *name) {
size_t index;
json_t *value;
json_array_foreach(g_webui.tags, index, value)
if (strcmp(json_string_value(value), name) == 0) return (int)index;
return -1;
}
static void store_add_category(const char *name, const char *save_path) {
pthread_mutex_lock(&g_webui.meta_lock);
if (find_category(name) < 0)
json_array_append_new(g_webui.categories, json_pack(
"{s:s,s:s}", "name", name, "savePath", save_path ? save_path : ""));
pthread_mutex_unlock(&g_webui.meta_lock);
}
static void store_remove_category(const char *name) {
pthread_mutex_lock(&g_webui.meta_lock);
int index = find_category(name);
if (index >= 0) json_array_remove(g_webui.categories, (size_t)index);
/* drop the category from any torrent that had it */
const char *key;
json_t *entry;
json_object_foreach(g_webui.assignments, key, entry)
if (strcmp(json_string_or(entry, "category", ""), name) == 0)
json_object_set_new(entry, "category", json_string(""));
pthread_mutex_unlock(&g_webui.meta_lock);
}
static void store_add_tag(const char *name) {
pthread_mutex_lock(&g_webui.meta_lock);
if (find_tag(name) < 0)
json_array_append_new(g_webui.tags, json_string(name));
pthread_mutex_unlock(&g_webui.meta_lock);
}
static void store_remove_tag(const char *name) {
pthread_mutex_lock(&g_webui.meta_lock);
int index = find_tag(name);
if (index >= 0) json_array_remove(g_webui.tags, (size_t)index);
const char *key;
json_t *entry;
json_object_foreach(g_webui.assignments, key, entry) {
json_t *tags = json_object_get(entry, "tags");
size_t i = 0;
while (i < json_array_size(tags)) {
if (strcmp(json_string_value(json_array_get(tags, i)), name) == 0)
json_array_remove(tags, i);
else
i++;
}
}
pthread_mutex_unlock(&g_webui.meta_lock);
}
static json_t *assignment_locked(uint64_t id, bool create) {
char key[32];
snprintf(key, sizeof key, "%llu", (unsigned long long)id);
json_t *entry = json_object_get(g_webui.assignments, key);
if (!entry && create) {
entry = json_pack("{s:s,s:o}", "category", "", "tags", json_array());
json_object_set_new(g_webui.assignments, key, entry);
}
return entry;
}
static void store_set_category(uint64_t id, const char *category) {
pthread_mutex_lock(&g_webui.meta_lock);
json_t *entry = assignment_locked(id, true);
if (entry)
json_object_set_new(entry, "category",
json_string(category ? category : ""));
pthread_mutex_unlock(&g_webui.meta_lock);
}
static void store_update_tags(uint64_t id, json_t *tags, bool add) {
if (!json_is_array(tags)) return;
pthread_mutex_lock(&g_webui.meta_lock);
json_t *entry = assignment_locked(id, true);
json_t *have = entry ? json_object_get(entry, "tags") : NULL;
if (have) {
size_t index;
json_t *value;
json_array_foreach(tags, index, value) {
const char *name = json_string_value(value);
if (!name) continue;
size_t pos = 0;
bool present = false;
for (; pos < json_array_size(have); pos++)
if (strcmp(json_string_value(json_array_get(have, pos)),
name) == 0) { present = true; break; }
if (add && !present)
json_array_append_new(have, json_string(name));
else if (!add && present)
json_array_remove(have, pos);
}
}
pthread_mutex_unlock(&g_webui.meta_lock);
}
static void store_set_name(uint64_t id, const char *name) {
pthread_mutex_lock(&g_webui.meta_lock);
json_t *entry = assignment_locked(id, true);
if (entry) json_object_set_new(entry, "name", json_string(name));
pthread_mutex_unlock(&g_webui.meta_lock);
}
static bool parse_id(const char *text, uint64_t *id);
/* Mirror a torrent's category + tags into the daemon (which persists them and
* exposes the flattened set to Lua via naut.get_labels). The web layer is the
* editing surface; the daemon is the source of truth. Snapshots the assignment
* under meta_lock, then RPCs without it held. */
static void webui_sync_labels(uint64_t id) {
char key[32];
snprintf(key, sizeof key, "%llu", (unsigned long long)id);
pthread_mutex_lock(&g_webui.meta_lock);
json_t *entry = json_object_get(g_webui.assignments, key);
char *category = strdup(entry ? json_string_or(entry, "category", "") : "");
json_t *tags_src = entry ? json_object_get(entry, "tags") : NULL;
json_t *tags = tags_src ? json_deep_copy(tags_src) : json_array();
pthread_mutex_unlock(&g_webui.meta_lock);
json_t *params = json_pack("{s:I,s:s,s:o}", "torrent_id", (json_int_t)id,
"category", category ? category : "",
"tags", tags);
free(category);
if (!params) { json_decref(tags); return; }
json_t *reply = rpc_call_json("set_labels", params);
json_decref(params);
if (reply) json_decref(reply);
}
/* Re-push every torrent's labels (after a global category/tag removal that can
* touch many assignments at once). */
static void webui_sync_all_labels(void) {
pthread_mutex_lock(&g_webui.meta_lock);
size_t n = json_object_size(g_webui.assignments);
uint64_t *ids = n ? malloc(n * sizeof *ids) : NULL;
size_t count = 0;
if (ids) {
const char *key;
json_t *entry;
json_object_foreach(g_webui.assignments, key, entry) {
uint64_t id = 0;
if (parse_id(key, &id)) ids[count++] = id;
}
}
pthread_mutex_unlock(&g_webui.meta_lock);
for (size_t i = 0; i < count; i++) webui_sync_labels(ids[i]);
free(ids);
}
/* Persist the full category + tag lists (including unassigned ones) to the
* daemon so they survive restarts. */
static void webui_sync_taxonomy(void) {
pthread_mutex_lock(&g_webui.meta_lock);
json_t *cats = json_deep_copy(g_webui.categories);
json_t *tags = json_deep_copy(g_webui.tags);
pthread_mutex_unlock(&g_webui.meta_lock);
json_t *params = json_pack("{s:o,s:o}",
"categories", cats ? cats : json_array(),
"tags", tags ? tags : json_array());
if (!params) { json_decref(cats); json_decref(tags); return; }
json_t *reply = rpc_call_json("set_label_taxonomy", params);
json_decref(params);
if (reply) json_decref(reply);
}
/* Seed the category + tag lists from the daemon's persisted copy at startup. */
static void webui_load_taxonomy(void) {
json_t *params = json_object();
json_t *reply = rpc_call_json("get_label_taxonomy", params);
json_decref(params);
if (!json_is_object(reply)) { json_decref(reply); return; }
json_t *cats = json_object_get(reply, "categories");
json_t *tags = json_object_get(reply, "tags");
pthread_mutex_lock(&g_webui.meta_lock);
if (json_is_array(cats)) {
json_decref(g_webui.categories);
g_webui.categories = json_deep_copy(cats);
}
if (json_is_array(tags)) {
json_decref(g_webui.tags);
g_webui.tags = json_deep_copy(tags);
}
pthread_mutex_unlock(&g_webui.meta_lock);
json_decref(reply);
}
static bool store_get_name(uint64_t id, char *out, size_t out_size) {
bool found = false;
pthread_mutex_lock(&g_webui.meta_lock);
json_t *entry = assignment_locked(id, false);
const char *name = entry ? json_string_value(json_object_get(entry, "name"))
: NULL;
if (name && *name) {
snprintf(out, out_size, "%s", name);
found = true;
}
pthread_mutex_unlock(&g_webui.meta_lock);
return found;
}
static void store_forget(uint64_t id) {
char key[32];
snprintf(key, sizeof key, "%llu", (unsigned long long)id);
pthread_mutex_lock(&g_webui.meta_lock);
json_object_del(g_webui.assignments, key);
pthread_mutex_unlock(&g_webui.meta_lock);
}
/* On first sight of a torrent (e.g. right after a restart, when the in-memory
* store is empty), seed its assignment + the global category/tag lists from the
* daemon's persisted category/tags. Only creates a missing entry, so live web
* edits are never clobbered. */
static void seed_assignment_from_daemon(uint64_t id, json_t *torrent) {
const char *category = json_string_or(torrent, "category", "");
json_t *tags = json_object_get(torrent, "tags");
char key[32];
snprintf(key, sizeof key, "%llu", (unsigned long long)id);
pthread_mutex_lock(&g_webui.meta_lock);
if (!json_object_get(g_webui.assignments, key)) {
json_t *entry = json_pack(
"{s:s,s:o}", "category", category,
"tags", json_is_array(tags) ? json_deep_copy(tags) : json_array());
if (entry) json_object_set_new(g_webui.assignments, key, entry);
if (category && *category && find_category(category) < 0)
json_array_append_new(g_webui.categories, json_pack(
"{s:s,s:s}", "name", category, "savePath", ""));
size_t i;
json_t *v;
json_array_foreach(tags, i, v) {
const char *t = json_string_value(v);
if (t && *t && find_tag(t) < 0)
json_array_append_new(g_webui.tags, json_string(t));
}
}
pthread_mutex_unlock(&g_webui.meta_lock);
}
/* Fill in category + tags for a torrent from the assignment store. */
static void apply_assignment(json_t *out, uint64_t id) {
pthread_mutex_lock(&g_webui.meta_lock);
json_t *entry = assignment_locked(id, false);
const char *category = entry ? json_string_or(entry, "category", "") : "";
json_t *tags = entry ? json_object_get(entry, "tags") : NULL;
json_object_set_new(out, "category", json_string(category));
json_object_set_new(out, "tags",
tags ? json_deep_copy(tags) : json_array());
pthread_mutex_unlock(&g_webui.meta_lock);
}
static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) {
uint64_t id = json_u64(torrent, "torrent_id");
uint64_t done = 0, total = 0;
double progress = torrent_progress_ratio(torrent, &done, &total);
uint64_t pieces = json_u64(torrent, "total_pieces");
uint64_t pieces_done = json_u64(torrent, "pieces_done");
char hash[32];
snprintf(hash, sizeof hash, "%llu", (unsigned long long)id);
char *name = torrent_name(torrent);
if (!name) return NULL;
/* Prefer the display name the UI captured at add time over the daemon's
* temp upload path. */
char override[256];
if (store_get_name(id, override, sizeof override)) {
char *better = strdup(override);
if (better) { free(name); name = better; }
}
bool force_start =
json_boolean_value(json_object_get(torrent, "force_start"));
const char *state = ui_state(json_string_value(json_object_get(torrent,
"state")),
progress);
if (force_start && strcmp(state, "downloading") == 0)
state = progress >= 1.0 ? "forcedUP" : "forcedDL";
json_t *trackers = NULL;
json_t *files = NULL;
json_t *peers_list = NULL;
json_t *hosts = NULL;
json_t *source_trackers = json_object_get(torrent, "trackers");
json_t *source_files = json_object_get(torrent, "files");
hosts = tracker_hosts(source_trackers);
if (detail) {
trackers = json_is_array(source_trackers)
? json_deep_copy(source_trackers) : json_array();
files = json_is_array(source_files)
? json_deep_copy(source_files) : json_array();
if (files && json_array_size(files) == 0 && total > 0)
json_array_append_new(files, json_pack(
"{s:s,s:I,s:f,s:i,s:f}", "name", name,
"size", (json_int_t)total, "progress", progress,
"priority", 1, "availability", 1.0));
peers_list = map_peer_list(torrent);
}
uint64_t discovered = json_u64(torrent, "peers_discovered");
const char *output = json_string_or(torrent, "output", "");
/* Built field-by-field on purpose: a single 30-key json_pack drifts out of
* sync with its argument list silently and then crashes on a type mismatch. */
json_t *out = json_object();
if (!out) {
free(name);
json_decref(hosts);
if (detail) {
json_decref(trackers);
json_decref(files);
json_decref(peers_list);
}
return NULL;
}
json_object_set_new(out, "hash", json_string(hash));
json_object_set_new(out, "name", json_string(name));
json_object_set_new(out, "size", json_integer((json_int_t)total));
json_object_set_new(out, "progress", json_real(progress));
json_object_set_new(out, "dlspeed", json_integer((json_int_t)dlspeed));
json_object_set_new(out, "upspeed", json_integer(0));
json_object_set_new(out, "eta", json_integer(compute_eta(done, total, dlspeed)));
json_object_set_new(out, "seeds",
json_integer((json_int_t)json_u64(torrent, "peers")));
json_object_set_new(out, "seedsTotal", json_integer((json_int_t)discovered));
json_object_set_new(out, "peers",
json_integer((json_int_t)json_u64(torrent, "peers_connecting")));
json_object_set_new(out, "peersTotal", json_integer((json_int_t)discovered));
json_object_set_new(out, "ratio", json_real(0.0));
json_object_set_new(out, "savePath", json_string(output));
json_object_set_new(out, "addedOn", json_integer(0));
json_object_set_new(out, "completionOn",
json_integer(progress >= 1.0 ? 0 : -1));
json_object_set_new(out, "lastActivity", json_integer(0));
json_object_set_new(out, "downloaded", json_integer((json_int_t)done));
json_object_set_new(out, "uploaded", json_integer(0));
json_object_set_new(out, "availability", json_real(1.0));
int64_t queue_pos = json_i64_or(torrent, "queue_pos", 0);
json_object_set_new(out, "priority",
json_integer((json_int_t)(queue_pos < 0
? 1 : queue_pos + 1)));
json_object_set_new(out, "queuePos", json_integer((json_int_t)queue_pos));
json_object_set_new(out, "trackerHosts", hosts ? hosts : json_array());
json_object_set_new(out, "seqDl", json_false());
json_object_set_new(out, "superSeeding", json_false());
json_object_set_new(out, "forceStart", json_boolean(force_start));
json_object_set_new(out, "timeActive",
json_integer((json_int_t)json_u64(torrent, "elapsed_seconds")));
json_object_set_new(out, "pieceSize",
json_integer(pieces ? (json_int_t)(total / pieces) : 0));
json_object_set_new(out, "state", json_string(state));
json_object_set_new(out, "contentPath", json_string(output));
/* Seed the web-layer store from the daemon's persisted category/tags the
* first time we see a torrent (survives restarts), then apply it. */
seed_assignment_from_daemon(id, torrent);
apply_assignment(out, id);
if (detail) {
json_object_set_new(out, "comment", json_string(""));
json_object_set_new(out, "createdBy", json_string("Naut"));
json_object_set_new(out, "creationDate", json_integer(0));
json_object_set_new(out, "private", json_false());
json_object_set_new(out, "magnetUri",
json_string(json_string_or(torrent, "source", "")));
json_object_set_new(out, "pieceCount", json_integer((json_int_t)pieces));
json_object_set_new(out, "piecesDone", json_integer((json_int_t)pieces_done));
json_t *piece_states = json_object_get(torrent, "piece_states");
json_object_set_new(out, "pieceStates",
json_is_array(piece_states)
? json_deep_copy(piece_states)
: json_array());
json_object_set_new(out, "trackers", trackers ? trackers : json_array());
json_object_set_new(out, "peersList", peers_list ? peers_list : json_array());
json_object_set_new(out, "files", files ? files : json_array());
}
free(name);
return out;
}
static json_t *preferences_json(void);
/* Build a fresh snapshot (grid + global stats) with live download rates. */
static json_t *build_snapshot(void) {
json_t *params = json_object();
json_t *torrents = rpc_call_json("torrents", params);
json_decref(params);
if (!json_is_array(torrents)) {
json_decref(torrents);
torrents = json_array();
}
speed_retain(torrents);
json_t *prefs = preferences_json();
json_t *items = json_array();
uint64_t active = 0;
uint64_t total_rate = 0;
uint64_t total_data = 0;
size_t index;
json_t *torrent;
json_array_foreach(torrents, index, torrent) {
uint64_t id = json_u64(torrent, "torrent_id");
uint64_t done = json_u64(torrent, "bytes_done");
double dlspeed = speed_sample(id, done);
json_t *mapped = map_torrent(torrent, false, dlspeed);
if (!mapped) continue;
const char *state = json_string_value(json_object_get(mapped, "state"));
if (state && (strcmp(state, "downloading") == 0 ||
strcmp(state, "forcedDL") == 0)) active++;
total_rate += (uint64_t)dlspeed;
total_data += done;
int64_t q = json_i64_or(mapped, "queuePos", 0);
size_t pos = 0;
for (; pos < json_array_size(items); pos++) {
json_t *cur = json_array_get(items, pos);
if (q < json_i64_or(cur, "queuePos", 0)) break;
}
if (json_array_insert_new(items, pos, mapped) != 0)
json_decref(mapped);
}
json_decref(torrents);
bool alt_speed = prefs &&
json_boolean_value(json_object_get(prefs, "alt_speed_enabled"));
uint64_t dl_limit = prefs ? json_u64(prefs, alt_speed ? "alt_dl_limit"
: "dl_limit") : 0;
uint64_t up_limit = prefs ? json_u64(prefs, alt_speed ? "alt_up_limit"
: "up_limit") : 0;
json_t *server = json_pack(
"{s:I,s:i,s:I,s:i,s:I,s:I,s:b,s:f,s:i,s:s,s:i,s:I,s:i,s:i,s:s,s:i}",
"dl_info_speed", (json_int_t)total_rate,
"up_info_speed", 0,
"dl_info_data", (json_int_t)total_data,
"up_info_data", 0,
"dl_rate_limit", (json_int_t)dl_limit,
"up_rate_limit", (json_int_t)up_limit,
"alt_speed_enabled", alt_speed,
"global_ratio", 0.0,
"dht_nodes", 0,
"connection_status", "connected",
"listen_port", g_webui.port,
"free_space", (json_int_t)0,
"active_torrents", (int)active,
"total_torrents", (int)json_array_size(items),
"read_cache_hits", "0.0",
"queued_io_jobs", 0);
json_decref(prefs);
return json_pack("{s:I,s:o,s:o}", "ts", (json_int_t)time(NULL) * 1000,
"server", server, "torrents", items);
}
/* Publish a newly built snapshot for all readers; wakes SSE waiters. */
static void publish_snapshot(void) {
json_t *snapshot = build_snapshot();
if (!snapshot) return;
char *full = json_dumps(snapshot, JSON_COMPACT | JSON_ENCODE_ANY);
json_t *torrents = json_object_get(snapshot, "torrents");
char *list = json_dumps(torrents ? torrents : json_array(),
JSON_COMPACT | JSON_ENCODE_ANY);
json_decref(snapshot);
if (!full || !list) {
free(full);
free(list);
return;
}
pthread_mutex_lock(&g_webui.snap_lock);
free(g_webui.snapshot_str);
free(g_webui.torrents_str);
g_webui.snapshot_str = full;
g_webui.torrents_str = list;
g_webui.snap_seq++;
pthread_cond_broadcast(&g_webui.snap_cond);
pthread_mutex_unlock(&g_webui.snap_lock);
}
static void *sampler_thread(void *arg) {
(void)arg;
while (!atomic_load(&g_webui.stopping)) {
publish_snapshot();
/* sleep ~1s but stay responsive to shutdown */
for (int i = 0; i < 10 && !atomic_load(&g_webui.stopping); i++) {
struct timespec ts = { .tv_sec = 0, .tv_nsec = 100 * 1000 * 1000 };
nanosleep(&ts, NULL);
}
}
return NULL;
}
static bool parse_id(const char *text, uint64_t *id) {
if (!text || !*text) return false;
char *end = NULL;
unsigned long long value = strtoull(text, &end, 10);
if (!end || (*end && *end != '/')) return false;
*id = (uint64_t)value;
return true;
}
static json_t *full_torrent_by_hash(const char *hash) {
uint64_t id = 0;
if (!parse_id(hash, &id)) return NULL;
json_t *params = json_pack("{s:I}", "torrent_id", (json_int_t)id);
json_t *torrent = rpc_call_json("torrent", params);
json_decref(params);
if (!torrent) return NULL;
json_t *mapped = map_torrent(torrent, true, speed_peek(id));
json_decref(torrent);
return mapped;
}
static json_t *preferences_json(void) {
json_t *params = json_object();
json_t *prefs = rpc_call_json("get_preferences", params);
json_decref(params);
if (!json_is_object(prefs)) {
json_decref(prefs);
prefs = json_object();
}
if (!prefs) return NULL;
json_t *max_active = json_object_get(prefs, "max_active");
if (json_is_integer(max_active) &&
!json_object_get(prefs, "max_active_downloads")) {
json_object_set_new(prefs, "max_active_downloads",
json_integer(json_integer_value(max_active)));
}
if (!json_object_get(prefs, "save_path"))
json_object_set_new(prefs, "save_path",
json_string(getenv("NAUT_WEBUI_SAVE_PATH")
? getenv("NAUT_WEBUI_SAVE_PATH") : "."));
if (!json_object_get(prefs, "dl_limit"))
json_object_set_new(prefs, "dl_limit", json_integer(0));
if (!json_object_get(prefs, "up_limit"))
json_object_set_new(prefs, "up_limit", json_integer(0));
if (!json_object_get(prefs, "alt_dl_limit"))
json_object_set_new(prefs, "alt_dl_limit", json_integer(0));
if (!json_object_get(prefs, "alt_up_limit"))
json_object_set_new(prefs, "alt_up_limit", json_integer(0));
if (!json_object_get(prefs, "alt_speed_enabled"))
json_object_set_new(prefs, "alt_speed_enabled", json_false());
json_object_set_new(prefs, "max_connec", json_integer(500));
json_object_set_new(prefs, "max_connec_per_torrent", json_integer(100));
json_object_set_new(prefs, "max_uploads", json_integer(20));
json_object_set_new(prefs, "max_active_uploads", json_integer(10));
json_object_set_new(prefs, "max_active_torrents",
json_integer((json_int_t)json_i64_or(
prefs, "max_active_downloads", 5)));
return prefs;
}
static void api_meta(int fd) {
json_t *json = json_object();
json_t *preferences = preferences_json();
if (!json || !preferences) {
json_decref(json);
json_decref(preferences);
http_text(fd, 500, "Internal Server Error", "oom");
return;
}
pthread_mutex_lock(&g_webui.meta_lock);
json_object_set_new(json, "categories",
json_deep_copy(g_webui.categories));
json_object_set_new(json, "tags", json_deep_copy(g_webui.tags));
pthread_mutex_unlock(&g_webui.meta_lock);
json_t *params = json_object();
json_t *torrents = rpc_call_json("torrents", params);
json_decref(params);
json_t *trackers = tracker_summary(torrents);
json_decref(torrents);
json_object_set_new(json, "trackers", trackers ? trackers : json_array());
json_object_set_new(json, "preferences", preferences);
json_object_set_new(json, "searchPlugins", json_array());
http_json(fd, 200, json);
json_decref(json);
}
static void api_plugins(int fd) {
char path[PATH_MAX];
if (!join_root_path(path, sizeof path, "/plugins/plugins.json")) {
http_text(fd, 500, "Internal Server Error", "root too long");
return;
}
json_error_t error;
json_t *manifest = json_load_file(path, JSON_REJECT_DUPLICATES, &error);
json_t *modules = json_array();
if (json_is_object(manifest)) {
json_t *raw = json_object_get(manifest, "modules");
if (json_is_array(raw)) {
size_t index;
json_t *value;
json_array_foreach(raw, index, value) {
const char *module = json_string_value(value);
if (module && strncmp(module, "/plugins/", 9) == 0 &&
strstr(module, ".js"))
json_array_append_new(modules, json_string(module));
}
}
}
json_decref(manifest);
json_t *reply = json_pack("{s:o}", "modules", modules);
http_json(fd, 200, reply);
json_decref(reply);
}
static json_t *read_body_json(const char *body, size_t len) {
if (!body || len == 0) return json_object();
json_error_t error;
json_t *json = json_loadb(body, len, JSON_REJECT_DUPLICATES, &error);
return json ? json : json_object();
}
static const char *path_after(const char *path, const char *prefix) {
size_t len = strlen(prefix);
return strncmp(path, prefix, len) == 0 ? path + len : NULL;
}
static void api_torrent_detail(int fd, const char *tail) {
char hash[64];
size_t n = strcspn(tail, "/?");
snprintf(hash, sizeof hash, "%.*s", (int)n, tail);
json_t *torrent = full_torrent_by_hash(hash);
if (!torrent) {
http_text(fd, 404, "Not Found", "not found");
return;
}
char tab[64] = {0};
if (tail[n] == '/')
snprintf(tab, sizeof tab, "%s", tail + n + 1);
strip_query(tab);
if (strcmp(tab, "trackers") == 0) {
json_t *value = json_incref(json_object_get(torrent, "trackers"));
http_json(fd, 200, value);
json_decref(value);
} else if (strcmp(tab, "peers") == 0) {
json_t *value = json_incref(json_object_get(torrent, "peersList"));
http_json(fd, 200, value);
json_decref(value);
} else if (strcmp(tab, "files") == 0) {
json_t *value = json_incref(json_object_get(torrent, "files"));
http_json(fd, 200, value);
json_decref(value);
} else if (strcmp(tab, "pieces") == 0) {
uint64_t count = json_u64(torrent, "pieceCount");
uint64_t done = json_u64(torrent, "piecesDone");
json_t *states = json_object_get(torrent, "pieceStates");
json_t *pieces = json_is_array(states) ? json_deep_copy(states)
: json_array();
if (pieces && json_array_size(pieces) == 0)
for (uint64_t i = 0; i < count && i < 4000; i++)
json_array_append_new(pieces, json_integer(i < done ? 2 : 0));
json_t *value = json_pack("{s:I,s:I,s:o}",
"pieceSize", json_u64(torrent, "pieceSize"),
"pieceCount", count, "pieces", pieces);
http_json(fd, 200, value);
json_decref(value);
} else {
http_json(fd, 200, torrent);
}
json_decref(torrent);
}
static void api_add(int fd, const char *body, size_t len) {
json_t *req = read_body_json(body, len);
const char *source = json_string_value(json_object_get(req, "source"));
const char *magnet = json_string_value(json_object_get(req, "magnet"));
const char *data = json_string_value(json_object_get(req, "data"));
const char *save_path = json_string_value(json_object_get(req, "savePath"));
/* The UI parses the .torrent client-side and sends a display name; the
* daemon only knows the temp upload path, so we keep the name here. */
const char *display = json_string_value(json_object_get(req, "name"));
char display_name[256] = {0};
if (display && *display)
snprintf(display_name, sizeof display_name, "%s", display);
if (!save_path || !*save_path) save_path = ".";
if (!source) source = magnet;
if ((!source || !*source) && (!data || !*data)) {
/* A request carrying only parsed metadata (name/files) but no bytes is
* the tell-tale of a stale UI that predates base64 upload support. */
bool looks_stale = json_object_get(req, "name") ||
json_object_get(req, "files");
json_decref(req);
http_text(fd, 400, "Bad Request", looks_stale
? "no torrent bytes in request: the page is running an old UI. "
"Hard-reload the browser (Ctrl+Shift+R) and add the file again."
: "send a magnet, a source path, or uploaded torrent bytes");
return;
}
json_t *params = json_object();
json_object_set_new(params, "output", json_string(save_path));
if (source && *source) json_object_set_new(params, "source", json_string(source));
if (data && *data) json_object_set_new(params, "data", json_string(data));
if (json_object_get(req, "paused"))
json_object_set_new(params, "paused",
json_boolean(json_boolean_value(
json_object_get(req, "paused"))));
/* Forward the display name so the daemon persists it for restore. */
if (display_name[0])
json_object_set_new(params, "name", json_string(display_name));
json_t *result = rpc_call_json("add_torrent", params);
json_decref(params);
json_decref(req);
if (!result) {
http_text(fd, 502, "Bad Gateway", "add_torrent failed");
return;
}
uint64_t new_id = json_u64(result, "torrent_id");
if (display_name[0]) store_set_name(new_id, display_name);
char id[32];
snprintf(id, sizeof id, "%llu", (unsigned long long)new_id);
json_t *reply = json_pack("{s:b,s:s}", "ok", 1, "hash", id);
http_json(fd, 200, reply);
json_decref(reply);
json_decref(result);
/* refresh the shared snapshot so the new torrent shows up immediately */
publish_snapshot();
}
static void api_delete(int fd, const char *body, size_t len) {
json_t *req = read_body_json(body, len);
json_t *hashes = json_object_get(req, "hashes");
size_t removed = 0;
if (json_is_array(hashes)) {
size_t index;
json_t *hash;
json_array_foreach(hashes, index, hash) {
uint64_t id = 0;
if (!parse_id(json_string_value(hash), &id)) continue;
json_t *params = json_pack("{s:I}", "torrent_id", (json_int_t)id);
json_t *result = rpc_call_json("remove_torrent", params);
json_decref(params);
if (result) {
removed++;
store_forget(id);
json_decref(result);
}
}
}
json_decref(req);
json_t *reply = json_pack("{s:b,s:i}", "ok", 1, "removed", (int)removed);
http_json(fd, 200, reply);
json_decref(reply);
if (removed) publish_snapshot();
}
static bool rpc_for_torrent(const char *method, uint64_t id, json_t *extra) {
json_t *params = json_object();
if (!params) return false;
json_object_set_new(params, "torrent_id", json_integer((json_int_t)id));
if (json_is_object(extra)) {
const char *key;
json_t *value;
json_object_foreach(extra, key, value)
json_object_set(params, key, value);
}
json_t *result = rpc_call_json(method, params);
json_decref(params);
if (!result) return false;
json_decref(result);
return true;
}
static const char *queue_op_for_action(const char *action) {
if (strcmp(action, "topPriority") == 0) return "top";
if (strcmp(action, "bottomPriority") == 0) return "bottom";
if (strcmp(action, "increasePriority") == 0) return "up";
if (strcmp(action, "decreasePriority") == 0) return "down";
return NULL;
}
/* Category/tag assignment is web-layer state. Engine-backed verbs delegate to
* nautd RPCs so toolbar actions mutate the real queue/lifecycle state. */
static void api_action(int fd, const char *body, size_t len) {
json_t *req = read_body_json(body, len);
const char *raw_action = json_string_value(json_object_get(req, "action"));
char action[64];
snprintf(action, sizeof action, "%s", raw_action ? raw_action : "");
json_t *hashes = json_object_get(req, "hashes");
json_t *params = json_object_get(req, "params");
bool handled = false;
int affected = 0;
if (json_is_array(hashes) &&
(strcmp(action, "setCategory") == 0 ||
strcmp(action, "addTags") == 0 ||
strcmp(action, "removeTags") == 0)) {
size_t index;
json_t *hash;
json_array_foreach(hashes, index, hash) {
uint64_t id = 0;
if (!parse_id(json_string_value(hash), &id)) continue;
if (strcmp(action, "setCategory") == 0)
store_set_category(id,
json_string_value(json_object_get(params, "category")));
else
store_update_tags(id, json_object_get(params, "tags"),
strcmp(action, "addTags") == 0);
webui_sync_labels(id); /* mirror to the daemon (persist + Lua) */
affected++;
}
handled = true;
}
if (json_is_array(hashes) && !handled) {
const char *rpc = NULL;
json_t *extra = NULL;
if (strcmp(action, "pause") == 0) {
rpc = "pause_torrent";
} else if (strcmp(action, "resume") == 0) {
rpc = "resume_torrent";
} else if (strcmp(action, "forceStart") == 0) {
rpc = "resume_torrent";
extra = json_pack("{s:b}", "force", 1);
} else if (strcmp(action, "recheck") == 0) {
rpc = "recheck_torrent";
} else if (strcmp(action, "setSavePath") == 0) {
const char *sp =
json_string_value(json_object_get(params, "savePath"));
if (sp && *sp) {
rpc = "set_save_path";
extra = json_pack("{s:s,s:b}", "savePath", sp, "reset",
json_boolean_value(
json_object_get(params, "reset")));
}
} else {
const char *op = queue_op_for_action(action);
if (op) {
rpc = "queue_move";
extra = json_pack("{s:s}", "op", op);
}
}
if (rpc) {
size_t index;
json_t *hash;
json_array_foreach(hashes, index, hash) {
uint64_t id = 0;
if (!parse_id(json_string_value(hash), &id)) continue;
if (rpc_for_torrent(rpc, id, extra)) affected++;
}
json_decref(extra);
handled = true;
}
}
json_decref(req);
if (handled) {
json_t *json = json_pack("{s:b,s:i}", "ok", 1, "affected", affected);
http_json(fd, 200, json);
json_decref(json);
publish_snapshot();
return;
}
char message[128];
snprintf(message, sizeof message,
"action '%s' is not supported by the engine", action);
json_t *json = json_pack("{s:b,s:s}", "ok", 0, "error", message);
http_json(fd, 501, json);
json_decref(json);
}
static void api_preferences(int fd, const char *method,
const char *body, size_t len) {
if (strcmp(method, "GET") == 0) {
json_t *prefs = preferences_json();
if (!prefs) {
http_text(fd, 502, "Bad Gateway", "get_preferences failed");
return;
}
http_json(fd, 200, prefs);
json_decref(prefs);
return;
}
if (strcmp(method, "POST") != 0) {
http_text(fd, 405, "Method Not Allowed", "method not allowed");
return;
}
json_t *req = read_body_json(body, len);
json_t *params = json_object();
if (!req || !params) {
json_decref(req);
json_decref(params);
http_text(fd, 500, "Internal Server Error", "oom");
return;
}
const char *keys[] = {
"dl_limit", "up_limit", "alt_dl_limit", "alt_up_limit",
"alt_speed_enabled", "max_active"
};
for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) {
json_t *v = json_object_get(req, keys[i]);
if (v) json_object_set(params, keys[i], v);
}
json_t *max = json_object_get(req, "max_active_downloads");
if (max) json_object_set(params, "max_active", max);
json_t *result = rpc_call_json("set_preferences", params);
json_decref(params);
json_decref(req);
if (!json_is_object(result)) {
json_decref(result);
http_text(fd, 502, "Bad Gateway", "set_preferences failed");
return;
}
json_decref(result);
json_t *prefs = preferences_json();
http_json(fd, 200, prefs);
json_decref(prefs);
publish_snapshot();
}
static void api_altspeed(int fd) {
json_t *params = json_object();
json_t *result = rpc_call_json("toggle_altspeed", params);
json_decref(params);
if (!json_is_object(result)) {
json_decref(result);
http_text(fd, 502, "Bad Gateway", "toggle_altspeed failed");
return;
}
http_json(fd, 200, result);
json_decref(result);
publish_snapshot();
}
/* POST /api/script/settings — persist user-edited script setting values. Body:
* { "settings": { "<key>": "<value>", ... } }. */
static void api_script_settings(int fd, const char *method, const char *body,
size_t len) {
if (strcmp(method, "POST") != 0) {
http_text(fd, 405, "Method Not Allowed", "method not allowed");
return;
}
json_t *req = read_body_json(body, len);
json_t *settings = req ? json_object_get(req, "settings") : NULL;
if (!json_is_object(settings)) {
json_decref(req);
http_text(fd, 400, "Bad Request", "missing settings object");
return;
}
json_t *params = json_object();
json_object_set(params, "settings", settings);
json_decref(req);
json_t *result = rpc_call_json("set_script_settings", params);
json_decref(params);
if (!json_is_object(result)) {
json_decref(result);
http_text(fd, 502, "Bad Gateway", "set_script_settings failed");
return;
}
http_json(fd, 200, result);
json_decref(result);
}
static void api_script(int fd, const char *method, const char *body, size_t len) {
json_t *params = NULL;
json_t *result = NULL;
const char *rpc_name = "script_status";
if (strcmp(method, "GET") == 0) {
params = json_object();
result = rpc_call_json("script_status", params);
} else if (strcmp(method, "POST") == 0) {
rpc_name = "update_script";
json_t *req = read_body_json(body, len);
const char *source = json_string_value(json_object_get(req, "source"));
if (!source) {
json_decref(req);
http_text(fd, 400, "Bad Request", "missing source");
return;
}
params = json_object();
json_object_set(params, "source", json_object_get(req, "source"));
json_decref(req);
result = rpc_call_json("update_script", params);
} else {
http_text(fd, 405, "Method Not Allowed", "method not allowed");
return;
}
json_decref(params);
if (!json_is_object(result)) {
json_decref(result);
char msg[96];
snprintf(msg, sizeof msg, "%s failed", rpc_name);
http_text(fd, 502, "Bad Gateway", msg);
return;
}
http_json(fd, 200, result);
json_decref(result);
}
/* POST /api/categories and /api/categories/delete */
static void api_categories(int fd, const char *body, size_t len, bool remove) {
json_t *req = read_body_json(body, len);
const char *name = json_string_value(json_object_get(req, "name"));
if (name && *name) {
if (remove) { store_remove_category(name); webui_sync_all_labels(); }
else store_add_category(name,
json_string_value(json_object_get(req, "savePath")));
webui_sync_taxonomy(); /* persist the category list via the daemon */
}
json_decref(req);
pthread_mutex_lock(&g_webui.meta_lock);
json_t *reply = json_deep_copy(g_webui.categories);
pthread_mutex_unlock(&g_webui.meta_lock);
http_json(fd, 200, reply);
json_decref(reply);
}
/* POST /api/tags and /api/tags/delete */
static void api_tags(int fd, const char *body, size_t len, bool remove) {
json_t *req = read_body_json(body, len);
const char *name = json_string_value(json_object_get(req, "name"));
if (name && *name) {
if (remove) { store_remove_tag(name); webui_sync_all_labels(); }
else store_add_tag(name);
webui_sync_taxonomy(); /* persist the tag list via the daemon */
}
json_decref(req);
pthread_mutex_lock(&g_webui.meta_lock);
json_t *reply = json_deep_copy(g_webui.tags);
pthread_mutex_unlock(&g_webui.meta_lock);
http_json(fd, 200, reply);
json_decref(reply);
}
static void api_stream(int fd) {
const char *head =
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n"
"Cache-Control: no-cache\r\nConnection: keep-alive\r\n\r\n"
"retry: 2000\n\n";
if (!send_all_fd(fd, head, strlen(head))) return;
uint64_t seen = 0;
while (!atomic_load(&g_webui.stopping)) {
char *payload = NULL;
pthread_mutex_lock(&g_webui.snap_lock);
while (!atomic_load(&g_webui.stopping) && g_webui.snap_seq == seen) {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_nsec += 250 * 1000 * 1000;
if (ts.tv_nsec >= 1000000000) { ts.tv_sec++; ts.tv_nsec -= 1000000000; }
pthread_cond_timedwait(&g_webui.snap_cond, &g_webui.snap_lock, &ts);
}
if (!atomic_load(&g_webui.stopping) && g_webui.snapshot_str) {
payload = strdup(g_webui.snapshot_str);
seen = g_webui.snap_seq;
}
pthread_mutex_unlock(&g_webui.snap_lock);
if (!payload) break;
bool ok = send_all_fd(fd, "event: snapshot\ndata: ", 22) &&
send_all_fd(fd, payload, strlen(payload)) &&
send_all_fd(fd, "\n\n", 2);
free(payload);
if (!ok) break;
}
}
static void serve_cached_snapshot(int fd) {
pthread_mutex_lock(&g_webui.snap_lock);
char *copy = g_webui.snapshot_str ? strdup(g_webui.snapshot_str) : NULL;
pthread_mutex_unlock(&g_webui.snap_lock);
http_json_str(fd, copy, "{\"server\":{},\"torrents\":[]}");
free(copy);
}
static void serve_cached_torrents(int fd) {
pthread_mutex_lock(&g_webui.snap_lock);
char *copy = g_webui.torrents_str ? strdup(g_webui.torrents_str) : NULL;
pthread_mutex_unlock(&g_webui.snap_lock);
http_json_str(fd, copy, "[]");
free(copy);
}
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_preferences(fd, method, body, body_len);
} else if (strcmp(path, "/api/altspeed") == 0 && strcmp(method, "POST") == 0) {
api_altspeed(fd);
} else if (strcmp(path, "/api/script/settings") == 0) {
api_script_settings(fd, method, body, body_len);
} else if (strcmp(path, "/api/script") == 0) {
api_script(fd, method, body, body_len);
} else if (strcmp(path, "/api/categories") == 0 &&
strcmp(method, "POST") == 0) {
api_categories(fd, body, body_len, false);
} else if (strcmp(path, "/api/categories/delete") == 0 &&
strcmp(method, "POST") == 0) {
api_categories(fd, body, body_len, true);
} else if (strcmp(path, "/api/tags") == 0 &&
strcmp(method, "POST") == 0) {
api_tags(fd, body, body_len, false);
} else if (strcmp(path, "/api/tags/delete") == 0 &&
strcmp(method, "POST") == 0) {
api_tags(fd, body, body_len, true);
} else if (strcmp(path, "/api/torrents") == 0 && strcmp(method, "GET") == 0) {
serve_cached_torrents(fd);
} else if (path_after(path, "/api/torrents/") && strcmp(method, "GET") == 0) {
api_torrent_detail(fd, path_after(path, "/api/torrents/"));
} else if (strcmp(path, "/api/add") == 0 && strcmp(method, "POST") == 0) {
api_add(fd, body, body_len);
} else if (strcmp(path, "/api/delete") == 0 && strcmp(method, "POST") == 0) {
api_delete(fd, body, body_len);
} else if (strcmp(path, "/api/action") == 0 && strcmp(method, "POST") == 0) {
api_action(fd, body, body_len);
} else if (strncmp(path, "/api/rss", 8) == 0 && strcmp(method, "GET") == 0) {
json_t *json = json_array();
http_json(fd, 200, json);
json_decref(json);
} else if (strncmp(path, "/api/search", 11) == 0 && strcmp(method, "GET") == 0) {
json_t *json = json_array();
http_json(fd, 200, json);
json_decref(json);
} else {
http_text(fd, 404, "Not Found", "not found");
}
}
static void handle_conn(int fd) {
char *request = malloc(READ_LIMIT + 1);
if (!request) {
http_text(fd, 500, "Internal Server Error", "oom");
return;
}
size_t len = 0;
char *hdrend = NULL;
while (len < READ_LIMIT) {
ssize_t n = recv(fd, request + len, READ_LIMIT - len, 0);
if (n < 0) {
if (errno == EINTR) continue;
free(request);
return;
}
if (n == 0) break;
len += (size_t)n;
request[len] = 0;
hdrend = memmem(request, len, "\r\n\r\n", 4);
if (hdrend) break;
}
if (!hdrend) {
free(request);
http_text(fd, 400, "Bad Request", "malformed request");
return;
}
char method[8] = {0};
char path[PATH_MAX] = {0};
if (sscanf(request, "%7s %4095s", method, path) != 2) {
free(request);
http_text(fd, 400, "Bad Request", "malformed request line");
return;
}
size_t header_len = (size_t)(hdrend - request) + 4;
size_t content_length = 0;
char *cl = strcasestr(request, "content-length:");
if (cl && cl < hdrend) content_length = strtoull(cl + 15, NULL, 10);
/* Reject bodies we can't buffer instead of silently truncating an upload
* into a corrupt torrent. */
if (content_length > READ_LIMIT - header_len) {
free(request);
http_text(fd, 413, "Payload Too Large",
"request body exceeds the 8 MiB limit");
return;
}
while (len - header_len < content_length && len < READ_LIMIT) {
ssize_t n = recv(fd, request + len, READ_LIMIT - len, 0);
if (n < 0) {
if (errno == EINTR) continue;
break;
}
if (n == 0) break;
len += (size_t)n;
request[len] = 0;
}
char *body = request + header_len;
size_t body_len = len > header_len ? len - header_len : 0;
if (strncmp(path, "/api/", 5) == 0)
handle_api(fd, method, path, request, hdrend, body, body_len);
else if (!serve_file(fd, path))
http_text(fd, 404, "Not Found", "not found");
free(request);
}
static void finish_connection(void) {
pthread_mutex_lock(&g_webui.conn_lock);
if (g_webui.active_connections > 0) g_webui.active_connections--;
pthread_cond_signal(&g_webui.conn_cond);
pthread_mutex_unlock(&g_webui.conn_lock);
}
static void *conn_thread(void *arg) {
conn_arg *conn = arg;
handle_conn(conn->fd);
close(conn->fd);
free(conn);
finish_connection();
return NULL;
}
static void *server_thread(void *arg) {
(void)arg;
for (;;) {
int fd = accept(g_webui.listener, NULL, NULL);
if (fd < 0) {
if (errno == EINTR) continue;
if (atomic_load(&g_webui.stopping)) break;
continue;
}
struct timeval timeout = { .tv_sec = 5, .tv_usec = 0 };
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof timeout);
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof timeout);
/* Bound concurrent connections so a client can't spawn unlimited
* threads (each SSE stream parks one). */
pthread_mutex_lock(&g_webui.conn_lock);
bool full = g_webui.active_connections >= MAX_CONNECTIONS;
if (!full) g_webui.active_connections++;
pthread_mutex_unlock(&g_webui.conn_lock);
if (full) {
http_text(fd, 503, "Service Unavailable", "too many connections");
close(fd);
continue;
}
conn_arg *conn = malloc(sizeof(*conn));
if (!conn) {
close(fd);
finish_connection();
continue;
}
conn->fd = fd;
pthread_t thread;
if (pthread_create(&thread, NULL, conn_thread, conn) != 0) {
close(fd);
free(conn);
finish_connection();
continue;
}
pthread_detach(thread);
}
return NULL;
}
static bool dir_exists(const char *path) {
struct stat st;
return path && stat(path, &st) == 0 && S_ISDIR(st.st_mode);
}
static const char *find_root(void) {
const char *env = getenv("NAUT_WEBUI_ROOT");
if (dir_exists(env)) return env;
static const char *candidates[] = {
"../torrent-ui/public",
"torrent-ui/public",
"./public",
"/usr/share/naut/torrent-ui/public",
};
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++)
if (dir_exists(candidates[i])) return candidates[i];
return NULL;
}
static int parse_port(void) {
const char *env = getenv("NAUT_WEBUI_PORT");
if (!env || !*env) return DEFAULT_PORT;
char *end = NULL;
long port = strtol(env, &end, 10);
return end && !*end && port > 0 && port <= 65535 ? (int)port : DEFAULT_PORT;
}
static naut_err start_server(void) {
const char *root = find_root();
if (!root) {
log_msg(0, "webui: could not find torrent-ui public assets; set NAUT_WEBUI_ROOT");
return NAUT_ERR_NOTFOUND;
}
snprintf(g_webui.root, sizeof g_webui.root, "%s", root);
const char *host = getenv("NAUT_WEBUI_HOST");
if (!host || !*host || strcmp(host, "localhost") == 0) host = DEFAULT_HOST;
snprintf(g_webui.host_name, sizeof g_webui.host_name, "%s", host);
g_webui.port = parse_port();
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return NAUT_ERR_IO;
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
struct sockaddr_in addr;
memset(&addr, 0, sizeof addr);
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)g_webui.port);
if (inet_pton(AF_INET, g_webui.host_name, &addr.sin_addr) != 1) {
close(fd);
return NAUT_ERR_INVAL;
}
if (bind(fd, (struct sockaddr *)&addr, sizeof addr) != 0 ||
listen(fd, 64) != 0) {
close(fd);
return NAUT_ERR_IO;
}
g_webui.listener = fd;
/* Prime the cache so the first request doesn't see an empty snapshot. */
publish_snapshot();
if (pthread_create(&g_webui.sampler, NULL, sampler_thread, NULL) != 0) {
close(fd);
g_webui.listener = -1;
return NAUT_ERR_NOMEM;
}
g_webui.sampler_started = true;
if (pthread_create(&g_webui.thread, NULL, server_thread, NULL) != 0) {
atomic_store(&g_webui.stopping, true);
pthread_join(g_webui.sampler, NULL);
g_webui.sampler_started = false;
close(fd);
g_webui.listener = -1;
return NAUT_ERR_NOMEM;
}
g_webui.thread_started = true;
char msg[PATH_MAX + 128];
snprintf(msg, sizeof msg, "webui: serving http://%s:%d from %s",
g_webui.host_name, g_webui.port, g_webui.root);
log_msg(2, msg);
if (strcmp(g_webui.host_name, DEFAULT_HOST) != 0)
log_msg(1, "webui: bound to a non-loopback address; credentials cross "
"the network in plaintext (set NAUT_AUTH_PASSWORD)");
snprintf(msg, sizeof msg, "webui: auth user %s", g_webui.auth_user);
log_msg(2, msg);
if (g_webui.generated_password && g_webui.auth_password[0]) {
snprintf(msg, sizeof msg, "webui: generated password %s",
g_webui.auth_password);
log_msg(1, msg);
} else if (!g_webui.auth_password[0]) {
log_msg(0, "webui: no password available (CSPRNG unavailable); set "
"NAUT_AUTH_PASSWORD to enable login");
}
return NAUT_OK;
}
naut_err naut_plugin_register(const naut_host_api *host) {
if (!host || host->abi_version != NAUT_PLUGIN_ABI_VERSION ||
host->struct_size < sizeof(*host) || !host->call_rpc)
return NAUT_ERR_INVAL;
naut_err error = NAUT_ERR_NOMEM;
memset(&g_webui, 0, sizeof g_webui);
g_webui.listener = -1;
g_webui.host = *host;
if (pthread_mutex_init(&g_webui.auth_lock, NULL) != 0)
return NAUT_ERR_NOMEM;
if (pthread_mutex_init(&g_webui.conn_lock, NULL) != 0)
goto fail_conn_lock;
if (pthread_cond_init(&g_webui.conn_cond, NULL) != 0)
goto fail_conn_cond;
if (pthread_mutex_init(&g_webui.snap_lock, NULL) != 0)
goto fail_snap_lock;
if (pthread_cond_init(&g_webui.snap_cond, NULL) != 0)
goto fail_snap_cond;
if (pthread_mutex_init(&g_webui.speed_lock, NULL) != 0)
goto fail_speed_lock;
if (pthread_mutex_init(&g_webui.meta_lock, NULL) != 0)
goto fail_meta_lock;
g_webui.categories = json_array();
g_webui.tags = json_array();
g_webui.assignments = json_object();
if (!g_webui.categories || !g_webui.tags || !g_webui.assignments)
goto fail_store;
init_auth();
error = g_webui.host.set_plugin_name(g_webui.host.host_context,
"webui");
if (error != NAUT_OK) goto fail_store;
error = start_server();
if (error != NAUT_OK) goto fail_store;
webui_load_taxonomy(); /* restore category + tag lists from the daemon */
return NAUT_OK;
fail_store:
json_decref(g_webui.categories);
json_decref(g_webui.tags);
json_decref(g_webui.assignments);
pthread_mutex_destroy(&g_webui.meta_lock);
fail_meta_lock:
pthread_mutex_destroy(&g_webui.speed_lock);
fail_speed_lock:
pthread_cond_destroy(&g_webui.snap_cond);
fail_snap_cond:
pthread_mutex_destroy(&g_webui.snap_lock);
fail_snap_lock:
pthread_cond_destroy(&g_webui.conn_cond);
fail_conn_cond:
pthread_mutex_destroy(&g_webui.conn_lock);
fail_conn_lock:
pthread_mutex_destroy(&g_webui.auth_lock);
return error;
}
naut_err naut_plugin_shutdown(void) {
atomic_store(&g_webui.stopping, true);
/* wake any SSE streams parked on the snapshot condition */
pthread_mutex_lock(&g_webui.snap_lock);
pthread_cond_broadcast(&g_webui.snap_cond);
pthread_mutex_unlock(&g_webui.snap_lock);
if (g_webui.listener >= 0) {
shutdown(g_webui.listener, SHUT_RDWR);
close(g_webui.listener);
g_webui.listener = -1;
}
if (g_webui.thread_started)
pthread_join(g_webui.thread, NULL);
g_webui.thread_started = false;
if (g_webui.sampler_started)
pthread_join(g_webui.sampler, NULL);
g_webui.sampler_started = false;
pthread_mutex_lock(&g_webui.conn_lock);
while (g_webui.active_connections > 0)
pthread_cond_wait(&g_webui.conn_cond, &g_webui.conn_lock);
pthread_mutex_unlock(&g_webui.conn_lock);
free(g_webui.snapshot_str);
free(g_webui.torrents_str);
g_webui.snapshot_str = NULL;
g_webui.torrents_str = NULL;
json_decref(g_webui.categories);
json_decref(g_webui.tags);
json_decref(g_webui.assignments);
g_webui.categories = NULL;
g_webui.tags = NULL;
g_webui.assignments = NULL;
pthread_mutex_destroy(&g_webui.meta_lock);
pthread_mutex_destroy(&g_webui.speed_lock);
pthread_cond_destroy(&g_webui.snap_cond);
pthread_mutex_destroy(&g_webui.snap_lock);
pthread_cond_destroy(&g_webui.conn_cond);
pthread_mutex_destroy(&g_webui.conn_lock);
pthread_mutex_destroy(&g_webui.auth_lock);
return NAUT_OK;
}