webui: fix add-torrent crash, add categories/tags, real names

The daemon segfaulted the moment any torrent existed: map_torrent built
its JSON with one 30-key json_pack whose format string had drifted out of
sync with the argument list, so json_pack misread an int as a char* and
crashed in the next snapshot build (an empty fleet hid it). Rebuild the
object field-by-field with json_object_set_new so it can't drift again.

Add a web-layer category/tag store (in memory, like qBittorrent's own Web
API) so the UI can actually create categories and tags and assign them:
- /api/categories[/delete] and /api/tags[/delete] persist and return them
- /api/meta returns the stored categories/tags
- /api/action handles setCategory/addTags/removeTags (still 501 for
  engine-level verbs the daemon can't do)
- map_torrent fills each torrent's category/tags from the store

Uploaded torrents kept their temp upload path as the display name; keep
the name the UI sends at add time and prefer it in the grid.

Fix a use-after-free in api_action that read the action string after
freeing the request, which corrupted the error body into a 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-17 00:43:26 -04:00
parent 41ed172272
commit f4f4e86be4

View file

@ -81,6 +81,14 @@ typedef struct {
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;
@ -580,6 +588,158 @@ static json_int_t compute_eta(uint64_t done, uint64_t total, double dlspeed) {
return ETA_INFINITY;
}
/* ---- category / tag store (web-layer owned, guarded by meta_lock) ---- */
static int find_category(const char *name) {
size_t index;
json_t *value;
json_array_foreach(g_webui.categories, index, value)
if (strcmp(json_string_or(value, "name", ""), name) == 0)
return (int)index;
return -1;
}
static int find_tag(const char *name) {
size_t index;
json_t *value;
json_array_foreach(g_webui.tags, index, value)
if (strcmp(json_string_value(value), name) == 0) return (int)index;
return -1;
}
static void store_add_category(const char *name, const char *save_path) {
pthread_mutex_lock(&g_webui.meta_lock);
if (find_category(name) < 0)
json_array_append_new(g_webui.categories, json_pack(
"{s:s,s:s}", "name", name, "savePath", save_path ? save_path : ""));
pthread_mutex_unlock(&g_webui.meta_lock);
}
static void store_remove_category(const char *name) {
pthread_mutex_lock(&g_webui.meta_lock);
int index = find_category(name);
if (index >= 0) json_array_remove(g_webui.categories, (size_t)index);
/* drop the category from any torrent that had it */
const char *key;
json_t *entry;
json_object_foreach(g_webui.assignments, key, entry)
if (strcmp(json_string_or(entry, "category", ""), name) == 0)
json_object_set_new(entry, "category", json_string(""));
pthread_mutex_unlock(&g_webui.meta_lock);
}
static void store_add_tag(const char *name) {
pthread_mutex_lock(&g_webui.meta_lock);
if (find_tag(name) < 0)
json_array_append_new(g_webui.tags, json_string(name));
pthread_mutex_unlock(&g_webui.meta_lock);
}
static void store_remove_tag(const char *name) {
pthread_mutex_lock(&g_webui.meta_lock);
int index = find_tag(name);
if (index >= 0) json_array_remove(g_webui.tags, (size_t)index);
const char *key;
json_t *entry;
json_object_foreach(g_webui.assignments, key, entry) {
json_t *tags = json_object_get(entry, "tags");
size_t i = 0;
while (i < json_array_size(tags)) {
if (strcmp(json_string_value(json_array_get(tags, i)), name) == 0)
json_array_remove(tags, i);
else
i++;
}
}
pthread_mutex_unlock(&g_webui.meta_lock);
}
static json_t *assignment_locked(uint64_t id, bool create) {
char key[32];
snprintf(key, sizeof key, "%llu", (unsigned long long)id);
json_t *entry = json_object_get(g_webui.assignments, key);
if (!entry && create) {
entry = json_pack("{s:s,s:o}", "category", "", "tags", json_array());
json_object_set_new(g_webui.assignments, key, entry);
}
return entry;
}
static void store_set_category(uint64_t id, const char *category) {
pthread_mutex_lock(&g_webui.meta_lock);
json_t *entry = assignment_locked(id, true);
if (entry)
json_object_set_new(entry, "category",
json_string(category ? category : ""));
pthread_mutex_unlock(&g_webui.meta_lock);
}
static void store_update_tags(uint64_t id, json_t *tags, bool add) {
if (!json_is_array(tags)) return;
pthread_mutex_lock(&g_webui.meta_lock);
json_t *entry = assignment_locked(id, true);
json_t *have = entry ? json_object_get(entry, "tags") : NULL;
if (have) {
size_t index;
json_t *value;
json_array_foreach(tags, index, value) {
const char *name = json_string_value(value);
if (!name) continue;
size_t pos = 0;
bool present = false;
for (; pos < json_array_size(have); pos++)
if (strcmp(json_string_value(json_array_get(have, pos)),
name) == 0) { present = true; break; }
if (add && !present)
json_array_append_new(have, json_string(name));
else if (!add && present)
json_array_remove(have, pos);
}
}
pthread_mutex_unlock(&g_webui.meta_lock);
}
static void store_set_name(uint64_t id, const char *name) {
pthread_mutex_lock(&g_webui.meta_lock);
json_t *entry = assignment_locked(id, true);
if (entry) json_object_set_new(entry, "name", json_string(name));
pthread_mutex_unlock(&g_webui.meta_lock);
}
static bool store_get_name(uint64_t id, char *out, size_t out_size) {
bool found = false;
pthread_mutex_lock(&g_webui.meta_lock);
json_t *entry = assignment_locked(id, false);
const char *name = entry ? json_string_value(json_object_get(entry, "name"))
: NULL;
if (name && *name) {
snprintf(out, out_size, "%s", name);
found = true;
}
pthread_mutex_unlock(&g_webui.meta_lock);
return found;
}
static void store_forget(uint64_t id) {
char key[32];
snprintf(key, sizeof key, "%llu", (unsigned long long)id);
pthread_mutex_lock(&g_webui.meta_lock);
json_object_del(g_webui.assignments, key);
pthread_mutex_unlock(&g_webui.meta_lock);
}
/* Fill in category + tags for a torrent from the assignment store. */
static void apply_assignment(json_t *out, uint64_t id) {
pthread_mutex_lock(&g_webui.meta_lock);
json_t *entry = assignment_locked(id, false);
const char *category = entry ? json_string_or(entry, "category", "") : "";
json_t *tags = entry ? json_object_get(entry, "tags") : NULL;
json_object_set_new(out, "category", json_string(category));
json_object_set_new(out, "tags",
tags ? json_deep_copy(tags) : json_array());
pthread_mutex_unlock(&g_webui.meta_lock);
}
static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) {
uint64_t id = json_u64(torrent, "torrent_id");
uint64_t done = 0, total = 0;
@ -591,6 +751,13 @@ static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) {
snprintf(hash, sizeof hash, "%llu", (unsigned long long)id);
char *name = torrent_name(torrent);
if (!name) return NULL;
/* Prefer the display name the UI captured at add time over the daemon's
* temp upload path. */
char override[256];
if (store_get_name(id, override, sizeof override)) {
char *better = strdup(override);
if (better) { free(name); name = better; }
}
const char *state = ui_state(json_string_value(json_object_get(torrent,
"state")),
progress);
@ -618,40 +785,57 @@ static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) {
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) {
uint64_t discovered = json_u64(torrent, "peers_discovered");
const char *output = json_string_or(torrent, "output", "");
/* Built field-by-field on purpose: a single 30-key json_pack drifts out of
* sync with its argument list silently and then crashes on a type mismatch. */
json_t *out = json_object();
if (!out) {
free(name);
if (detail) {
json_decref(trackers);
json_decref(files);
json_decref(peers_list);
json_decref(hosts);
}
return NULL;
}
json_object_set_new(out, "hash", json_string(hash));
json_object_set_new(out, "name", json_string(name));
json_object_set_new(out, "size", json_integer((json_int_t)total));
json_object_set_new(out, "progress", json_real(progress));
json_object_set_new(out, "dlspeed", json_integer((json_int_t)dlspeed));
json_object_set_new(out, "upspeed", json_integer(0));
json_object_set_new(out, "eta", json_integer(compute_eta(done, total, dlspeed)));
json_object_set_new(out, "seeds",
json_integer((json_int_t)json_u64(torrent, "peers")));
json_object_set_new(out, "seedsTotal", json_integer((json_int_t)discovered));
json_object_set_new(out, "peers",
json_integer((json_int_t)json_u64(torrent, "peers_connecting")));
json_object_set_new(out, "peersTotal", json_integer((json_int_t)discovered));
json_object_set_new(out, "ratio", json_real(0.0));
json_object_set_new(out, "savePath", json_string(output));
json_object_set_new(out, "addedOn", json_integer(0));
json_object_set_new(out, "completionOn",
json_integer(progress >= 1.0 ? 0 : -1));
json_object_set_new(out, "lastActivity", json_integer(0));
json_object_set_new(out, "downloaded", json_integer((json_int_t)done));
json_object_set_new(out, "uploaded", json_integer(0));
json_object_set_new(out, "availability", json_real(1.0));
json_object_set_new(out, "priority", json_integer(1));
json_object_set_new(out, "trackerHosts", hosts ? hosts : json_array());
json_object_set_new(out, "seqDl", json_false());
json_object_set_new(out, "superSeeding", json_false());
json_object_set_new(out, "forceStart", json_false());
json_object_set_new(out, "timeActive",
json_integer((json_int_t)json_u64(torrent, "elapsed_seconds")));
json_object_set_new(out, "pieceSize",
json_integer(pieces ? (json_int_t)(total / pieces) : 0));
json_object_set_new(out, "state", json_string(state));
json_object_set_new(out, "contentPath", json_string(output));
/* category + tags come from the web-layer assignment store */
apply_assignment(out, id);
if (detail) {
json_object_set_new(out, "comment", json_string(""));
json_object_set_new(out, "createdBy", json_string("Naut"));
json_object_set_new(out, "creationDate", json_integer(0));
@ -663,11 +847,6 @@ static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) {
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;
@ -791,8 +970,11 @@ static void api_meta(int fd) {
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());
pthread_mutex_lock(&g_webui.meta_lock);
json_object_set_new(json, "categories",
json_deep_copy(g_webui.categories));
json_object_set_new(json, "tags", json_deep_copy(g_webui.tags));
pthread_mutex_unlock(&g_webui.meta_lock);
json_object_set_new(json, "trackers", json_array());
json_object_set_new(preferences, "save_path",
json_string(getenv("NAUT_WEBUI_SAVE_PATH")
@ -894,6 +1076,12 @@ static void api_add(int fd, const char *body, size_t len) {
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)) {
@ -913,9 +1101,10 @@ static void api_add(int fd, const char *body, size_t len) {
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)json_u64(result, "torrent_id"));
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);
@ -939,6 +1128,7 @@ static void api_delete(int fd, const char *body, size_t len) {
json_decref(params);
if (result) {
removed++;
store_forget(id);
json_decref(result);
}
}
@ -950,22 +1140,85 @@ static void api_delete(int fd, const char *body, size_t len) {
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. */
/* Category/tag assignment is web-layer state the plugin owns, so those verbs
* are honored here. Engine-level verbs (pause/resume/recheck/queue/rate
* limits) have no daemon support yet, so we return 501 instead of pretending
* they worked; the front end shows that as an honest "Action failed" toast. */
static void api_action(int fd, const char *body, size_t len) {
json_t *req = read_body_json(body, len);
const char *action = json_string_value(json_object_get(req, "action"));
const char *raw_action = json_string_value(json_object_get(req, "action"));
char action[64];
snprintf(action, sizeof action, "%s", raw_action ? raw_action : "");
json_t *hashes = json_object_get(req, "hashes");
json_t *params = json_object_get(req, "params");
bool handled = false;
if (json_is_array(hashes) &&
(strcmp(action, "setCategory") == 0 ||
strcmp(action, "addTags") == 0 ||
strcmp(action, "removeTags") == 0)) {
size_t index;
json_t *hash;
json_array_foreach(hashes, index, hash) {
uint64_t id = 0;
if (!parse_id(json_string_value(hash), &id)) continue;
if (strcmp(action, "setCategory") == 0)
store_set_category(id,
json_string_value(json_object_get(params, "category")));
else
store_update_tags(id, json_object_get(params, "tags"),
strcmp(action, "addTags") == 0);
}
handled = true;
}
json_decref(req);
if (handled) {
json_t *json = json_pack("{s:b}", "ok", 1);
http_json(fd, 200, json);
json_decref(json);
publish_snapshot();
return;
}
char message[128];
snprintf(message, sizeof message,
"action '%s' is not supported by the engine",
action ? action : "");
json_decref(req);
"action '%s' is not supported by the engine", action);
json_t *json = json_pack("{s:b,s:s}", "ok", 0, "error", message);
http_json(fd, 501, json);
json_decref(json);
}
/* POST /api/categories and /api/categories/delete */
static void api_categories(int fd, const char *body, size_t len, bool remove) {
json_t *req = read_body_json(body, len);
const char *name = json_string_value(json_object_get(req, "name"));
if (name && *name) {
if (remove) store_remove_category(name);
else store_add_category(name,
json_string_value(json_object_get(req, "savePath")));
}
json_decref(req);
pthread_mutex_lock(&g_webui.meta_lock);
json_t *reply = json_deep_copy(g_webui.categories);
pthread_mutex_unlock(&g_webui.meta_lock);
http_json(fd, 200, reply);
json_decref(reply);
}
/* POST /api/tags and /api/tags/delete */
static void api_tags(int fd, const char *body, size_t len, bool remove) {
json_t *req = read_body_json(body, len);
const char *name = json_string_value(json_object_get(req, "name"));
if (name && *name) {
if (remove) store_remove_tag(name);
else store_add_tag(name);
}
json_decref(req);
pthread_mutex_lock(&g_webui.meta_lock);
json_t *reply = json_deep_copy(g_webui.tags);
pthread_mutex_unlock(&g_webui.meta_lock);
http_json(fd, 200, reply);
json_decref(reply);
}
static void api_stream(int fd) {
const char *head =
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n"
@ -1084,18 +1337,18 @@ static void handle_api(int fd, const char *method, char *path,
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) &&
} else if (strcmp(path, "/api/categories") == 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) &&
api_categories(fd, body, body_len, false);
} else if (strcmp(path, "/api/categories/delete") == 0 &&
strcmp(method, "POST") == 0) {
json_t *json = json_array();
http_json(fd, 200, json);
json_decref(json);
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) {
@ -1358,15 +1611,27 @@ naut_err naut_plugin_register(const naut_host_api *host) {
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_named;
if (error != NAUT_OK) goto fail_store;
error = start_server();
if (error != NAUT_OK) goto fail_named;
if (error != NAUT_OK) goto fail_store;
return NAUT_OK;
fail_named:
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);
@ -1408,6 +1673,14 @@ naut_err naut_plugin_shutdown(void) {
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);