Seregon/zftpd

Zero-copy FTP/HTTP Daemon compatible with all POSIX systems

C/11.0 KB/No license
src/http_api.c
zftpd / src / http_api.c
1/*
2MIT License
3 
4Copyright (c) 2026 Seregon
5 
6Permission is hereby granted, free of charge, to any person obtaining a copy
7of this software and associated documentation files (the "Software"), to deal
8in the Software without restriction, including without limitation the rights
9to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10copies of the Software, and to permit persons to whom the Software is
11furnished to do so, subject to the following conditions:
12 
13The above copyright notice and this permission notice shall be included in all
14copies or substantial portions of the Software.
15 
16THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22SOFTWARE.
23*/
24/**
25 * @file http_api.c
26 * @brief REST API handlers for the Web File Explorer
27 *
28 * ENDPOINTS:
29 * GET /api/list?path=<dir> Directory listing (JSON)
30 * GET /api/download?path=<file> File download (binary)
31 * GET / Serve embedded index.html
32 * GET /style.css Serve embedded stylesheet
33 * GET /app.js Serve embedded JavaScript
34 */
35 
36#include "http_api.h"
37#include "ftp_path.h"
38#include "ftp_server.h" /* ftp_server_context_t — for network reset endpoint */
39#include "ftp_log.h"
40#include "http_config.h"
41#include "pal_fileio.h"
42#include "pal_network.h" /* pal_network_reset_ftp_stack() */
43#include "pal_notification.h" /* pal_notification_send() — fallback notify */
44#include "exfat_unpacker.h" /* exFAT image parsing for game metadata */
45#include "pkg_unpacker.h" /* PKG archive parsing for game metadata */
46#include <dirent.h>
47#include <errno.h>
48#include <ctype.h>
49#include <fcntl.h>
50#include <inttypes.h>
51#include <limits.h>
52#include <stdatomic.h>
53#include <stdint.h>
54#include <stdio.h>
55#include <stdlib.h>
56#include <string.h>
57#include <sys/stat.h>
58#include <sys/statvfs.h>
59#include <sys/time.h>
60#ifndef _WIN32
61#include <sys/ioctl.h>
62#endif
63 
64#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
65#include <dlfcn.h>
66 
67typedef struct sqlite3 sqlite3;
68 
69typedef enum {
70 LNC_FLAG_NONE = 0,
71 LNC_SKIP_LAUNCH_CHECK = 1,
72 LNC_SKIP_SYSTEM_UPDATE_CHECK = 2,
73 LNC_REBOOT_PATCH_INSTALL = 4,
74 LNC_VR_MODE = 8,
75 LNC_NON_VR_MODE = 16
76} LncAppParamFlag;
77 
78typedef struct _LncAppParam {
79 uint32_t sz;
80 uint32_t user_id;
81 uint32_t app_opt;
82 uint64_t crash_report;
83 LncAppParamFlag check_flag;
84} LncAppParam;
85 
86#define SCE_SYSMODULE_INTERNAL_SYS_CORE 0x80000004
87#define SCE_SYSMODULE_INTERNAL_SYSTEM_SERVICE 0x80000010
88#define SCE_SYSMODULE_INTERNAL_USER_SERVICE 0x80000011
89 
90static int psx_sysmodule_load_internal(unsigned int module_id, int *out_rc) {
91 void *sysmodule =
92 dlopen("/system/common/lib/libSceSysmodule.sprx", RTLD_NOW | RTLD_GLOBAL);
93 if (!sysmodule) {
94 if (out_rc)
95 *out_rc = -1;
96 return -1;
97 }
98 
99 int (*f_sceSysmoduleLoadModuleInternal)(unsigned int) =
100 (int (*)(unsigned int))dlsym(sysmodule,
101 "sceSysmoduleLoadModuleInternal");
102 if (!f_sceSysmoduleLoadModuleInternal) {
103 dlclose(sysmodule);
104 if (out_rc)
105 *out_rc = -2;
106 return -1;
107 }
108 
109 int rc = f_sceSysmoduleLoadModuleInternal(module_id);
110 dlclose(sysmodule);
111 if (out_rc)
112 *out_rc = rc;
113 return 0;
114}
115 
116typedef enum {
117 BGFT_TASK_OPTION_NONE = 0x0,
118 BGFT_TASK_OPTION_DELETE_AFTER_UPLOAD = 0x1,
119 BGFT_TASK_OPTION_INVISIBLE = 0x2,
120 BGFT_TASK_OPTION_ENABLE_PLAYGO = 0x4,
121 BGFT_TASK_OPTION_FORCE_UPDATE = 0x8,
122 BGFT_TASK_OPTION_REMOTE = 0x10,
123 BGFT_TASK_OPTION_COPY_CRASH_REPORT_FILES = 0x20,
124 BGFT_TASK_OPTION_DISABLE_INSERT_POPUP = 0x40,
125 BGFT_TASK_OPTION_DISABLE_CDN_QUERY_PARAM = 0x10000,
126} bgft_task_option_t;
127 
128typedef struct {
129 int user_id;
130 int entitlement_type;
131 const char *id;
132 const char *content_url;
133 const char *content_ex_url;
134 const char *content_name;
135 const char *icon_path;
136 const char *sku_id;
137 bgft_task_option_t option;
138 const char *playgo_scenario_id;
139 const char *release_date;
140 const char *package_type;
141 const char *package_sub_type;
142 unsigned long package_size;
143} bgft_download_param;
144 
145typedef struct {
146 bgft_download_param param;
147 unsigned int slot;
148} bgft_download_param_ex;
149 
150typedef struct {
151 void *heap;
152 size_t heapSize;
153} bgft_init_params;
154 
155typedef struct {
156 unsigned int bits;
157 int error_result;
158 unsigned long length;
159 unsigned long transferred;
160 unsigned long lengthTotal;
161 unsigned long transferredTotal;
162 unsigned int numIndex;
163 unsigned int numTotal;
164 unsigned int restSec;
165 unsigned int restSecTotal;
166 int preparingPercent;
167 int localCopyPercent;
168} SceBgftTaskProgress;
169#endif
170#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5) || defined(__FreeBSD__)
171#include <sys/mount.h> /* fstatfs, struct statfs — for sendfile safety check */
172/* PS4/PS5 libkernel exports _fstatfs, not fstatfs */
173#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
174extern int _fstatfs(int, struct statfs *);
175#define http_fstatfs _fstatfs
176#else
177#define http_fstatfs fstatfs
178#endif
179#endif /* PLATFORM_PS4 || PLATFORM_PS5 || __FreeBSD__ */
180#include <time.h>
181#if defined(PLATFORM_LINUX) && __has_include(<sys/sysinfo.h>)
182#define HAS_SYSINFO 1
183#include <sys/sysinfo.h>
184#endif
185#if defined(PLATFORM_MACOS) || defined(PLATFORM_PS4) || \
186 defined(PLATFORM_PS5) || defined(PS4) || defined(PS5) || \
187 defined(__APPLE__)
188#include <sys/sysctl.h>
189#endif
190#if defined(PLATFORM_MACOS) || defined(__APPLE__)
191#include <mach/mach.h>
192#include <mach/vm_statistics.h>
193#endif
194#include <unistd.h>
195 
196/*===========================================================================*
197 * EMBEDDED RESOURCES (defined in http_resources.c)
198 *===========================================================================*/
199 
200extern const char *http_get_resource(const char *path, size_t *size);
201 
202/*===========================================================================*
203 * ROOT PATH CONFINEMENT
204 *
205 * FTP side: ftp_path_resolve() -> ftp_path_normalize() ->
206 * realpath() -> ftp_path_is_within_root()
207 * HTTP side: http_validate_and_confine() reuses the same primitives.
208 *
209 * Root is stored in http_server.root_path and propagated here via
210 * http_api_set_root() during http_server_create().
211 *===========================================================================*/
212 
213static char g_http_root[FTP_PATH_MAX] = "/";
214 
215/*
216 * Pointer to the FTP server context.
217 *
218 * Set once by http_api_set_server_ctx() during server startup.
219 * Used by the /api/network/reset endpoint (Fix #4) to reach the session pool
220 * and call pal_network_reset_ftp_stack().
221 *
222 * NULL if not set (e.g. HTTP server started standalone without FTP).
223 * Access is single-threaded from the HTTP event loop — no lock needed.
224 */
225static ftp_server_context_t *g_ftp_server_ctx = NULL;
226 
227/**
228 * @brief Set the FTP server context for the HTTP API layer.
229 *
230 * Must be called after ftp_server_init() and before http_server_create().
231 *
232 * @param ctx Pointer to the initialized FTP server context, or NULL to clear.
233 */
234void http_api_set_server_ctx(ftp_server_context_t *ctx) {
235 g_ftp_server_ctx = ctx;
236}
237 
238void http_api_set_root(const char *root) {
239 if ((root == NULL) || (root[0] == '\0')) {
240 g_http_root[0] = '/';
241 g_http_root[1] = '\0';
242 return;
243 }
244 size_t len = strlen(root);
245 if (len >= sizeof(g_http_root)) {
246 len = sizeof(g_http_root) - 1U;
247 }
248 memcpy(g_http_root, root, len);
249 g_http_root[len] = '\0';
250 
251 /* Strip trailing slash (unless root is exactly "/") */
252 while (len > 1U && g_http_root[len - 1U] == '/') {
253 g_http_root[--len] = '\0';
254 }
255}
256 
257const char *http_api_get_root(void) { return g_http_root; }
258 
259/**
260 * @brief Validate and confine an HTTP path to the server root
261 *
262 * Reuses the same path security primitives as the FTP core:
263 *
264 * Step 1: ftp_path_normalize() - resolve .., ., //
265 * Step 2: ftp_path_is_within_root() - pre-realpath confinement
266 * Step 3: realpath() - resolve symlinks
267 * Step 4: ftp_path_is_within_root() - post-realpath re-check
268 *
269 * @param[in] input Raw path from URL (already URL-decoded)
270 * @param[in] root Root directory (absolute)
271 * @param[out] out Buffer for the canonical confined path
272 * @param[in] out_size Size of out (>= FTP_PATH_MAX)
273 *
274 * @return 0 on success, -1 if path escapes root
275 *
276 * @pre input != NULL, root != NULL, out != NULL
277 * @post On success, ftp_path_is_within_root(out, root) == 1
278 */
279static int http_validate_and_confine(const char *input, const char *root,
280 char *out, size_t out_size) {
281 if ((input == NULL) || (root == NULL) || (out == NULL)) {
282 return -1;
283 }
284 
285 /* Step 1: normalize (resolve .., ., //) */
286 char normalized[FTP_PATH_MAX];
287 if (ftp_path_normalize(input, normalized, sizeof(normalized)) != FTP_OK) {
288 return -1;
289 }
290 
291 /* Step 2: pre-realpath confinement check */
292 if (ftp_path_is_within_root(normalized, root) != 1) {
293 return -1;
294 }
295 
296 /* Step 3: resolve symlinks */
297 char real[FTP_PATH_MAX];
298 if (realpath(normalized, real) != NULL) {
299 /* Step 4: post-realpath re-check (anti symlink traversal) */
300 if (ftp_path_is_within_root(real, root) != 1) {
301 return -1;
302 }
303 size_t n = strlen(real);
304 if ((n + 1U) > out_size) {
305 return -1;
306 }
307 memcpy(out, real, n + 1U);
308 } else {
309 /*
310 * Path doesn't exist yet (upload target, new directory).
311 * Pre-realpath check already passed — use normalized.
312 */
313 size_t n = strlen(normalized);
314 if ((n + 1U) > out_size) {
315 return -1;
316 }
317 memcpy(out, normalized, n + 1U);
318 }
319 
320 return 0;
321}
322 
323/*===========================================================================*
324 * FORWARD DECLARATIONS
325 *===========================================================================*/
326 
327static http_response_t *api_list(const http_request_t *request);
328static http_response_t *api_dirsize(const http_request_t *request);
329static http_response_t *api_download(const http_request_t *request);
330static http_response_t *api_stats(const http_request_t *request);
331static http_response_t *api_stats_ram(const http_request_t *request);
332static http_response_t *api_stats_system(const http_request_t *request);
333static http_response_t *api_disk_info(const http_request_t *request);
334static http_response_t *api_disk_tree(const http_request_t *request);
335static http_response_t *api_processes(const http_request_t *request);
336static http_response_t *api_process_kill(const http_request_t *request);
337static http_response_t *serve_static(const http_request_t *request);
338static http_response_t *api_game_meta(const http_request_t *request);
339static http_response_t *api_game_icon(const http_request_t *request);
340static http_response_t *api_extract(const http_request_t *request);
341static http_response_t *api_extract_progress(const http_request_t *request);
342static http_response_t *api_extract_cancel(const http_request_t *request);
343static http_response_t *api_dl_start(const http_request_t *request);
344static http_response_t *api_dl_status(const http_request_t *request);
345static http_response_t *api_dl_pause(const http_request_t *request);
346static http_response_t *api_dl_cancel(const http_request_t *request);
347#if ENABLE_WEB_UPLOAD
348static http_response_t *api_create_file(const http_request_t *request);
349static http_response_t *api_mkdir(const http_request_t *request);
350static http_response_t *api_delete(const http_request_t *request);
351static http_response_t *api_rename(const http_request_t *request);
352static http_response_t *api_copy(const http_request_t *request);
353static http_response_t *api_copy_progress(const http_request_t *request);
354static http_response_t *api_copy_cancel(const http_request_t *request);
355static http_response_t *api_copy_pause(const http_request_t *request);
356#endif
357static http_response_t *api_network_reset(const http_request_t *request);
358static http_response_t *api_admin_fan(const http_request_t *request);
359static http_response_t *api_admin_launch(const http_request_t *request);
360static http_response_t *api_games_installed(const http_request_t *request);
361static http_response_t *api_games_install_status(const http_request_t *request);
362static http_response_t *api_games_icon(const http_request_t *request);
363static http_response_t *api_games_repair_visibility(const http_request_t *request);
364static http_response_t *api_games_uninstall(const http_request_t *request);
365static http_response_t *api_games_install(const http_request_t *request);
366static http_response_t *api_games_reinstall(const http_request_t *request);
367static http_response_t *api_legacy_disabled_json(const char *json_body);
368static http_response_t *error_json(http_status_t code, const char *message);
369static http_response_t *status_json_200(int ok, const char *message,
370 int code);
371static http_response_t *png_fallback_response(void);
372 
373static void launch_diag_log(const char *stage, const char *title_id, int code,
374 const char *detail) {
375 char line[512];
376 const char *s = stage ? stage : "unknown";
377 const char *t = title_id ? title_id : "-";
378 const char *d = detail ? detail : "";
379 (void)snprintf(line, sizeof(line),
380 "[LAUNCH-DIAG] stage=%s title=%s code=0x%08X detail=%s", s,
381 t, (unsigned)code, d);
382 fprintf(stderr, "%s\n", line);
383 ftp_log_line((code == 0 || (uint32_t)code == 0x8094000CU ||
384 (((uint32_t)code & 0xFF000000U) == 0x60000000U))
385 ? FTP_LOG_INFO
386 : FTP_LOG_ERROR,
387 line);
388}
389 
390#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
391static int launch_result_is_success(uint32_t res) {
392 return (res == 0U) || (res == 0x8094000CU) ||
393 ((res & 0xFF000000U) == 0x60000000U);
394}
395#endif
396 
397/*===========================================================================*
398 * PATH SECURITY
399 *
400 * ┌──────────────────────────────────────────────────┐
401 * │ BLOCKED PATTERNS REASON │
402 * │ ../ traversal │
403 * │ // double-slash trick │
404 * │ /dev /proc /sys /kern PS kernel crash │
405 * │ outside g_http_root VULN-01/02 fix │
406 * └──────────────────────────────────────────────────┘
407 *===========================================================================*/
408 
409/**
410 * @brief Check for directory-traversal attacks
411 *
412 * Returns 1 if path is safe, 0 if it contains ".." components.
413 */
414static int is_safe_path(const char *path) {
415 if (path == NULL) {
416 return 0;
417 }
418 
419 /* Must start with '/' */
420 if (path[0] != '/') {
421 return 0;
422 }
423 
424 /* Search for ".." components */
425 const char *p = path;
426 while (*p != '\0') {
427 if (p[0] == '.' && p[1] == '.') {
428 /* ".." at start of path, or preceded by '/' */
429 if (p == path || p[-1] == '/') {
430 return 0;
431 }
432 }
433 p++;
434 }
435 
436 return 1;
437}
438 
439#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5) || defined(PS4) || \
440 defined(PS5)
441/**
442 * @brief PS4/PS5 forbidden path blacklist
443 *
444 * Accessing these causes "Fatal trap 12: page fault" on unjailbroken kernels.
445 */
446static const char *forbidden_prefixes[] = {"/dev", "/proc", "/sys", "/kern",
447 NULL};
448 
449static int is_ps_safe_path(const char *path) {
450 for (size_t i = 0; forbidden_prefixes[i] != NULL; i++) {
451 size_t len = strlen(forbidden_prefixes[i]);
452 if (strncmp(path, forbidden_prefixes[i], len) == 0) {
453 /* Exact match or followed by '/' */
454 if (path[len] == '\0' || path[len] == '/') {
455 return 0;
456 }
457 }
458 }
459 return 1;
460}
461#endif
462 
463/**
464 * @brief Combined path validation
465 *
466 * 1. Reject traversal patterns ("..")
467 * 2. Reject PS kernel-crash paths (/dev, /proc, ...)
468 * 3. Confine to g_http_root via http_validate_and_confine()
469 *
470 * @param[in] path Raw input path
471 * @param[out] safe Canonical path confined to root (FTP_PATH_MAX)
472 *
473 * @return 1 if safe, 0 if rejected
474 */
475static int validate_path(const char *path, char *safe, size_t safe_size) {
476 if (!is_safe_path(path)) {
477 return 0;
478 }
479 
480#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5) || defined(PS4) || \
481 defined(PS5)
482 if (!is_ps_safe_path(path)) {
483 return 0;
484 }
485#endif
486 
487 /* Root confinement via ftp_path_normalize + ftp_path_is_within_root */
488 if (http_validate_and_confine(path, g_http_root, safe, safe_size) != 0) {
489 return 0;
490 }
491 
492 return 1;
493}
494 
495static int buf_append_bytes(char *buf, size_t cap, size_t *pos,
496 const char *data, size_t len) {
497 if ((buf == NULL) || (pos == NULL) || (data == NULL)) {
498 return -1;
499 }
500 if (*pos > cap) {
501 return -1;
502 }
503 if (len > (cap - *pos)) {
504 return -1;
505 }
506 if (len > 0U) {
507 memcpy(buf + *pos, data, len);
508 *pos += len;
509 }
510 return 0;
511}
512 
513static int buf_append_cstr(char *buf, size_t cap, size_t *pos,
514 const char *str) {
515 if (str == NULL) {
516 return -1;
517 }
518 return buf_append_bytes(buf, cap, pos, str, strlen(str));
519}
520 
521static int buf_append_u64(char *buf, size_t cap, size_t *pos, uint64_t v) {
522 char tmp[32];
523 int n = snprintf(tmp, sizeof(tmp), "%" PRIu64, v);
524 if ((n < 0) || ((size_t)n >= sizeof(tmp))) {
525 return -1;
526 }
527 return buf_append_bytes(buf, cap, pos, tmp, (size_t)n);
528}
529 
530static int buf_append_u32(char *buf, size_t cap, size_t *pos, uint32_t v) {
531 char tmp[16];
532 int n = snprintf(tmp, sizeof(tmp), "%" PRIu32, v);
533 if ((n < 0) || ((size_t)n >= sizeof(tmp))) {
534 return -1;
535 }
536 return buf_append_bytes(buf, cap, pos, tmp, (size_t)n);
537}
538 
539static int buf_append_i32(char *buf, size_t cap, size_t *pos, int32_t v) {
540 char tmp[16];
541 int n = snprintf(tmp, sizeof(tmp), "%" PRId32, v);
542 if ((n < 0) || ((size_t)n >= sizeof(tmp))) {
543 return -1;
544 }
545 return buf_append_bytes(buf, cap, pos, tmp, (size_t)n);
546}
547 
548static int u64_mul_checked(uint64_t a, uint64_t b, uint64_t *out) {
549 if (out == NULL) {
550 return -1;
551 }
552 if ((a != 0U) && (b > (UINT64_MAX / a))) {
553 *out = UINT64_MAX;
554 return -1;
555 }
556 *out = a * b;
557 return 0;
558}
559 
560static int get_disk_stats_bytes(const char *path, uint64_t *total,
561 uint64_t *used, uint64_t *free_b) {
562 if ((path == NULL) || (total == NULL) || (used == NULL) || (free_b == NULL)) {
563 return -1;
564 }
565 
566 struct statvfs s;
567 if (statvfs(path, &s) != 0) {
568 return -1;
569 }
570 
571 uint64_t fr = (uint64_t)((s.f_frsize != 0U) ? s.f_frsize : s.f_bsize);
572 uint64_t total_bytes = 0U;
573 uint64_t free_bytes = 0U;
574 
575 if (u64_mul_checked(fr, (uint64_t)s.f_blocks, &total_bytes) != 0) {
576 return -1;
577 }
578 if (u64_mul_checked(fr, (uint64_t)s.f_bavail, &free_bytes) != 0) {
579 return -1;
580 }
581 
582 uint64_t used_bytes =
583 (total_bytes >= free_bytes) ? (total_bytes - free_bytes) : total_bytes;
584 
585 *total = total_bytes;
586 *free_b = free_bytes;
587 *used = used_bytes;
588 return 0;
589}
590 
591static int get_best_disk_stats(const char *hint_path, const char **out_path,
592 uint64_t *total, uint64_t *used,
593 uint64_t *free_b) {
594 if ((out_path == NULL) || (total == NULL) || (used == NULL) ||
595 (free_b == NULL)) {
596 return -1;
597 }
598 
599#if defined(PLATFORM_PS5) || defined(PS5)
600 /*
601 * On PS5 always report the /user partition — that is what the system
602 * Settings > Storage screen shows. The "pick largest" heuristic used
603 * below selects /mnt (full SSD) which is much larger and does not match
604 * what the user expects.
605 */
606 (void)hint_path;
607 uint64_t t = 0U, u = 0U, f = 0U;
608 if (get_disk_stats_bytes("/user", &t, &u, &f) == 0) {
609 *out_path = "/user";
610 *total = t;
611 *used = u;
612 *free_b = f;
613 return 0;
614 }
615 return -1;
616#else
617 /* Ordered: real user data mounts first, then root fallback.
618 * macOS home and /Volumes entries come before PS4 paths. */
619 const char *candidates[] = {
620#if defined(PLATFORM_MACOS) || defined(__APPLE__)
621 "/Users",
622 "/",
623#elif defined(PLATFORM_PS4) || defined(PS4)
624 "/user", "/data", "/system_data", "/mnt/usb0", "/mnt/usb1", "/",
625#else
626 "/home",
627 "/",
628#endif
629 NULL,
630 };
631 
632 const char *best = NULL;
633 uint64_t best_total = 0U;
634 uint64_t best_used = 0U;
635 uint64_t best_free = 0U;
636 
637 if (hint_path != NULL) {
638 uint64_t t = 0U, u = 0U, f = 0U;
639 if (get_disk_stats_bytes(hint_path, &t, &u, &f) == 0) {
640 best = hint_path;
641 best_total = t;
642 best_used = u;
643 best_free = f;
644 }
645 }
646 
647 for (size_t i = 0U; candidates[i] != NULL; i++) {
648 uint64_t t = 0U, u = 0U, f = 0U;
649 if (get_disk_stats_bytes(candidates[i], &t, &u, &f) != 0) {
650 continue;
651 }
652 if (t > best_total) {
653 best = candidates[i];
654 best_total = t;
655 best_used = u;
656 best_free = f;
657 }
658 }
659 
660 if (best == NULL) {
661 return -1;
662 }
663 
664 *out_path = best;
665 *total = best_total;
666 *used = best_used;
667 *free_b = best_free;
668 return 0;
669#endif
670}
671 
672static int count_dir_items(const char *path, uint32_t *out_count) {
673 if ((path == NULL) || (out_count == NULL)) {
674 return -1;
675 }
676 
677 DIR *dir = opendir(path);
678 if (dir == NULL) {
679 return -1;
680 }
681 
682 uint32_t count = 0U;
683 for (;;) {
684 errno = 0;
685 struct dirent *ent = readdir(dir);
686 if (ent == NULL) {
687 if (errno != 0) {
688 closedir(dir);
689 return -1;
690 }
691 break;
692 }
693 if ((strcmp(ent->d_name, ".") == 0) || (strcmp(ent->d_name, "..") == 0)) {
694 continue;
695 }
696 if (count == UINT32_MAX) {
697 closedir(dir);
698 return -1;
699 }
700 count++;
701 }
702 
703 closedir(dir);
704 *out_count = count;
705 return 0;
706}
707 
708/**
709 * @brief Recursively sum the size of all regular files under a directory.
710 *
711 * Uses a shared context to enforce:
712 * - Time budget (DIR_SIZE_TIMEOUT_MS) — bail out after ~200 ms
713 * - Entry limit (DIR_SIZE_MAX_ENTRIES) — bail after 10 000 stat() calls
714 * - Depth limit (DIR_SIZE_MAX_DEPTH) — max 8 levels deep
715 *
716 * On slow USB/exFAT media with deeply nested trees the scan returns
717 * a partial result instead of blocking the HTTP server for seconds.
718 *
719 * ┌──────────────────────────────────────────────┐
720 * │ 200 ms budget ──► partial=true, ~size │
721 * │ 10 000 entries ──► partial=true, ~size │
722 * │ depth > 8 ──► skip subtree │
723 * │ otherwise ──► full scan, partial=false │
724 * └──────────────────────────────────────────────┘
725 */
726#define DIR_SIZE_MAX_DEPTH 8
727#define DIR_SIZE_MAX_ENTRIES 10000
728#define DIR_SIZE_TIMEOUT_MS 200
729 
730typedef struct {
731 struct timeval deadline; /* absolute wallclock deadline */
732 uint32_t entries; /* stat() calls so far */
733 int partial; /* set to 1 if limits exceeded */
734} dir_size_ctx_t;
735 
736/* Return 1 if the context limits have been exceeded. */
737static int dir_size_exceeded(dir_size_ctx_t *ctx) {
738 if (ctx->partial) {
739 return 1;
740 }
741 if (ctx->entries >= DIR_SIZE_MAX_ENTRIES) {
742 ctx->partial = 1;
743 return 1;
744 }
745 /* Check clock every 64 entries to minimise gettimeofday overhead */
746 if ((ctx->entries & 63U) == 0U) {
747 struct timeval now;
748 gettimeofday(&now, NULL);
749 if ((now.tv_sec > ctx->deadline.tv_sec) ||
750 (now.tv_sec == ctx->deadline.tv_sec &&
751 now.tv_usec >= ctx->deadline.tv_usec)) {
752 ctx->partial = 1;
753 return 1;
754 }
755 }
756 return 0;
757}
758 
759static uint64_t dir_size_walk(const char *path, int depth, dir_size_ctx_t *ctx) {
760 if ((path == NULL) || (depth > DIR_SIZE_MAX_DEPTH)) {
761 return 0U;
762 }
763 if (dir_size_exceeded(ctx)) {
764 return 0U;
765 }
766 
767 DIR *dir = opendir(path);
768 if (dir == NULL) {
769 return 0U;
770 }
771 
772 uint64_t total = 0U;
773 
774 for (;;) {
775 if (dir_size_exceeded(ctx)) {
776 break;
777 }
778 
779 errno = 0;
780 struct dirent *ent = readdir(dir);
781 if (ent == NULL) {
782 break;
783 }
784 if ((strcmp(ent->d_name, ".") == 0) || (strcmp(ent->d_name, "..") == 0)) {
785 continue;
786 }
787 
788 char child[FTP_PATH_MAX];
789 int n;
790 if (strcmp(path, "/") == 0) {
791 n = snprintf(child, sizeof(child), "/%s", ent->d_name);
792 } else {
793 n = snprintf(child, sizeof(child), "%s/%s", path, ent->d_name);
794 }
795 if ((n < 0) || ((size_t)n >= sizeof(child))) {
796 continue;
797 }
798 
799 struct stat st;
800 if (lstat(child, &st) != 0) {
801 continue;
802 }
803 ctx->entries++;
804 
805 if (S_ISREG(st.st_mode)) {
806 total += (uint64_t)st.st_blocks * 512U;
807 } else if (S_ISDIR(st.st_mode)) {
808 total += dir_size_walk(child, depth + 1, ctx);
809 }
810 /* skip symlinks, devices, etc. */
811 }
812 
813 closedir(dir);
814 return total;
815}
816 
817uint64_t http_dir_size_recursive(const char *path, int depth) {
818 dir_size_ctx_t ctx;
819 gettimeofday(&ctx.deadline, NULL);
820 ctx.deadline.tv_usec += DIR_SIZE_TIMEOUT_MS * 1000;
821 if (ctx.deadline.tv_usec >= 1000000) {
822 ctx.deadline.tv_sec += ctx.deadline.tv_usec / 1000000;
823 ctx.deadline.tv_usec = ctx.deadline.tv_usec % 1000000;
824 }
825 ctx.entries = 0;
826 ctx.partial = 0;
827 
828 return dir_size_walk(path, depth, &ctx);
829}
830 
831/**
832 * @brief Same as http_dir_size_recursive but also reports whether
833 * the scan was truncated by the time/entry budget.
834 */
835static uint64_t http_dir_size_with_partial(const char *path, int *out_partial) {
836 dir_size_ctx_t ctx;
837 gettimeofday(&ctx.deadline, NULL);
838 ctx.deadline.tv_usec += DIR_SIZE_TIMEOUT_MS * 1000;
839 if (ctx.deadline.tv_usec >= 1000000) {
840 ctx.deadline.tv_sec += ctx.deadline.tv_usec / 1000000;
841 ctx.deadline.tv_usec = ctx.deadline.tv_usec % 1000000;
842 }
843 ctx.entries = 0;
844 ctx.partial = 0;
845 
846 uint64_t sz = dir_size_walk(path, 0, &ctx);
847 if (out_partial != NULL) {
848 *out_partial = ctx.partial;
849 }
850 return sz;
851}
852 
853static int get_boot_epoch_seconds(uint64_t *out_epoch) {
854 if (out_epoch == NULL) {
855 return -1;
856 }
857 
858#if defined(HAS_SYSINFO)
859 struct sysinfo info;
860 if (sysinfo(&info) != 0) {
861 return -1;
862 }
863 time_t now = time(NULL);
864 if (now < 0) {
865 return -1;
866 }
867 uint64_t now_u = (uint64_t)now;
868 uint64_t up_u = (uint64_t)info.uptime;
869 *out_epoch = (now_u >= up_u) ? (now_u - up_u) : 0U;
870 return 0;
871#elif defined(PLATFORM_MACOS) || defined(__APPLE__) || \
872 defined(PLATFORM_PS4) || defined(PLATFORM_PS5) || defined(PS4) || \
873 defined(PS5)
874 struct timeval bt;
875 size_t sz = sizeof(bt);
876 if (sysctlbyname("kern.boottime", &bt, &sz, NULL, 0) != 0) {
877 return -1;
878 }
879 if (sz < sizeof(bt)) {
880 return -1;
881 }
882 if (bt.tv_sec < 0) {
883 return -1;
884 }
885 *out_epoch = (uint64_t)bt.tv_sec;
886 return 0;
887#else
888 (void)out_epoch;
889 return -1;
890#endif
891}
892 
893static int get_cpu_temp_c(int32_t *out_c) {
894 if (out_c == NULL) {
895 return -1;
896 }
897 
898#if defined(PLATFORM_PS4) || defined(PS4)
899 __attribute__((weak)) int32_t sceKernelGetCpuTemperature(
900 uint64_t *temperature);
901 if (sceKernelGetCpuTemperature != NULL) {
902 uint64_t raw = 0U;
903 int32_t rc = sceKernelGetCpuTemperature(&raw);
904 if (rc == 0) {
905 if ((raw >= 20U) && (raw <= 110U)) {
906 *out_c = (int32_t)raw;
907 return 0;
908 }
909 }
910 }
911#endif
912 
913#if defined(PLATFORM_MACOS) || defined(PLATFORM_PS4) || \
914 defined(PLATFORM_PS5) || defined(PS4) || defined(PS5)
915 const char *names[] = {
916 "dev.cpu.0.temperature",
917 "dev.cpu.0.coretemp.temperature",
918 "dev.cpu.0.temp",
919 "dev.amdtemp.0.temperature",
920 "dev.amdtemp.0.core0.sensor0",
921 "dev.thermal.0.temperature",
922 "hw.acpi.thermal.tz0.temperature",
923 "hw.temperature",
924 NULL,
925 };
926 
927 for (size_t i = 0U; names[i] != NULL; i++) {
928 int v = 0;
929 size_t sz = sizeof(v);
930 if (sysctlbyname(names[i], &v, &sz, NULL, 0) != 0) {
931 continue;
932 }
933 if (sz != sizeof(v)) {
934 continue;
935 }
936 
937 int32_t c = 0;
938 if (v > 1000) {
939 int32_t dk = (int32_t)v;
940 c = (dk - 2731 + 5) / 10;
941 } else {
942 c = (int32_t)v;
943 }
944 if ((c < -40) || (c > 200)) {
945 continue;
946 }
947 *out_c = c;
948 return 0;
949 }
950 
951 return -1;
952#else
953 (void)out_c;
954 return -1;
955#endif
956}
957 
958/*===========================================================================*
959 * JSON HELPERS
960 *===========================================================================*/
961 
962/**
963 * @brief Append a JSON-escaped string to buffer
964 *
965 * Escapes: " \ / \b \f \n \r \t and control chars
966 */
967static int json_escape_append(char *buf, size_t cap, size_t *pos,
968 const char *str) {
969 size_t p = *pos;
970 
971 for (const char *s = str; *s != '\0'; s++) {
972 unsigned char c = (unsigned char)*s;
973 
974 if (p + 6 >= cap) {
975 return -1; /* would overflow */
976 }
977 
978 switch (c) {
979 case '"':
980 buf[p++] = '\\';
981 buf[p++] = '"';
982 break;
983 case '\\':
984 buf[p++] = '\\';
985 buf[p++] = '\\';
986 break;
987 case '\b':
988 buf[p++] = '\\';
989 buf[p++] = 'b';
990 break;
991 case '\f':
992 buf[p++] = '\\';
993 buf[p++] = 'f';
994 break;
995 case '\n':
996 buf[p++] = '\\';
997 buf[p++] = 'n';
998 break;
999 case '\r':
1000 buf[p++] = '\\';
1001 buf[p++] = 'r';
1002 break;
1003 case '\t':
1004 buf[p++] = '\\';
1005 buf[p++] = 't';
1006 break;
1007 default:
1008 if (c < 0x20) {
1009 p += (size_t)snprintf(buf + p, cap - p, "\\u%04x", c);
1010 } else {
1011 buf[p++] = (char)c;
1012 }
1013 break;
1014 }
1015 }
1016 
1017 *pos = p;
1018 return 0;
1019}
1020 
1021/*===========================================================================*
1022 * QUERY STRING PARSER
1023 *===========================================================================*/
1024 
1025/**
1026 * @brief Extract "path" parameter from query string
1027 *
1028 * Given "?path=/foo/bar&other=1", writes "/foo/bar" into out.
1029 */
1030static int parse_path_param(const char *query, char *out, size_t out_size) {
1031 if (query == NULL || out == NULL) {
1032 return -1;
1033 }
1034 if (out_size < 2U) {
1035 return -1;
1036 }
1037 
1038 const char *start = strstr(query, "path=");
1039 if (start == NULL) {
1040 return -1;
1041 }
1042 start += 5; /* skip "path=" */
1043 
1044 size_t in_pos = 0U;
1045 size_t out_pos = 0U;
1046 
1047 while ((start[in_pos] != '\0') && (start[in_pos] != '&') &&
1048 (out_pos < (out_size - 1U))) {
1049 unsigned char ch = (unsigned char)start[in_pos];
1050 
1051 if ((ch == '%') && (start[in_pos + 1] != '\0') &&
1052 (start[in_pos + 2] != '\0')) {
1053 unsigned char hi = (unsigned char)start[in_pos + 1];
1054 unsigned char lo = (unsigned char)start[in_pos + 2];
1055 
1056 unsigned int v_hi;
1057 unsigned int v_lo;
1058 
1059 if ((hi >= '0') && (hi <= '9')) {
1060 v_hi = (unsigned int)(hi - '0');
1061 } else if ((hi >= 'A') && (hi <= 'F')) {
1062 v_hi = 10U + (unsigned int)(hi - 'A');
1063 } else if ((hi >= 'a') && (hi <= 'f')) {
1064 v_hi = 10U + (unsigned int)(hi - 'a');
1065 } else {
1066 v_hi = 0xFFFFFFFFU;
1067 }
1068 
1069 if ((lo >= '0') && (lo <= '9')) {
1070 v_lo = (unsigned int)(lo - '0');
1071 } else if ((lo >= 'A') && (lo <= 'F')) {
1072 v_lo = 10U + (unsigned int)(lo - 'A');
1073 } else if ((lo >= 'a') && (lo <= 'f')) {
1074 v_lo = 10U + (unsigned int)(lo - 'a');
1075 } else {
1076 v_lo = 0xFFFFFFFFU;
1077 }
1078 
1079 if ((v_hi != 0xFFFFFFFFU) && (v_lo != 0xFFFFFFFFU)) {
1080 unsigned char decoded = (unsigned char)((v_hi << 4U) | v_lo);
1081 if (decoded == '\0') {
1082 return -1;
1083 }
1084 out[out_pos++] = (char)decoded;
1085 in_pos += 3U;
1086 continue;
1087 }
1088 }
1089 
1090 if (ch == '+') {
1091 out[out_pos++] = ' ';
1092 } else {
1093 out[out_pos++] = (char)ch;
1094 }
1095 in_pos++;
1096 }
1097 out[out_pos] = '\0';
1098 
1099 /* If empty, default to "/" */
1100 if (out[0] == '\0') {
1101 out[0] = '/';
1102 out[1] = '\0';
1103 }
1104 
1105 return 0;
1106}
1107 
1108static int parse_query_param(const char *query, const char *key,
1109 char *out, size_t out_size) {
1110 if ((query == NULL) || (key == NULL) || (out == NULL) || (out_size < 2U)) {
1111 return -1;
1112 }
1113 
1114 size_t key_len = strlen(key);
1115 const char *p = query;
1116 while ((p = strstr(p, key)) != NULL) {
1117 if ((p == query || p[-1] == '?' || p[-1] == '&') &&
1118 (p[key_len] == '=')) {
1119 const char *start = p + key_len + 1U;
1120 size_t in_pos = 0U;
1121 size_t out_pos = 0U;
1122 
1123 while ((start[in_pos] != '\0') && (start[in_pos] != '&') &&
1124 (out_pos < (out_size - 1U))) {
1125 unsigned char ch = (unsigned char)start[in_pos];
1126 
1127 if ((ch == '%') && (start[in_pos + 1] != '\0') &&
1128 (start[in_pos + 2] != '\0')) {
1129 unsigned char hi = (unsigned char)start[in_pos + 1];
1130 unsigned char lo = (unsigned char)start[in_pos + 2];
1131 unsigned int v_hi = 0xFFFFFFFFU;
1132 unsigned int v_lo = 0xFFFFFFFFU;
1133 
1134 if ((hi >= '0') && (hi <= '9'))
1135 v_hi = (unsigned int)(hi - '0');
1136 else if ((hi >= 'A') && (hi <= 'F'))
1137 v_hi = 10U + (unsigned int)(hi - 'A');
1138 else if ((hi >= 'a') && (hi <= 'f'))
1139 v_hi = 10U + (unsigned int)(hi - 'a');
1140 
1141 if ((lo >= '0') && (lo <= '9'))
1142 v_lo = (unsigned int)(lo - '0');
1143 else if ((lo >= 'A') && (lo <= 'F'))
1144 v_lo = 10U + (unsigned int)(lo - 'A');
1145 else if ((lo >= 'a') && (lo <= 'f'))
1146 v_lo = 10U + (unsigned int)(lo - 'a');
1147 
1148 if ((v_hi != 0xFFFFFFFFU) && (v_lo != 0xFFFFFFFFU)) {
1149 unsigned char decoded = (unsigned char)((v_hi << 4U) | v_lo);
1150 if (decoded == '\0') {
1151 return -1;
1152 }
1153 out[out_pos++] = (char)decoded;
1154 in_pos += 3U;
1155 continue;
1156 }
1157 }
1158 
1159 out[out_pos++] = (ch == '+') ? ' ' : (char)ch;
1160 in_pos++;
1161 }
1162 out[out_pos] = '\0';
1163 return (out_pos > 0U) ? 0 : -1;
1164 }
1165 p += key_len;
1166 }
1167 
1168 return -1;
1169}
1170 
1171#if ENABLE_WEB_UPLOAD
1172static int parse_name_param(const char *query, char *out, size_t out_size) {
1173 if (query == NULL || out == NULL) {
1174 return -1;
1175 }
1176 if (out_size < 2U) {
1177 return -1;
1178 }
1179 
1180 const char *start = strstr(query, "name=");
1181 if (start == NULL) {
1182 return -1;
1183 }
1184 start += 5; /* skip "name=" */
1185 
1186 size_t in_pos = 0U;
1187 size_t out_pos = 0U;
1188 
1189 while ((start[in_pos] != '\0') && (start[in_pos] != '&') &&
1190 (out_pos < (out_size - 1U))) {
1191 unsigned char ch = (unsigned char)start[in_pos];
1192 
1193 if ((ch == '%') && (start[in_pos + 1] != '\0') &&
1194 (start[in_pos + 2] != '\0')) {
1195 unsigned char hi = (unsigned char)start[in_pos + 1];
1196 unsigned char lo = (unsigned char)start[in_pos + 2];
1197 
1198 unsigned int v_hi;
1199 unsigned int v_lo;
1200 
1201 if ((hi >= '0') && (hi <= '9')) {
1202 v_hi = (unsigned int)(hi - '0');
1203 } else if ((hi >= 'A') && (hi <= 'F')) {
1204 v_hi = 10U + (unsigned int)(hi - 'A');
1205 } else if ((hi >= 'a') && (hi <= 'f')) {
1206 v_hi = 10U + (unsigned int)(hi - 'a');
1207 } else {
1208 v_hi = 0xFFFFFFFFU;
1209 }
1210 
1211 if ((lo >= '0') && (lo <= '9')) {
1212 v_lo = (unsigned int)(lo - '0');
1213 } else if ((lo >= 'A') && (lo <= 'F')) {
1214 v_lo = 10U + (unsigned int)(lo - 'A');
1215 } else if ((lo >= 'a') && (lo <= 'f')) {
1216 v_lo = 10U + (unsigned int)(lo - 'a');
1217 } else {
1218 v_lo = 0xFFFFFFFFU;
1219 }
1220 
1221 if ((v_hi != 0xFFFFFFFFU) && (v_lo != 0xFFFFFFFFU)) {
1222 unsigned char decoded = (unsigned char)((v_hi << 4U) | v_lo);
1223 if (decoded == '\0') {
1224 return -1;
1225 }
1226 out[out_pos++] = (char)decoded;
1227 in_pos += 3U;
1228 continue;
1229 }
1230 }
1231 
1232 if (ch == '+') {
1233 out[out_pos++] = ' ';
1234 } else {
1235 out[out_pos++] = (char)ch;
1236 }
1237 in_pos++;
1238 }
1239 out[out_pos] = '\0';
1240 if (out[0] == '\0') {
1241 return -1;
1242 }
1243 
1244 return 0;
1245}
1246 
1247static int is_safe_filename(const char *name) {
1248 if ((name == NULL) || (name[0] == '\0')) {
1249 return 0;
1250 }
1251 if (strstr(name, "..") != NULL) {
1252 return 0;
1253 }
1254 for (const char *p = name; *p != '\0'; p++) {
1255 if ((*p == '/') || (*p == '\\')) {
1256 return 0;
1257 }
1258 }
1259 return 1;
1260}
1261#endif
1262 
1263/*===========================================================================*
1264 * REQUEST ROUTER
1265 *===========================================================================*/
1266 
1267#include "http_csrf.h"
1268 
1269http_response_t *http_api_handle(const http_request_t *request) {
1270 if (request == NULL) {
1271 return NULL;
1272 }
1273 
1274#if ENABLE_WEB_UPLOAD
1275 /* CSRF Protection for mutating requests */
1276 if (request->method == HTTP_METHOD_POST) {
1277 if (http_csrf_validate(request) != 0) {
1278 return error_json(HTTP_STATUS_403_FORBIDDEN,
1279 "Invalid or missing CSRF token");
1280 }
1281 }
1282#endif
1283 
1284 /* /api/list?path=... */
1285 if (strncmp(request->uri, "/api/list", 9) == 0) {
1286 return api_list(request);
1287 }
1288 
1289 /* /api/dirsize?path=... */
1290 if (strncmp(request->uri, "/api/dirsize", 12) == 0) {
1291 return api_dirsize(request);
1292 }
1293 
1294 /* Download manager — /api/download/start, status, pause, cancel
1295 *
1296 * IMPORTANT: These longer-prefix routes MUST come BEFORE the
1297 * generic /api/download handler below, because strncmp matches
1298 * left-to-right and "/api/download" (13 chars) is a prefix of
1299 * "/api/download/start" (19 chars).
1300 *
1301 * Route order: Match example:
1302 * /api/download/start → api_dl_start() ✓
1303 * /api/download/status → api_dl_status() ✓
1304 * /api/download/pause → api_dl_pause() ✓
1305 * /api/download/cancel → api_dl_cancel() ✓
1306 * /api/download?path= → api_download() ✓ (file download)
1307 */
1308 if (strncmp(request->uri, "/api/download/start", 19) == 0) {
1309 return api_dl_start(request);
1310 }
1311 if (strncmp(request->uri, "/api/download/status", 20) == 0) {
1312 return api_dl_status(request);
1313 }
1314 if (strncmp(request->uri, "/api/download/pause", 19) == 0) {
1315 return api_dl_pause(request);
1316 }
1317 if (strncmp(request->uri, "/api/download/cancel", 20) == 0) {
1318 return api_dl_cancel(request);
1319 }
1320 
1321 /* /api/download?path=... (file download — generic, MUST come after /start etc) */
1322 if (strncmp(request->uri, "/api/download", 13) == 0) {
1323 return api_download(request);
1324 }
1325 
1326 /* /api/stats/ram */
1327 if (strncmp(request->uri, "/api/stats/ram", 14) == 0) {
1328 return api_stats_ram(request);
1329 }
1330 
1331 /* /api/stats/system */
1332 if (strncmp(request->uri, "/api/stats/system", 17) == 0) {
1333 return api_stats_system(request);
1334 }
1335 
1336 /* /api/stats?path=... (legacy widget) */
1337 if (strncmp(request->uri, "/api/stats", 10) == 0) {
1338 return api_stats(request);
1339 }
1340 
1341 /* /api/disk/info */
1342 if (strncmp(request->uri, "/api/disk/info", 14) == 0) {
1343 return api_disk_info(request);
1344 }
1345 
1346 /* /api/disk/tree?path=... */
1347 if (strncmp(request->uri, "/api/disk/tree", 14) == 0) {
1348 return api_disk_tree(request);
1349 }
1350 
1351 /* POST /api/process/kill */
1352 if (strncmp(request->uri, "/api/process/kill", 17) == 0) {
1353 return api_process_kill(request);
1354 }
1355 
1356 /* GET /api/processes */
1357 if (strncmp(request->uri, "/api/processes", 14) == 0) {
1358 return api_processes(request);
1359 }
1360 
1361#if ENABLE_WEB_UPLOAD
1362 /* POST /api/create_file?path=...&name=... */
1363 if (strncmp(request->uri, "/api/create_file", 16) == 0) {
1364 return api_create_file(request);
1365 }
1366 
1367 /* POST /api/mkdir?path=...&name=... */
1368 if (strncmp(request->uri, "/api/mkdir", 10) == 0) {
1369 return api_mkdir(request);
1370 }
1371 
1372 /* POST /api/delete?path=... */
1373 if (strncmp(request->uri, "/api/delete", 11) == 0) {
1374 return api_delete(request);
1375 }
1376 
1377 /* POST /api/rename?path=...&name=... */
1378 if (strncmp(request->uri, "/api/rename", 11) == 0) {
1379 return api_rename(request);
1380 }
1381 
1382 /* POST /api/copy?src=...&dst=... */
1383 if (strncmp(request->uri, "/api/copy_progress", 18) == 0) {
1384 return api_copy_progress(request);
1385 }
1386 if (strncmp(request->uri, "/api/copy_cancel", 16) == 0) {
1387 return api_copy_cancel(request);
1388 }
1389 if (strncmp(request->uri, "/api/copy_pause", 15) == 0) {
1390 return api_copy_pause(request);
1391 }
1392 if (strncmp(request->uri, "/api/copy", 9) == 0) {
1393 return api_copy(request);
1394 }
1395#endif
1396 
1397 /* GET /api/game/meta?path=... — game metadata (title, icon base64) */
1398 if (strncmp(request->uri, "/api/game/meta", 14) == 0) {
1399 return api_game_meta(request);
1400 }
1401 
1402 /* GET /api/game/icon?path=... — game cover art PNG */
1403 if (strncmp(request->uri, "/api/game/icon", 14) == 0) {
1404 return api_game_icon(request);
1405 }
1406 
1407 /* POST /api/extract — archive extraction (libarchive) */
1408 if (strncmp(request->uri, "/api/extract_progress", 21) == 0) {
1409 return api_extract_progress(request);
1410 }
1411 if (strncmp(request->uri, "/api/extract_cancel", 19) == 0) {
1412 return api_extract_cancel(request);
1413 }
1414 if (strncmp(request->uri, "/api/extract", 12) == 0) {
1415 return api_extract(request);
1416 }
1417 
1418 /* POST /api/network/reset — flush TCP buffer accounting (Fix #4) */
1419 if (strncmp(request->uri, "/api/network/reset", 18) == 0) {
1420 return api_network_reset(request);
1421 }
1422 
1423 /* GET /api/admin/fan?threshold=... — set PS4/PS5 fan threshold */
1424 if (strncmp(request->uri, "/api/admin/fan", 14) == 0) {
1425 return api_admin_fan(request);
1426 }
1427 
1428 /* Games management API */
1429 if (strncmp(request->uri, "/api/admin/games/installed",
1430 sizeof("/api/admin/games/installed") - 1U) == 0) {
1431 return api_games_installed(request);
1432 }
1433 if (strncmp(request->uri, "/api/admin/games/icon",
1434 sizeof("/api/admin/games/icon") - 1U) == 0) {
1435 return api_games_icon(request);
1436 }
1437 if (strncmp(request->uri, "/api/admin/games/repair_visibility",
1438 sizeof("/api/admin/games/repair_visibility") - 1U) == 0) {
1439 return api_games_repair_visibility(request);
1440 }
1441 if (strncmp(request->uri, "/api/admin/games/uninstall",
1442 sizeof("/api/admin/games/uninstall") - 1U) == 0) {
1443 return api_games_uninstall(request);
1444 }
1445 if (strncmp(request->uri, "/api/admin/games/install_status",
1446 sizeof("/api/admin/games/install_status") - 1U) == 0) {
1447 return api_games_install_status(request);
1448 }
1449 if (strncmp(request->uri, "/api/admin/games/install",
1450 sizeof("/api/admin/games/install") - 1U) == 0) {
1451 return api_games_install(request);
1452 }
1453 if (strncmp(request->uri, "/api/admin/games/reinstall",
1454 sizeof("/api/admin/games/reinstall") - 1U) == 0) {
1455 return api_games_reinstall(request);
1456 }
1457 
1458 /* GET /api/admin/launch?id=... — launch PS4/PS5 app by title ID */
1459 if (strncmp(request->uri, "/api/admin/launch", 17) == 0) {
1460 return api_admin_launch(request);
1461 }
1462 
1463 /* Legacy frontend compatibility (old embedded UIs) */
1464 if (strncmp(request->uri, "/api/stream/status", 18) == 0) {
1465 return api_legacy_disabled_json(
1466 "{\"ok\":true,\"enabled\":false,\"status\":\"offline\"}");
1467 }
1468 if (strncmp(request->uri, "/api/stream/start", 17) == 0 ||
1469 strncmp(request->uri, "/api/stream/stop", 16) == 0 ||
1470 strncmp(request->uri, "/api/stream", 11) == 0) {
1471 return api_legacy_disabled_json(
1472 "{\"ok\":false,\"message\":\"Stream disabled\"}");
1473 }
1474 if (strncmp(request->uri, "/api/admin/installed", 20) == 0) {
1475 return api_legacy_disabled_json(
1476 "{\"ok\":true,\"installed\":false}");
1477 }
1478 if (strncmp(request->uri, "/api/admin/install", 18) == 0) {
1479 return api_legacy_disabled_json(
1480 "{\"ok\":false,\"message\":\"Install API not available\"}");
1481 }
1482 
1483 /* Static resources (index.html, style.css, app.js) */
1484 return serve_static(request);
1485}
1486 
1487/*===========================================================================*
1488 * GET /api/list — Directory Listing
1489 *
1490 * RESPONSE:
1491 * {
1492 * "path": "/some/dir",
1493 * "entries": [
1494 * { "name": "file.txt", "type": "file", "size": 1024 },
1495 * { "name": "subdir", "type": "directory", "size": 0 }
1496 * ]
1497 * }
1498 *===========================================================================*/
1499 
1500static http_response_t *api_list(const http_request_t *request) {
1501 /* Extract ?path= */
1502 const char *query = strchr(request->uri, '?');
1503 char path[1024] = "/";
1504 
1505 if (query != NULL) {
1506 (void)parse_path_param(query, path, sizeof(path));
1507 }
1508 
1509 char safe[FTP_PATH_MAX];
1510 if (!validate_path(path, safe, sizeof(safe))) {
1511 return error_json(HTTP_STATUS_403_FORBIDDEN,
1512 "Path traversal attempt detected");
1513 }
1514 
1515 DIR *dir = opendir(safe);
1516 if (dir == NULL) {
1517 return error_json(HTTP_STATUS_404_NOT_FOUND, "Directory not found");
1518 }
1519 
1520 /*
1521 * STREAMING JSON (Chunked Transfer Encoding)
1522 * Instead of building the whole JSON in memory (which can exceed 512KB),
1523 * we send the headers and the opening JSON, then let http_server.c
1524 * stream the entries one by one.
1525 */
1526 
1527 /* Build response headers */
1528 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
1529 http_response_add_header(resp, "Content-Type", "application/json");
1530 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
1531 http_response_add_header(resp, "Transfer-Encoding", "chunked");
1532 
1533 /* Prepare the opening JSON: {"path":"<escaped>","entries":[ */
1534 char prefix[2048];
1535 size_t pos = 0;
1536 size_t cap = sizeof(prefix);
1537 
1538 pos += (size_t)snprintf(prefix + pos, cap - pos, "{\"path\":\"");
1539 (void)json_escape_append(prefix, cap, &pos, path);
1540 pos += (size_t)snprintf(prefix + pos, cap - pos, "\",\"entries\":[");
1541 
1542 /* Finalize headers now (adds \r\n after headers) */
1543 http_response_finalize(resp);
1544 
1545 /* Now append the prefix as the first CHUNK */
1546 char chunk_header[32];
1547 int header_len = snprintf(chunk_header, sizeof(chunk_header), "%zx\r\n", pos);
1548 
1549 if (http_response_append_raw(resp, chunk_header, (size_t)header_len) < 0 ||
1550 http_response_append_raw(resp, prefix, pos) < 0 ||
1551 http_response_append_raw(resp, "\r\n", 2) < 0) {
1552 http_response_destroy(resp);
1553 closedir(dir);
1554 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1555 }
1556 
1557 /* Set up streaming state */
1558 resp->stream_dir = dir;
1559 strncpy(resp->stream_path, path, sizeof(resp->stream_path) - 1);
1560 resp->stream_path[sizeof(resp->stream_path) - 1] = '\0';
1561 
1562 return resp;
1563}
1564 
1565/*===========================================================================*
1566 * GET /api/dirsize?path=<dir> — Recursive directory size
1567 *
1568 * Returns the total size in bytes of all regular files under path.
1569 * Called lazily by the frontend after the listing is already rendered,
1570 * so it does not block the initial directory load.
1571 *
1572 * RESPONSE: {"path":"/some/dir","size":123456789}
1573 *===========================================================================*/
1574 
1575static http_response_t *api_dirsize(const http_request_t *request) {
1576 const char *query = strchr(request->uri, '?');
1577 char path[1024] = "/";
1578 
1579 if (query != NULL) {
1580 (void)parse_path_param(query, path, sizeof(path));
1581 }
1582 
1583 char safe[FTP_PATH_MAX];
1584 if (!validate_path(path, safe, sizeof(safe))) {
1585 return error_json(HTTP_STATUS_403_FORBIDDEN,
1586 "Path traversal attempt detected");
1587 }
1588 
1589 struct stat st;
1590 if (stat(safe, &st) != 0 || !S_ISDIR(st.st_mode)) {
1591 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Not a directory");
1592 }
1593 
1594 int partial = 0;
1595 uint64_t sz = http_dir_size_with_partial(safe, &partial);
1596 
1597 char body[256];
1598 size_t pos = 0;
1599 size_t cap = sizeof(body);
1600 
1601 if (buf_append_cstr(body, cap, &pos, "{\"path\":\"") != 0 ||
1602 json_escape_append(body, cap, &pos, path) != 0 ||
1603 buf_append_cstr(body, cap, &pos, "\",\"size\":") != 0 ||
1604 buf_append_u64(body, cap, &pos, sz) != 0 ||
1605 buf_append_cstr(body, cap, &pos,
1606 partial ? ",\"partial\":true}"
1607 : ",\"partial\":false}") != 0) {
1608 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1609 }
1610 
1611 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
1612 http_response_add_header(resp, "Content-Type", "application/json");
1613 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
1614 http_response_add_header(resp, "Cache-Control", "no-store");
1615 http_response_set_body(resp, body, pos);
1616 return resp;
1617}
1618 
1619/*===========================================================================*
1620 * GET /api/download — File Download
1621 *
1622 * Reads the file and sends it with Content-Disposition: attachment.
1623 * For large files, uses the sendfile_fd field so the server can
1624 * stream with sendfile() / read+write loop.
1625 *===========================================================================*/
1626 
1627static http_response_t *api_download(const http_request_t *request) {
1628 const char *query = strchr(request->uri, '?');
1629 char path[1024] = "";
1630 
1631 if (query != NULL) {
1632 (void)parse_path_param(query, path, sizeof(path));
1633 }
1634 
1635 if (path[0] == '\0') {
1636 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing path parameter");
1637 }
1638 
1639 char safe[FTP_PATH_MAX];
1640 if (!validate_path(path, safe, sizeof(safe))) {
1641 return error_json(HTTP_STATUS_403_FORBIDDEN,
1642 "Path traversal attempt detected");
1643 }
1644 
1645 /* Open file */
1646 int fd = open(safe, O_RDONLY);
1647 if (fd < 0) {
1648 return error_json(HTTP_STATUS_404_NOT_FOUND, "File not found");
1649 }
1650 
1651 struct stat st;
1652 if (fstat(fd, &st) < 0 || S_ISDIR(st.st_mode)) {
1653 close(fd);
1654 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Not a regular file");
1655 }
1656 
1657 /* Extract basename for Content-Disposition */
1658 const char *basename = strrchr(path, '/');
1659 basename = (basename != NULL) ? basename + 1 : path;
1660 
1661 /* Build response headers */
1662 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
1663 /*
1664 * SAFETY: http_response_create() returns NULL when the response pool is
1665 * exhausted (HTTP_MAX_CONNECTIONS concurrent responses already in flight).
1666 * Without this check the subsequent struct-field assignments would
1667 * dereference a NULL pointer, causing SIGSEGV. The open fd must be closed
1668 * here to prevent a file-descriptor leak — if we returned NULL without
1669 * closing it, the fd would be lost forever because no other code path holds
1670 * a reference to it.
1671 *
1672 * @pre fd >= 0 and valid (opened above)
1673 * @post On NULL return: fd is closed, no resources are leaked
1674 */
1675 if (resp == NULL) {
1676 close(fd);
1677 return NULL; /* http_handle_request() will synthesise a 500 response */
1678 }
1679 http_response_add_header(resp, "Content-Type", "application/octet-stream");
1680 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
1681 
1682 char disposition[512];
1683 snprintf(disposition, sizeof(disposition), "attachment; filename=\"%s\"",
1684 basename);
1685 http_response_add_header(resp, "Content-Disposition", disposition);
1686 
1687 char len_str[32];
1688 snprintf(len_str, sizeof(len_str), "%lld", (long long)st.st_size);
1689 http_response_add_header(resp, "Content-Length", len_str);
1690 
1691 /*
1692 * Finalize headers (appends the blank \r\n line that separates headers
1693 * from the body). Failure here means the response buffer is full —
1694 * destroy the response and close the fd rather than sending a malformed
1695 * HTTP message with missing header terminator.
1696 *
1697 * @post On failure: fd is closed, resp is freed, no resources are leaked
1698 */
1699 if (http_response_finalize(resp) != 0) {
1700 close(fd);
1701 http_response_destroy(resp);
1702 return NULL;
1703 }
1704 
1705 /* Store fd so http_server.c can stream the file content */
1706 resp->sendfile_fd = fd;
1707 resp->sendfile_offset = 0;
1708 resp->sendfile_count = (size_t)st.st_size;
1709 
1710 /*
1711 * SENDFILE SAFETY CHECK — must happen before http_server.c touches the fd.
1712 *
1713 * On PS5/PS4 (FreeBSD), calling sendfile(2) on vnodes backed by certain
1714 * filesystems causes an IMMEDIATE KERNEL PANIC:
1715 *
1716 * exfatfs — USB drives formatted exFAT: the kernel exFAT vnode does not
1717 * implement vm_pager_ops, so sendfile() dereferences a null
1718 * function pointer.
1719 * msdosfs — FAT32 USB drives: same broken pager ops.
1720 * nullfs — bind-mount: inherits the pager of the origin vnode. If the
1721 * origin is exFAT, the nullfs vnode also KPs.
1722 * pfsmnt — PlayStation FS mount (/user/av_contents, game data mounts):
1723 * sendfile() sends corrupt/incomplete data.
1724 * pfs — raw PFS on internal SSD (/data, /user):
1725 * same broken pager as pfsmnt.
1726 *
1727 * CRITICAL: on these filesystems errno is NEVER set — the kernel triple-
1728 * faults before returning to userspace. Our EINVAL fallback in
1729 * pal_sendfile() cannot help because execution never reaches it.
1730 *
1731 * The fix: detect the filesystem type on the open fd with fstatfs() and set
1732 * sendfile_safe = 0. http_server.c will then use pread()+send_all() for
1733 * the entire transfer, bypassing sendfile(2) entirely.
1734 *
1735 * On Linux and macOS sendfile() is always safe; sendfile_safe = 1.
1736 * On FreeBSD/PS5/PS4 default to 0 (unsafe) and only enable for filesystems
1737 * known to be safe (ufs, tmpfs, zfs, ffs — internal NVMe on PS5 via
1738 * the native FFS layer if ever used).
1739 */
1740#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5) || defined(__FreeBSD__)
1741 {
1742 int sf_safe = 0; /* conservative default: assume unsafe */
1743 struct statfs sfs;
1744 if (http_fstatfs(fd, &sfs) == 0) {
1745 const char *t = sfs.f_fstypename;
1746 /*
1747 * Whitelist: filesystems known to work correctly with sendfile(2).
1748 * Everything not on this list is treated as unsafe.
1749 *
1750 * ufs/ffs — standard FreeBSD FFS (unlikely on PS5 but correct)
1751 * tmpfs — memory-backed (safe, though uncommon for large files)
1752 * zfs — ZFS (safe on standard FreeBSD)
1753 *
1754 * NOT whitelisted (KP or corrupt data):
1755 * exfatfs, msdosfs, nullfs, pfsmnt, pfs
1756 */
1757 if ((strcmp(t, "ufs") == 0) || (strcmp(t, "ffs") == 0) ||
1758 (strcmp(t, "tmpfs") == 0) || (strcmp(t, "zfs") == 0)) {
1759 sf_safe = 1;
1760 }
1761 }
1762 /* fstatfs failure: stay with 0 (unsafe) — tolerate the perf hit */
1763 resp->sendfile_safe = sf_safe;
1764 }
1765#else
1766 /* Linux / macOS: sendfile() is always safe */
1767 resp->sendfile_safe = 1;
1768#endif
1769 
1770 return resp;
1771}
1772 
1773static http_response_t *api_stats(const http_request_t *request) {
1774 const char *query = strchr(request->uri, '?');
1775 char path[1024] = "/";
1776 
1777 if (query != NULL) {
1778 (void)parse_path_param(query, path, sizeof(path));
1779 }
1780 
1781 char safe[FTP_PATH_MAX];
1782 if (!validate_path(path, safe, sizeof(safe))) {
1783 return error_json(HTTP_STATUS_403_FORBIDDEN, "Forbidden path");
1784 }
1785 
1786 uint64_t disk_total = 0U;
1787 uint64_t disk_used = 0U;
1788 uint64_t disk_free = 0U;
1789 const char *disk_path = NULL;
1790 int disk_ok = get_best_disk_stats(path, &disk_path, &disk_total, &disk_used,
1791 &disk_free);
1792 
1793 uint32_t items = 0U;
1794 int items_ok = count_dir_items(path, &items);
1795 
1796 uint64_t boot_epoch = 0U;
1797 int boot_ok = get_boot_epoch_seconds(&boot_epoch);
1798 
1799 int32_t temp_c = 0;
1800 int temp_ok = get_cpu_temp_c(&temp_c);
1801 
1802 char body[1024];
1803 size_t pos = 0U;
1804 size_t cap = sizeof(body);
1805 
1806 if (buf_append_cstr(body, cap, &pos, "{\"path\":\"") != 0) {
1807 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1808 }
1809 if (json_escape_append(body, cap, &pos, path) != 0) {
1810 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1811 }
1812 if (buf_append_cstr(body, cap, &pos, "\"") != 0) {
1813 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1814 }
1815 
1816 if (disk_ok == 0) {
1817 if (buf_append_cstr(body, cap, &pos, ",\"disk_used\":") != 0 ||
1818 buf_append_u64(body, cap, &pos, disk_used) != 0 ||
1819 buf_append_cstr(body, cap, &pos, ",\"disk_total\":") != 0 ||
1820 buf_append_u64(body, cap, &pos, disk_total) != 0 ||
1821 buf_append_cstr(body, cap, &pos, ",\"disk_free\":") != 0 ||
1822 buf_append_u64(body, cap, &pos, disk_free) != 0 ||
1823 buf_append_cstr(body, cap, &pos, ",\"disk_path\":\"") != 0 ||
1824 json_escape_append(body, cap, &pos,
1825 (disk_path != NULL) ? disk_path : "") != 0 ||
1826 buf_append_cstr(body, cap, &pos, "\"") != 0) {
1827 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1828 }
1829 } else {
1830 if (buf_append_cstr(body, cap, &pos,
1831 ",\"disk_used\":null,\"disk_total\":null,"
1832 "\"disk_free\":null,\"disk_path\":null") != 0) {
1833 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1834 }
1835 }
1836 
1837 if (temp_ok == 0) {
1838 if (buf_append_cstr(body, cap, &pos, ",\"cpu_temp\":") != 0 ||
1839 buf_append_i32(body, cap, &pos, temp_c) != 0) {
1840 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1841 }
1842 } else {
1843 if (buf_append_cstr(body, cap, &pos, ",\"cpu_temp\":null") != 0) {
1844 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1845 }
1846 }
1847 
1848 if (boot_ok == 0) {
1849 if (buf_append_cstr(body, cap, &pos, ",\"uptime\":") != 0 ||
1850 buf_append_u64(body, cap, &pos, boot_epoch) != 0) {
1851 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1852 }
1853 } else {
1854 if (buf_append_cstr(body, cap, &pos, ",\"uptime\":null") != 0) {
1855 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1856 }
1857 }
1858 
1859 if (items_ok == 0) {
1860 if (buf_append_cstr(body, cap, &pos, ",\"items_in_dir\":") != 0 ||
1861 buf_append_u32(body, cap, &pos, items) != 0) {
1862 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1863 }
1864 } else {
1865 if (buf_append_cstr(body, cap, &pos, ",\"items_in_dir\":null") != 0) {
1866 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1867 }
1868 }
1869 
1870 if (buf_append_cstr(body, cap, &pos, "}") != 0) {
1871 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
1872 }
1873 
1874 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
1875 http_response_add_header(resp, "Content-Type", "application/json");
1876 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
1877 http_response_set_body(resp, body, pos);
1878 return resp;
1879}
1880 
1881#if ENABLE_WEB_UPLOAD
1882static http_response_t *api_create_file(const http_request_t *request) {
1883 if (request->method != HTTP_METHOD_POST) {
1884 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED,
1885 "Use POST for this endpoint");
1886 }
1887 
1888 const char *query = strchr(request->uri, '?');
1889 if (query == NULL) {
1890 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing query string");
1891 }
1892 
1893 char dir_path[1024] = "/";
1894 char name[256];
1895 
1896 if (parse_path_param(query, dir_path, sizeof(dir_path)) != 0) {
1897 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing or invalid path");
1898 }
1899 if (parse_name_param(query, name, sizeof(name)) != 0) {
1900 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing or invalid name");
1901 }
1902 if (!is_safe_filename(name)) {
1903 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Invalid file name");
1904 }
1905 char safe_dir[FTP_PATH_MAX];
1906 if (!validate_path(dir_path, safe_dir, sizeof(safe_dir))) {
1907 return error_json(HTTP_STATUS_403_FORBIDDEN, "Forbidden path");
1908 }
1909 
1910 char full[FTP_PATH_MAX];
1911 if (strcmp(safe_dir, "/") == 0) {
1912 (void)snprintf(full, sizeof(full), "/%s", name);
1913 } else {
1914 (void)snprintf(full, sizeof(full), "%s/%s", safe_dir, name);
1915 }
1916 
1917 char safe_full[FTP_PATH_MAX];
1918 if (!validate_path(full, safe_full, sizeof(safe_full))) {
1919 return error_json(HTTP_STATUS_403_FORBIDDEN, "Forbidden path");
1920 }
1921 
1922 int fd = pal_file_open(safe_full, O_WRONLY | O_CREAT | O_TRUNC, 0666);
1923 if (fd < 0) {
1924 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Failed to create file");
1925 }
1926 
1927 if ((request->body != NULL) && (request->body_length > 0U)) {
1928 if (pal_file_write_all(fd, request->body, request->body_length) < 0) {
1929 (void)pal_file_close(fd);
1930 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Failed to write file");
1931 }
1932 }
1933 
1934 (void)pal_file_close(fd);
1935 
1936 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
1937 http_response_add_header(resp, "Content-Type", "application/json");
1938 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
1939 
1940 char body[512];
1941 int len =
1942 snprintf(body, sizeof(body),
1943 "{\"ok\":true,\"path\":\"%s\",\"name\":\"%s\"}", full, name);
1944 http_response_set_body(resp, body, (size_t)len);
1945 return resp;
1946}
1947 
1948/*===========================================================================*
1949 * POST /api/mkdir — Create directory
1950 *
1951 * POST /api/mkdir?path=/parent&name=new_folder
1952 * Returns: {"ok":true,"path":"/parent/new_folder","name":"new_folder"}
1953 *===========================================================================*/
1954 
1955static http_response_t *api_mkdir(const http_request_t *request) {
1956 if (request->method != HTTP_METHOD_POST) {
1957 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED,
1958 "Use POST for this endpoint");
1959 }
1960 
1961 const char *query = strchr(request->uri, '?');
1962 if (query == NULL) {
1963 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing query string");
1964 }
1965 
1966 char dir_path[1024] = "/";
1967 char name[256];
1968 
1969 if (parse_path_param(query, dir_path, sizeof(dir_path)) != 0) {
1970 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing or invalid path");
1971 }
1972 if (parse_name_param(query, name, sizeof(name)) != 0) {
1973 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing or invalid name");
1974 }
1975 if (!is_safe_filename(name)) {
1976 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Invalid folder name");
1977 }
1978 char safe_dir[FTP_PATH_MAX];
1979 if (!validate_path(dir_path, safe_dir, sizeof(safe_dir))) {
1980 return error_json(HTTP_STATUS_403_FORBIDDEN, "Forbidden path");
1981 }
1982 
1983 char full[FTP_PATH_MAX];
1984 if (strcmp(safe_dir, "/") == 0) {
1985 (void)snprintf(full, sizeof(full), "/%s", name);
1986 } else {
1987 (void)snprintf(full, sizeof(full), "%s/%s", safe_dir, name);
1988 }
1989 
1990 char safe_full[FTP_PATH_MAX];
1991 if (!validate_path(full, safe_full, sizeof(safe_full))) {
1992 return error_json(HTTP_STATUS_403_FORBIDDEN, "Forbidden path");
1993 }
1994 
1995 if (mkdir(safe_full, 0777) != 0 && errno != EEXIST) {
1996 char msg[128];
1997 snprintf(msg, sizeof(msg), "mkdir failed: %s", strerror(errno));
1998 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, msg);
1999 }
2000 
2001 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
2002 http_response_add_header(resp, "Content-Type", "application/json");
2003 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
2004 
2005 char body[512];
2006 int len =
2007 snprintf(body, sizeof(body),
2008 "{\"ok\":true,\"path\":\"%s\",\"name\":\"%s\"}", full, name);
2009 http_response_set_body(resp, body, (size_t)len);
2010 return resp;
2011}
2012 
2013/*===========================================================================*
2014 * POST /api/delete — Delete file or empty directory
2015 *
2016 * ┌─────────────────────────────────────────────┐
2017 * │ POST /api/delete?path=/some/file.txt │
2018 * │ │
2019 * │ file -> pal_file_delete(path) │
2020 * │ dir -> pal_dir_remove(path) (empty) │
2021 * │ result -> {"ok":true} │
2022 * └─────────────────────────────────────────────┘
2023 *===========================================================================*/
2024 
2025static http_response_t *api_delete(const http_request_t *request) {
2026 if (request->method != HTTP_METHOD_POST) {
2027 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED,
2028 "Use POST for this endpoint");
2029 }
2030 
2031 const char *query = strchr(request->uri, '?');
2032 if (query == NULL) {
2033 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing query string");
2034 }
2035 
2036 char path[1024] = "";
2037 if (parse_path_param(query, path, sizeof(path)) != 0) {
2038 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing or invalid path");
2039 }
2040 
2041 char safe[FTP_PATH_MAX];
2042 if (!validate_path(path, safe, sizeof(safe))) {
2043 return error_json(HTTP_STATUS_403_FORBIDDEN, "Forbidden path");
2044 }
2045 
2046 /* Refuse to delete the root itself */
2047 if (strcmp(safe, g_http_root) == 0) {
2048 return error_json(HTTP_STATUS_403_FORBIDDEN, "Cannot delete root");
2049 }
2050 
2051 struct stat st;
2052 if (stat(safe, &st) != 0) {
2053 return error_json(HTTP_STATUS_404_NOT_FOUND, "Path not found");
2054 }
2055 
2056 ftp_error_t rc;
2057 if (S_ISDIR(st.st_mode)) {
2058 /*
2059 * DIRECTORY DELETE
2060 *
2061 * Standard rmdir(2) fails with ENOTEMPTY if the directory has any
2062 * contents — including hidden system files (e.g. PFS metadata on
2063 * /data, exFAT recycle-bin entries on USB) that the user cannot see
2064 * from a normal listing. This caused "random" delete failures because
2065 * some directories appeared empty in the UI but were not at the kernel
2066 * level.
2067 *
2068 * Strategy:
2069 * 1. Try rmdir() first — fast, safe, and correct for truly empty dirs.
2070 * 2. If that returns ENOTEMPTY and the caller passed ?recursive=1,
2071 * fall back to pal_dir_remove_recursive() (depth-first unlink tree).
2072 * 3. Without ?recursive=1 on a non-empty dir: return 409 Conflict
2073 * with a clear message so the web UI can prompt for confirmation
2074 * rather than silently succeeding or giving a generic 500.
2075 *
2076 * SAFETY: recursive delete is opt-in — the client must explicitly send
2077 * ?recursive=1. A plain POST /api/delete?path=X on a non-empty dir
2078 * returns 409 instead of deleting everything silently.
2079 *
2080 * @note pal_dir_remove_recursive() is the same depth-first cleanup
2081 * used in the rollback path of pal_copy_cross_device_r_ex, so
2082 * its error handling (unlink failures on locked files, etc.) is
2083 * already well-exercised.
2084 */
2085 rc = pal_dir_remove(safe); /* try rmdir first */
2086 
2087 if (rc != FTP_OK) {
2088 /* Check if the failure was ENOTEMPTY (or our mapped error code) */
2089 const char *recursive_flag = strstr(query, "recursive=1");
2090 if (recursive_flag != NULL) {
2091 /* Caller explicitly requested recursive delete — proceed */
2092 rc = pal_dir_remove_recursive_pub(safe);
2093 if (rc != FTP_OK) {
2094 return error_json(
2095 HTTP_STATUS_500_INTERNAL_ERROR,
2096 "Recursive delete failed (permission denied or I/O error)");
2097 }
2098 } else {
2099 /*
2100 * Return 409 Conflict — the directory is not empty and the
2101 * caller did not ask for recursive deletion.
2102 *
2103 * The web UI should catch this and either:
2104 * (a) Show a confirmation dialog ("Delete all contents?") then
2105 * retry with ?recursive=1, or
2106 * (b) Tell the user to empty the folder first.
2107 */
2108 return error_json(HTTP_STATUS_409_CONFLICT,
2109 "Directory is not empty. Use recursive=1 to force.");
2110 }
2111 }
2112 } else {
2113 rc = pal_file_delete(safe);
2114 if (rc != FTP_OK) {
2115 return error_json(HTTP_STATUS_500_INTERNAL_ERROR,
2116 "Failed to delete file");
2117 }
2118 }
2119 
2120 /* POST-DELETE VERIFICATION: Ensure the path was actually deleted */
2121 struct stat verify_st;
2122 if (stat(safe, &verify_st) == 0) {
2123 /* Path still exists — delete operation failed silently */
2124 return error_json(HTTP_STATUS_500_INTERNAL_ERROR,
2125 "Delete operation failed: path still exists (permission "
2126 "denied or I/O error)");
2127 }
2128 
2129 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
2130 http_response_add_header(resp, "Content-Type", "application/json");
2131 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
2132 const char *body = "{\"ok\":true}";
2133 http_response_set_body(resp, body, strlen(body));
2134 return resp;
2135}
2136 
2137/*===========================================================================*
2138 * POST /api/rename — Rename file or directory in-place
2139 *
2140 * ┌─────────────────────────────────────────────────┐
2141 * │ POST /api/rename?path=/dir/old.txt&name=new │
2142 * │ │
2143 * │ old = validate_path(path) │
2144 * │ new = parent(old) + '/' + name │
2145 * │ pal_file_rename(old, new) │
2146 * │ result -> {"ok":true,"path":"/dir/new"} │
2147 * └─────────────────────────────────────────────────┘
2148 *===========================================================================*/
2149 
2150static http_response_t *api_rename(const http_request_t *request) {
2151 if (request->method != HTTP_METHOD_POST) {
2152 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED,
2153 "Use POST for this endpoint");
2154 }
2155 
2156 const char *query = strchr(request->uri, '?');
2157 if (query == NULL) {
2158 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing query string");
2159 }
2160 
2161 char path[1024] = "";
2162 char name[256];
2163 if (parse_path_param(query, path, sizeof(path)) != 0) {
2164 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing or invalid path");
2165 }
2166 if (parse_name_param(query, name, sizeof(name)) != 0) {
2167 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing or invalid name");
2168 }
2169 if (!is_safe_filename(name)) {
2170 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Invalid file name");
2171 }
2172 
2173 /* Validate old path */
2174 char safe_old[FTP_PATH_MAX];
2175 if (!validate_path(path, safe_old, sizeof(safe_old))) {
2176 return error_json(HTTP_STATUS_403_FORBIDDEN, "Forbidden path");
2177 }
2178 
2179 /* Check old exists */
2180 if (pal_path_exists(safe_old) != 1) {
2181 return error_json(HTTP_STATUS_404_NOT_FOUND, "Path not found");
2182 }
2183 
2184 /*
2185 * Build new path: parent(safe_old) + '/' + name
2186 *
2187 * /data/files/old.txt -> /data/files/ (parent)
2188 * parent + "new.txt" -> /data/files/new.txt
2189 */
2190 char new_path[FTP_PATH_MAX];
2191 const char *last_slash = strrchr(safe_old, '/');
2192 if (last_slash == NULL) {
2193 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Internal path error");
2194 }
2195 size_t parent_len = (size_t)(last_slash - safe_old);
2196 if (parent_len == 0U) {
2197 /* file is directly under root "/" */
2198 (void)snprintf(new_path, sizeof(new_path), "/%s", name);
2199 } else {
2200 (void)snprintf(new_path, sizeof(new_path), "%.*s/%s", (int)parent_len,
2201 safe_old, name);
2202 }
2203 
2204 /* Validate new path stays within root */
2205 char safe_new[FTP_PATH_MAX];
2206 if (!validate_path(new_path, safe_new, sizeof(safe_new))) {
2207 return error_json(HTTP_STATUS_403_FORBIDDEN, "Destination forbidden");
2208 }
2209 
2210 ftp_error_t rc = pal_file_rename(safe_old, safe_new);
2211 if (rc != FTP_OK) {
2212 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Rename failed");
2213 }
2214 
2215 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
2216 http_response_add_header(resp, "Content-Type", "application/json");
2217 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
2218 
2219 char body[512];
2220 int len =
2221 snprintf(body, sizeof(body), "{\"ok\":true,\"path\":\"%s\"}", new_path);
2222 http_response_set_body(resp, body, (size_t)len);
2223 return resp;
2224}
2225 
2226/*===========================================================================*
2227 * COPY PROGRESS TRACKING (background pthread)
2228 *
2229 * ┌──────────────────────────────────────────────────────┐
2230 * │ Browser Server │
2231 * │ POST /api/copy ──────────► spawn pthread │
2232 * │ ◄── {ok:true,async:true} │ │
2233 * │ │ copy thread │
2234 * │ GET /api/copy_progress ◄────── atomic counters │
2235 * │ (polled every 500ms) │ │
2236 * │ ▼ │
2237 * │ GET /api/copy_progress ──► done=true │
2238 * └──────────────────────────────────────────────────────┘
2239 *===========================================================================*/
2240 
2241#include <pthread.h>
2242 
2243typedef struct {
2244 _Atomic uint64_t bytes_copied;
2245 _Atomic uint64_t total_bytes;
2246 _Atomic int active; /* 1 while copy thread is running */
2247 _Atomic int done; /* 1 when copy finished */
2248 _Atomic int error; /* 1 if copy failed */
2249 _Atomic int cancel; /* 1 to request cancellation */
2250 _Atomic int paused; /* 1 to pause, 0 to resume */
2251 _Atomic int error_code; /* ftp_error_t value on failure */
2252 _Atomic int error_errno; /* errno captured at failure point */
2253} copy_progress_t;
2254 
2255static copy_progress_t g_copy_progress = {0};
2256 
2257static int copy_progress_cb(uint64_t bytes_copied, void *user_data) {
2258 (void)user_data;
2259 atomic_store(&g_copy_progress.bytes_copied, bytes_copied);
2260 
2261 /* Pause: spin-wait in 100 ms increments while the flag is set.
2262 * Check cancel each iteration so the user can abort while paused. */
2263 while (atomic_load(&g_copy_progress.paused) != 0) {
2264 if (atomic_load(&g_copy_progress.cancel) != 0) {
2265 return -1;
2266 }
2267 usleep(100000); /* 100 ms */
2268 }
2269 
2270 /* Check cancellation flag — return -1 to abort copy */
2271 return (atomic_load(&g_copy_progress.cancel) != 0) ? -1 : 0;
2272}
2273 
2274/* Background copy thread */
2275typedef struct {
2276 char src[FTP_PATH_MAX];
2277 char dst[FTP_PATH_MAX];
2278 int *out_errno; /* points to g_copy_progress.error_errno storage (unused;
2279 errno captured inside) */
2280} copy_thread_args_t;
2281 
2282static void *copy_thread_fn(void *arg) {
2283 copy_thread_args_t *a = (copy_thread_args_t *)arg;
2284 
2285 int saved_errno = 0;
2286 ftp_error_t rc = pal_file_copy_recursive_ex(
2287 a->src, a->dst, 1, copy_progress_cb, NULL, &saved_errno);
2288 if ((rc != FTP_OK) || (atomic_load(&g_copy_progress.cancel) != 0)) {
2289 atomic_store(&g_copy_progress.error, 1);
2290 atomic_store(&g_copy_progress.error_code, (int)rc);
2291 atomic_store(&g_copy_progress.error_errno, saved_errno);
2292 }
2293 atomic_store(&g_copy_progress.active, 0);
2294 atomic_store(&g_copy_progress.done, 1);
2295 
2296 free(a);
2297 return NULL;
2298}
2299 
2300/* GET /api/copy_progress */
2301static http_response_t *api_copy_progress(const http_request_t *request) {
2302 (void)request;
2303 
2304 uint64_t copied = atomic_load(&g_copy_progress.bytes_copied);
2305 uint64_t total = atomic_load(&g_copy_progress.total_bytes);
2306 int active = atomic_load(&g_copy_progress.active);
2307 int done = atomic_load(&g_copy_progress.done);
2308 int err = atomic_load(&g_copy_progress.error);
2309 int err_code = atomic_load(&g_copy_progress.error_code);
2310 int err_errno = atomic_load(&g_copy_progress.error_errno);
2311 
2312 int is_paused = atomic_load(&g_copy_progress.paused);
2313 
2314 char body[320];
2315 int len =
2316 snprintf(body, sizeof(body),
2317 "{\"active\":%s,\"done\":%s,\"error\":%s,\"paused\":%s,"
2318 "\"error_code\":%d,"
2319 "\"error_errno\":%d,"
2320 "\"bytes_copied\":%" PRIu64 ",\"total_bytes\":%" PRIu64 "}",
2321 active ? "true" : "false", done ? "true" : "false",
2322 err ? "true" : "false", is_paused ? "true" : "false", err_code,
2323 err_errno, copied, total);
2324 
2325 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
2326 http_response_add_header(resp, "Content-Type", "application/json");
2327 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
2328 http_response_set_body(resp, body, (size_t)len);
2329 return resp;
2330}
2331 
2332/* POST /api/copy_cancel */
2333static http_response_t *api_copy_cancel(const http_request_t *request) {
2334 (void)request;
2335 atomic_store(&g_copy_progress.cancel, 1);
2336 
2337 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
2338 http_response_add_header(resp, "Content-Type", "application/json");
2339 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
2340 const char *body = "{\"ok\":true}";
2341 http_response_set_body(resp, body, strlen(body));
2342 return resp;
2343}
2344 
2345/* POST /api/copy_pause — toggle pause/resume */
2346static http_response_t *api_copy_pause(const http_request_t *request) {
2347 (void)request;
2348 int cur = atomic_load(&g_copy_progress.paused);
2349 int next = (cur != 0) ? 0 : 1;
2350 atomic_store(&g_copy_progress.paused, next);
2351 
2352 char body[64];
2353 int len = snprintf(body, sizeof(body), "{\"ok\":true,\"paused\":%s}",
2354 next ? "true" : "false");
2355 
2356 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
2357 http_response_add_header(resp, "Content-Type", "application/json");
2358 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
2359 http_response_set_body(resp, body, (size_t)len);
2360 return resp;
2361}
2362 
2363/*===========================================================================*
2364 * POST /api/copy — Server-side file/directory copy
2365 *
2366 * ┌──────────────────────────────────────────────────────┐
2367 * │ POST /api/copy?path=/src/file&dst=/dest/folder │
2368 * │ │
2369 * │ src = validate_path(path) │
2370 * │ dst = validate_path(dst) + '/' + basename(src) │
2371 * │ pal_file_copy_recursive_ex(src, dst, keep_src=1) │
2372 * │ result -> {"ok":true} │
2373 * └──────────────────────────────────────────────────────┘
2374 *===========================================================================*/
2375 
2376static int parse_dst_param(const char *query, char *out, size_t out_size) {
2377 if (query == NULL || out == NULL) {
2378 return -1;
2379 }
2380 if (out_size < 2U) {
2381 return -1;
2382 }
2383 
2384 const char *start = strstr(query, "dst=");
2385 if (start == NULL) {
2386 return -1;
2387 }
2388 start += 4; /* skip "dst=" */
2389 
2390 size_t in_pos = 0U;
2391 size_t out_pos = 0U;
2392 
2393 while ((start[in_pos] != '\0') && (start[in_pos] != '&') &&
2394 (out_pos < (out_size - 1U))) {
2395 unsigned char ch = (unsigned char)start[in_pos];
2396 
2397 if ((ch == '%') && (start[in_pos + 1] != '\0') &&
2398 (start[in_pos + 2] != '\0')) {
2399 unsigned char hi = (unsigned char)start[in_pos + 1];
2400 unsigned char lo = (unsigned char)start[in_pos + 2];
2401 
2402 unsigned int v_hi;
2403 unsigned int v_lo;
2404 
2405 if ((hi >= '0') && (hi <= '9')) {
2406 v_hi = (unsigned int)(hi - '0');
2407 } else if ((hi >= 'A') && (hi <= 'F')) {
2408 v_hi = 10U + (unsigned int)(hi - 'A');
2409 } else if ((hi >= 'a') && (hi <= 'f')) {
2410 v_hi = 10U + (unsigned int)(hi - 'a');
2411 } else {
2412 v_hi = 0xFFFFFFFFU;
2413 }
2414 
2415 if ((lo >= '0') && (lo <= '9')) {
2416 v_lo = (unsigned int)(lo - '0');
2417 } else if ((lo >= 'A') && (lo <= 'F')) {
2418 v_lo = 10U + (unsigned int)(lo - 'A');
2419 } else if ((lo >= 'a') && (lo <= 'f')) {
2420 v_lo = 10U + (unsigned int)(lo - 'a');
2421 } else {
2422 v_lo = 0xFFFFFFFFU;
2423 }
2424 
2425 if ((v_hi != 0xFFFFFFFFU) && (v_lo != 0xFFFFFFFFU)) {
2426 unsigned char decoded = (unsigned char)((v_hi << 4U) | v_lo);
2427 if (decoded == '\0') {
2428 return -1;
2429 }
2430 out[out_pos++] = (char)decoded;
2431 in_pos += 3U;
2432 continue;
2433 }
2434 }
2435 
2436 if (ch == '+') {
2437 out[out_pos++] = ' ';
2438 } else {
2439 out[out_pos++] = (char)ch;
2440 }
2441 in_pos++;
2442 }
2443 out[out_pos] = '\0';
2444 
2445 if (out[0] == '\0') {
2446 out[0] = '/';
2447 out[1] = '\0';
2448 }
2449 
2450 return 0;
2451}
2452 
2453static http_response_t *api_copy(const http_request_t *request) {
2454 if (request->method != HTTP_METHOD_POST) {
2455 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED,
2456 "Use POST for this endpoint");
2457 }
2458 
2459 /* Reject if a copy is already in progress */
2460 if (atomic_load(&g_copy_progress.active) != 0) {
2461 return error_json(HTTP_STATUS_400_BAD_REQUEST,
2462 "A copy operation is already in progress");
2463 }
2464 
2465 const char *query = strchr(request->uri, '?');
2466 if (query == NULL) {
2467 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing query string");
2468 }
2469 
2470 char src_path[1024] = "";
2471 char dst_dir[1024] = "";
2472 if (parse_path_param(query, src_path, sizeof(src_path)) != 0) {
2473 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing or invalid path");
2474 }
2475 if (parse_dst_param(query, dst_dir, sizeof(dst_dir)) != 0) {
2476 return error_json(HTTP_STATUS_400_BAD_REQUEST,
2477 "Missing or invalid dst parameter");
2478 }
2479 
2480 /* Validate source */
2481 char safe_src[FTP_PATH_MAX];
2482 if (!validate_path(src_path, safe_src, sizeof(safe_src))) {
2483 return error_json(HTTP_STATUS_403_FORBIDDEN, "Source path forbidden");
2484 }
2485 if (pal_path_exists(safe_src) != 1) {
2486 return error_json(HTTP_STATUS_404_NOT_FOUND, "Source not found");
2487 }
2488 
2489 /* Validate destination directory */
2490 char safe_dst_dir[FTP_PATH_MAX];
2491 if (!validate_path(dst_dir, safe_dst_dir, sizeof(safe_dst_dir))) {
2492 return error_json(HTTP_STATUS_403_FORBIDDEN, "Destination path forbidden");
2493 }
2494 if (pal_path_is_directory(safe_dst_dir) != 1) {
2495 return error_json(HTTP_STATUS_400_BAD_REQUEST,
2496 "Destination is not a directory");
2497 }
2498 
2499 /*
2500 * Build full destination: dst_dir + '/' + basename(src)
2501 *
2502 * src = /data/files/readme.txt
2503 * dst = /mnt/usb0/backup
2504 * -> /mnt/usb0/backup/readme.txt
2505 */
2506 const char *base = strrchr(safe_src, '/');
2507 base = (base != NULL) ? base + 1 : safe_src;
2508 
2509 char full_dst[FTP_PATH_MAX];
2510 if (strcmp(safe_dst_dir, "/") == 0) {
2511 (void)snprintf(full_dst, sizeof(full_dst), "/%s", base);
2512 } else {
2513 (void)snprintf(full_dst, sizeof(full_dst), "%s/%s", safe_dst_dir, base);
2514 }
2515 
2516 /* Re-validate the composed destination */
2517 char safe_final[FTP_PATH_MAX];
2518 if (!validate_path(full_dst, safe_final, sizeof(safe_final))) {
2519 return error_json(HTTP_STATUS_403_FORBIDDEN, "Final destination forbidden");
2520 }
2521 
2522 /*
2523 * Compute total size for progress UI.
2524 * For a single file use stat(). For directories an exact total
2525 * would require a full recursive scan — instead the browser
2526 * passes an estimate via ?totalsize= from the original listing.
2527 */
2528 {
2529 struct stat copy_st;
2530 uint64_t total_est = 0U;
2531 if (stat(safe_src, &copy_st) == 0) {
2532 total_est = (uint64_t)copy_st.st_size;
2533 }
2534 /* Client may provide totalsize hint for dirs */
2535 const char *ts_str = strstr(query, "totalsize=");
2536 if (ts_str != NULL) {
2537 uint64_t ts_val = (uint64_t)strtoull(ts_str + 10, NULL, 10);
2538 if (ts_val > 0U) {
2539 total_est = ts_val;
2540 }
2541 }
2542 atomic_store(&g_copy_progress.bytes_copied, 0U);
2543 atomic_store(&g_copy_progress.total_bytes, total_est);
2544 atomic_store(&g_copy_progress.active, 1);
2545 atomic_store(&g_copy_progress.done, 0);
2546 atomic_store(&g_copy_progress.error, 0);
2547 atomic_store(&g_copy_progress.error_code, 0);
2548 atomic_store(&g_copy_progress.error_errno, 0);
2549 atomic_store(&g_copy_progress.cancel, 0);
2550 atomic_store(&g_copy_progress.paused, 0);
2551 }
2552 
2553 /* Spawn background copy thread so event loop stays responsive */
2554 copy_thread_args_t *args =
2555 (copy_thread_args_t *)malloc(sizeof(copy_thread_args_t));
2556 if (args == NULL) {
2557 atomic_store(&g_copy_progress.active, 0);
2558 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
2559 }
2560 (void)strncpy(args->src, safe_src, sizeof(args->src) - 1U);
2561 args->src[sizeof(args->src) - 1U] = '\0';
2562 (void)strncpy(args->dst, safe_final, sizeof(args->dst) - 1U);
2563 args->dst[sizeof(args->dst) - 1U] = '\0';
2564 
2565 pthread_t tid;
2566 if (pthread_create(&tid, NULL, copy_thread_fn, args) != 0) {
2567 free(args);
2568 atomic_store(&g_copy_progress.active, 0);
2569 return error_json(HTTP_STATUS_500_INTERNAL_ERROR,
2570 "Failed to start copy thread");
2571 }
2572 (void)pthread_detach(tid);
2573 
2574 /* Return immediately -- client polls /api/copy_progress for status */
2575 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
2576 http_response_add_header(resp, "Content-Type", "application/json");
2577 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
2578 const char *body = "{\"ok\":true,\"async\":true}";
2579 http_response_set_body(resp, body, strlen(body));
2580 return resp;
2581}
2582#endif
2583 
2584/*===========================================================================*
2585 * GET /api/stats/ram — RAM usage
2586 *
2587 * RESPONSE: { "used": N, "cached": N, "buffers": N, "free": N, "total": N }
2588 *===========================================================================*/
2589 
2590static int get_ram_stats(uint64_t *used, uint64_t *cached, uint64_t *buffers,
2591 uint64_t *free_b, uint64_t *total) {
2592 if (!used || !cached || !buffers || !free_b || !total) {
2593 return -1;
2594 }
2595 *used = 0;
2596 *cached = 0;
2597 *buffers = 0;
2598 *free_b = 0;
2599 *total = 0;
2600 
2601#if defined(HAS_SYSINFO)
2602 struct sysinfo si;
2603 if (sysinfo(&si) != 0) {
2604 return -1;
2605 }
2606 uint64_t unit = (uint64_t)si.mem_unit;
2607 *total = (uint64_t)si.totalram * unit;
2608 *free_b = (uint64_t)si.freeram * unit;
2609 *buffers = (uint64_t)si.bufferram * unit;
2610 *cached = 0; /* not in sysinfo; /proc/meminfo would give it */
2611 *used = (*total > *free_b + *buffers + *cached)
2612 ? (*total - *free_b - *buffers - *cached)
2613 : 0U;
2614 /* Try /proc/meminfo for Cached */
2615 FILE *fp = fopen("/proc/meminfo", "r");
2616 if (fp) {
2617 char line[128];
2618 while (fgets(line, sizeof(line), fp)) {
2619 uint64_t v = 0;
2620 if (sscanf(line, "Cached: %" SCNu64, &v) == 1) {
2621 *cached = v * 1024U;
2622 } else if (sscanf(line, "MemAvailable: %" SCNu64, &v) == 1) {
2623 /* recalculate used from MemAvailable */
2624 uint64_t avail = v * 1024U;
2625 *used = (*total > avail) ? (*total - avail) : 0U;
2626 }
2627 }
2628 fclose(fp);
2629 }
2630 return 0;
2631#elif defined(PLATFORM_MACOS) || defined(__APPLE__)
2632 /* Total physical memory */
2633 uint64_t mem_total = 0;
2634 size_t sz = sizeof(mem_total);
2635 if (sysctlbyname("hw.memsize", &mem_total, &sz, NULL, 0) != 0) {
2636 return -1;
2637 }
2638 *total = mem_total;
2639 
2640 /* Page size */
2641 vm_size_t page_sz = 0;
2642 if (host_page_size(mach_host_self(), &page_sz) != KERN_SUCCESS) {
2643 page_sz = 4096;
2644 }
2645 
2646 /* VM stats via host_statistics64 — same source as vm_stat(1) */
2647 vm_statistics64_data_t vmstat;
2648 mach_msg_type_number_t count = HOST_VM_INFO64_COUNT;
2649 if (host_statistics64(mach_host_self(), HOST_VM_INFO64,
2650 (host_info64_t)&vmstat, &count) != KERN_SUCCESS) {
2651 return -1;
2652 }
2653 
2654 *free_b = (uint64_t)vmstat.free_count * (uint64_t)page_sz;
2655 *used =
2656 (uint64_t)(vmstat.active_count + vmstat.wire_count) * (uint64_t)page_sz;
2657 *cached = (uint64_t)vmstat.inactive_count * (uint64_t)page_sz;
2658 *buffers = (uint64_t)vmstat.speculative_count * (uint64_t)page_sz;
2659 return 0;
2660#elif defined(PLATFORM_PS4) || defined(PLATFORM_PS5) || defined(PS4) || \
2661 defined(PS5)
2662 /* PS4/PS5 FreeBSD-derived kernel */
2663 uint64_t physmem = 0;
2664 size_t psz = sizeof(physmem);
2665 sysctlbyname("hw.physmem", &physmem, &psz, NULL, 0);
2666 *total = physmem;
2667 
2668 uint32_t page_sz32 = 16384;
2669 psz = sizeof(page_sz32);
2670 sysctlbyname("hw.pagesize", &page_sz32, &psz, NULL, 0);
2671 uint64_t page_sz = (uint64_t)page_sz32;
2672 
2673 uint32_t v_free = 0, v_active = 0, v_inactive = 0, v_wire = 0;
2674 psz = sizeof(v_free);
2675 sysctlbyname("vm.stats.vm.v_free_count", &v_free, &psz, NULL, 0);
2676 psz = sizeof(v_active);
2677 sysctlbyname("vm.stats.vm.v_active_count", &v_active, &psz, NULL, 0);
2678 psz = sizeof(v_inactive);
2679 sysctlbyname("vm.stats.vm.v_inactive_count", &v_inactive, &psz, NULL, 0);
2680 psz = sizeof(v_wire);
2681 sysctlbyname("vm.stats.vm.v_wire_count", &v_wire, &psz, NULL, 0);
2682 
2683 *free_b = (uint64_t)v_free * page_sz;
2684 *used = (uint64_t)(v_active + v_wire) * page_sz;
2685 *cached = (uint64_t)v_inactive * page_sz;
2686 *buffers = 0;
2687 return 0;
2688#else
2689 return -1;
2690#endif
2691}
2692 
2693static http_response_t *api_stats_ram(const http_request_t *request) {
2694 (void)request;
2695 uint64_t used = 0, cached = 0, buffers = 0, free_b = 0, total = 0;
2696 get_ram_stats(&used, &cached, &buffers, &free_b, &total);
2697 
2698 char body[256];
2699 int len = snprintf(body, sizeof(body),
2700 "{\"used\":%" PRIu64 ",\"cached\":%" PRIu64
2701 ",\"buffers\":%" PRIu64 ",\"free\":%" PRIu64
2702 ",\"total\":%" PRIu64 "}",
2703 used, cached, buffers, free_b, total);
2704 
2705 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
2706 http_response_add_header(resp, "Content-Type", "application/json");
2707 http_response_add_header(resp, "Cache-Control", "no-store");
2708 http_response_set_body(resp, body, (size_t)len);
2709 return resp;
2710}
2711 
2712/*===========================================================================*
2713 * GET /api/stats/system — CPU temp, uptime, boot time
2714 *
2715 * RESPONSE: { "cpu_temp": N|null, "uptime_seconds": N|null,
2716 * "boot_epoch": N|null }
2717 *===========================================================================*/
2718 
2719static http_response_t *api_stats_system(const http_request_t *request) {
2720 (void)request;
2721 
2722 int32_t temp_c = 0;
2723 int temp_ok = get_cpu_temp_c(&temp_c);
2724 
2725 uint64_t boot_epoch = 0;
2726 int boot_ok = get_boot_epoch_seconds(&boot_epoch);
2727 
2728 uint64_t uptime_sec = 0;
2729 if (boot_ok == 0) {
2730 time_t now = time(NULL);
2731 if (now > 0 && (uint64_t)now >= boot_epoch) {
2732 uptime_sec = (uint64_t)now - boot_epoch;
2733 }
2734 }
2735 
2736 char body[512];
2737 size_t pos = 0;
2738 size_t cap = sizeof(body);
2739 
2740 pos += (size_t)snprintf(body + pos, cap - pos, "{");
2741 if (temp_ok == 0) {
2742 pos += (size_t)snprintf(body + pos, cap - pos, "\"cpu_temp\":%" PRId32,
2743 temp_c);
2744 } else {
2745 pos += (size_t)snprintf(body + pos, cap - pos, "\"cpu_temp\":null");
2746 }
2747 if (boot_ok == 0) {
2748 pos += (size_t)snprintf(body + pos, cap - pos,
2749 ",\"uptime_seconds\":%" PRIu64
2750 ",\"boot_epoch\":%" PRIu64,
2751 uptime_sec, boot_epoch);
2752 } else {
2753 pos += (size_t)snprintf(body + pos, cap - pos,
2754 ",\"uptime_seconds\":null,\"boot_epoch\":null");
2755 }
2756 pos += (size_t)snprintf(body + pos, cap - pos, "}");
2757 
2758 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
2759 http_response_add_header(resp, "Content-Type", "application/json");
2760 http_response_add_header(resp, "Cache-Control", "no-store");
2761 http_response_set_body(resp, body, pos);
2762 return resp;
2763}
2764 
2765/*===========================================================================*
2766 * GET /api/disk/info — Disk usage for largest mounted volume
2767 *
2768 * RESPONSE: { "used": N, "free": N, "total": N, "path": "..." }
2769 *===========================================================================*/
2770 
2771static http_response_t *api_disk_info(const http_request_t *request) {
2772 (void)request;
2773 
2774 uint64_t total = 0, used = 0, free_b = 0;
2775 const char *disk_path = NULL;
2776 get_best_disk_stats(g_http_root, &disk_path, &total, &used, &free_b);
2777 
2778 char body[256];
2779 size_t pos = 0;
2780 size_t cap = sizeof(body);
2781 pos += (size_t)snprintf(body + pos, cap - pos,
2782 "{\"used\":%" PRIu64 ",\"free\":%" PRIu64
2783 ",\"total\":%" PRIu64 ",\"path\":\"",
2784 used, free_b, total);
2785 (void)json_escape_append(body, cap, &pos, disk_path ? disk_path : "/");
2786 pos += (size_t)snprintf(body + pos, cap - pos, "\"}");
2787 
2788 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
2789 http_response_add_header(resp, "Content-Type", "application/json");
2790 http_response_add_header(resp, "Cache-Control", "no-store");
2791 http_response_set_body(resp, body, pos);
2792 return resp;
2793}
2794 
2795/*===========================================================================*
2796 * GET /api/disk/tree?path=X — Directory tree (1 level deep, with sizes)
2797 *
2798 * RESPONSE: { "name": "dirname", "type": "directory",
2799 * "size": N, "children": [ { "name":..., "type":..., "size":...
2800 *}, ...] }
2801 *===========================================================================*/
2802 
2803#define DISK_TREE_MAX_CHILDREN 512
2804 
2805static http_response_t *api_disk_tree(const http_request_t *request) {
2806 const char *query = strchr(request->uri, '?');
2807 char path[1024] = "/";
2808 if (query != NULL) {
2809 (void)parse_path_param(query, path, sizeof(path));
2810 }
2811 
2812 char safe[FTP_PATH_MAX];
2813 if (!validate_path(path, safe, sizeof(safe))) {
2814 return error_json(HTTP_STATUS_403_FORBIDDEN, "Forbidden path");
2815 }
2816 
2817 DIR *dir = opendir(safe);
2818 if (dir == NULL) {
2819 return error_json(HTTP_STATUS_404_NOT_FOUND, "Directory not found");
2820 }
2821 
2822 /* Allocate a generous output buffer — tree JSON can be large */
2823 size_t cap = 512 * 1024; /* 512 KB */
2824 char *body = (char *)malloc(cap);
2825 if (body == NULL) {
2826 closedir(dir);
2827 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
2828 }
2829 
2830 size_t pos = 0;
2831 
2832 /* Header: root node name */
2833 const char *dirname = strrchr(safe, '/');
2834 dirname = (dirname && dirname[1] != '\0') ? dirname + 1 : safe;
2835 
2836 pos += (size_t)snprintf(body + pos, cap - pos, "{\"name\":\"");
2837 (void)json_escape_append(body, cap, &pos, dirname);
2838 pos += (size_t)snprintf(body + pos, cap - pos,
2839 "\",\"type\":\"directory\",\"children\":[");
2840 
2841 uint64_t dir_total = 0;
2842 int first = 1;
2843 int count = 0;
2844 
2845 for (;;) {
2846 errno = 0;
2847 struct dirent *ent = readdir(dir);
2848 if (ent == NULL) {
2849 break;
2850 }
2851 if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) {
2852 continue;
2853 }
2854 if (count >= DISK_TREE_MAX_CHILDREN) {
2855 break;
2856 }
2857 
2858 /* Build full child path */
2859 char child[FTP_PATH_MAX];
2860 if (strcmp(safe, "/") == 0) {
2861 (void)snprintf(child, sizeof(child), "/%s", ent->d_name);
2862 } else {
2863 (void)snprintf(child, sizeof(child), "%s/%s", safe, ent->d_name);
2864 }
2865 
2866 struct stat st;
2867 if (stat(child, &st) != 0) {
2868 continue;
2869 }
2870 
2871 uint64_t sz = (uint64_t)st.st_size;
2872 const char *type = S_ISDIR(st.st_mode) ? "directory" : "file";
2873 
2874 /* For directories, use statvfs block count as size approximation */
2875 if (S_ISDIR(st.st_mode)) {
2876 /* Use du-style: st_blocks * 512 */
2877 sz = (uint64_t)st.st_blocks * 512U;
2878 }
2879 
2880 dir_total += sz;
2881 
2882 if (!first) {
2883 if (pos + 1 < cap) {
2884 body[pos++] = ',';
2885 }
2886 }
2887 first = 0;
2888 
2889 /* Append child entry */
2890 size_t name_start = pos;
2891 pos += (size_t)snprintf(body + pos, cap - pos, "{\"name\":\"");
2892 (void)json_escape_append(body, cap, &pos, ent->d_name);
2893 pos +=
2894 (size_t)snprintf(body + pos, cap - pos,
2895 "\",\"type\":\"%s\",\"size\":%" PRIu64 "}", type, sz);
2896 
2897 /* Safety: if we are getting close to buffer limit, stop */
2898 if (pos + 256 >= cap) {
2899 /* Truncate last entry and break */
2900 pos = name_start;
2901 if (pos > 0 && body[pos - 1] == ',') {
2902 pos--;
2903 }
2904 break;
2905 }
2906 count++;
2907 }
2908 closedir(dir);
2909 
2910 pos += (size_t)snprintf(body + pos, cap - pos, "],\"size\":%" PRIu64 "}",
2911 dir_total);
2912 
2913 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
2914 http_response_add_header(resp, "Content-Type", "application/json");
2915 http_response_add_header(resp, "Cache-Control", "no-store");
2916 if (http_response_set_body_owned(resp, body, pos) != 0) {
2917 free(body);
2918 }
2919 return resp;
2920}
2921 
2922/*===========================================================================*
2923 * GET /api/processes — Process list
2924 *
2925 * RESPONSE: [ { "pid": N, "name": "...", "user": "...",
2926 * "cpu": F, "mem_mb": N, "status": "...",
2927 * "killable": bool }, ... ]
2928 *===========================================================================*/
2929 
2930#include <signal.h>
2931 
2932#if defined(PLATFORM_MACOS) || defined(__APPLE__)
2933#include <sys/proc.h>
2934#include <sys/sysctl.h>
2935#endif
2936 
2937static http_response_t *api_processes(const http_request_t *request) {
2938 (void)request;
2939 
2940 size_t cap = 256 * 1024; /* 256 KB */
2941 char *body = (char *)malloc(cap);
2942 if (body == NULL) {
2943 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
2944 }
2945 
2946 size_t pos = 0;
2947 pos += (size_t)snprintf(body + pos, cap - pos, "[");
2948 int first = 1;
2949 
2950#if defined(PLATFORM_MACOS) || defined(__APPLE__)
2951 /* --- macOS: use KERN_PROC sysctl (no entitlements required) --- */
2952 int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0};
2953 size_t buf_size = 0;
2954 /* First call: get required size */
2955 if (sysctl(mib, 4, NULL, &buf_size, NULL, 0) == 0 && buf_size > 0) {
2956 /* Over-allocate slightly to handle races */
2957 buf_size += buf_size / 8;
2958 struct kinfo_proc *procs = (struct kinfo_proc *)malloc(buf_size);
2959 if (procs) {
2960 if (sysctl(mib, 4, procs, &buf_size, NULL, 0) == 0) {
2961 int n = (int)(buf_size / sizeof(struct kinfo_proc));
2962 for (int i = 0; i < n; i++) {
2963 struct kinfo_proc *kp = &procs[i];
2964 pid_t pid = kp->kp_proc.p_pid;
2965 if (pid <= 0)
2966 continue;
2967 
2968 char name[MAXCOMLEN + 1];
2969 strncpy(name, kp->kp_proc.p_comm, sizeof(name) - 1);
2970 name[sizeof(name) - 1] = '\0';
2971 
2972 unsigned int uid = (unsigned int)kp->kp_eproc.e_ucred.cr_uid;
2973 
2974 /* p_stat: SSLEEP=1, SWAIT=2, SRUN=3, SIDL=4, SZOMB=5, SSTOP=6 */
2975 const char *status = "running";
2976 if (kp->kp_proc.p_stat == 1 || kp->kp_proc.p_stat == 2)
2977 status = "sleep";
2978 else if (kp->kp_proc.p_stat == 5)
2979 status = "zombie";
2980 
2981 /* RSS from e_vm — not always available, use 0 as fallback */
2982 uint64_t mem_mb = 0;
2983 
2984 int killable = (uid != 0) ? 1 : 0;
2985 
2986 if (!first && pos + 2 < cap) {
2987 body[pos++] = ',';
2988 }
2989 first = 0;
2990 
2991 pos += (size_t)snprintf(body + pos, cap - pos,
2992 "{\"pid\":%d,\"name\":\"", (int)pid);
2993 (void)json_escape_append(body, cap, &pos, name);
2994 pos += (size_t)snprintf(
2995 body + pos, cap - pos,
2996 "\",\"user\":\"%u\",\"cpu\":0.0,\"mem_mb\":%" PRIu64
2997 ",\"status\":\"%s\",\"killable\":%s}",
2998 uid, mem_mb, status, killable ? "true" : "false");
2999 
3000 if (pos + 512 >= cap)
3001 break;
3002 }
3003 }
3004 free(procs);
3005 }
3006 }
3007 
3008#elif defined(HAS_SYSINFO)
3009 /* --- Linux: parse /proc --- */
3010 DIR *proc_dir = opendir("/proc");
3011 if (proc_dir) {
3012 struct dirent *ent;
3013 while ((ent = readdir(proc_dir)) != NULL) {
3014 /* Only numeric entries are PIDs */
3015 int pid = 0;
3016 int is_pid = 1;
3017 for (const char *c = ent->d_name; *c; c++) {
3018 if (*c < '0' || *c > '9') {
3019 is_pid = 0;
3020 break;
3021 }
3022 }
3023 if (!is_pid || ent->d_name[0] == '\0')
3024 continue;
3025 pid = atoi(ent->d_name);
3026 if (pid <= 0)
3027 continue;
3028 
3029 /* /proc/<pid>/stat */
3030 char stat_path[64];
3031 snprintf(stat_path, sizeof(stat_path), "/proc/%d/stat", pid);
3032 FILE *f = fopen(stat_path, "r");
3033 if (!f)
3034 continue;
3035 
3036 char comm[256] = "";
3037 char state = '?';
3038 long rss = 0;
3039 unsigned int uid = 0;
3040 
3041 /* Read comm from /proc/<pid>/status for cleaner name */
3042 char status_path[64];
3043 snprintf(status_path, sizeof(status_path), "/proc/%d/status", pid);
3044 FILE *sf = fopen(status_path, "r");
3045 if (sf) {
3046 char line[256];
3047 while (fgets(line, sizeof(line), sf)) {
3048 if (strncmp(line, "Name:", 5) == 0) {
3049 sscanf(line + 5, " %255s", comm);
3050 } else if (strncmp(line, "Uid:", 4) == 0) {
3051 sscanf(line + 4, " %u", &uid);
3052 }
3053 }
3054 fclose(sf);
3055 }
3056 
3057 /* Read utime/stime/rss from stat */
3058 {
3059 char tmp[2048];
3060 if (fgets(tmp, sizeof(tmp), f)) {
3061 /* format: pid (comm) state ppid ... utime stime ... rss */
3062 char *p = strrchr(tmp, ')');
3063 if (p) {
3064 sscanf(p + 2,
3065 " %c %*d %*d %*d %*d %*d %*u %*u %*u %*u %*u "
3066 "%*lu %*lu %*d %*d %*d %*d %*d %*d %*u %*u %ld",
3067 &state, &rss);
3068 }
3069 }
3070 }
3071 fclose(f);
3072 
3073 if (comm[0] == '\0')
3074 snprintf(comm, sizeof(comm), "pid%d", pid);
3075 
3076 uint64_t mem_mb =
3077 (uint64_t)((rss > 0 ? rss : 0) * 4096UL / (1024UL * 1024UL));
3078 const char *status_str = "running";
3079 if (state == 'S' || state == 'D')
3080 status_str = "sleep";
3081 else if (state == 'Z')
3082 status_str = "zombie";
3083 double cpu_pct = 0.0; /* snapshot only */
3084 int killable = (uid != 0) ? 1 : 0;
3085 
3086 if (!first && pos + 2 < cap) {
3087 body[pos++] = ',';
3088 }
3089 first = 0;
3090 
3091 pos += (size_t)snprintf(body + pos, cap - pos, "{\"pid\":%d,\"name\":\"",
3092 pid);
3093 (void)json_escape_append(body, cap, &pos, comm);
3094 pos += (size_t)snprintf(
3095 body + pos, cap - pos,
3096 "\",\"user\":\"%u\",\"cpu\":%.1f,\"mem_mb\":%" PRIu64
3097 ",\"status\":\"%s\",\"killable\":%s}",
3098 uid, cpu_pct, mem_mb, status_str, killable ? "true" : "false");
3099 
3100 if (pos + 512 >= cap)
3101 break;
3102 }
3103 closedir(proc_dir);
3104 }
3105 
3106#else
3107 /* Unsupported platform — return empty array */
3108 (void)first;
3109#endif
3110 
3111 if (pos + 2 < cap) {
3112 body[pos++] = ']';
3113 body[pos] = '\0';
3114 }
3115 
3116 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
3117 http_response_add_header(resp, "Content-Type", "application/json");
3118 http_response_add_header(resp, "Cache-Control", "no-store");
3119 if (http_response_set_body_owned(resp, body, pos) != 0) {
3120 free(body);
3121 }
3122 return resp;
3123}
3124 
3125/*===========================================================================*
3126 * POST /api/process/kill — Send SIGTERM to a process
3127 *
3128 * REQUEST BODY: { "pid": N }
3129 * RESPONSE: { "success": true } | { "error": "..." }
3130 *===========================================================================*/
3131 
3132static int parse_pid_from_body(const char *body, size_t len, int *out_pid) {
3133 if (!body || len == 0 || !out_pid)
3134 return -1;
3135 const char *p = strstr(body, "\"pid\"");
3136 if (!p)
3137 p = strstr(body, "\"pid\":");
3138 if (!p)
3139 return -1;
3140 p += 5; /* skip "pid" */
3141 while (*p == ':' || *p == ' ' || *p == '\t')
3142 p++;
3143 if (*p == '\0')
3144 return -1;
3145 int v = atoi(p);
3146 if (v <= 0)
3147 return -1;
3148 *out_pid = v;
3149 return 0;
3150}
3151 
3152static http_response_t *api_process_kill(const http_request_t *request) {
3153#if ENABLE_WEB_UPLOAD
3154 if (http_csrf_validate(request) != 0) {
3155 return error_json(HTTP_STATUS_403_FORBIDDEN,
3156 "Invalid or missing CSRF token");
3157 }
3158#endif
3159 if (request->method != HTTP_METHOD_POST) {
3160 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED, "Use POST");
3161 }
3162 
3163 int pid = 0;
3164 if (parse_pid_from_body(request->body, request->body_length, &pid) != 0) {
3165 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing or invalid pid");
3166 }
3167 
3168 /* Safety: never kill PID 1 or negative PIDs */
3169 if (pid <= 1) {
3170 return error_json(HTTP_STATUS_403_FORBIDDEN, "Cannot kill system process");
3171 }
3172 
3173 if (kill((pid_t)pid, SIGTERM) != 0) {
3174 char msg[64];
3175 snprintf(msg, sizeof(msg), "kill failed: %s", strerror(errno));
3176 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, msg);
3177 }
3178 
3179 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
3180 http_response_add_header(resp, "Content-Type", "application/json");
3181 char body[64];
3182 int len = snprintf(body, sizeof(body), "{\"success\":true,\"pid\":%d}", pid);
3183 http_response_set_body(resp, body, (size_t)len);
3184 return resp;
3185}
3186 
3187/*===========================================================================*
3188 * GAME METADATA — exFAT image parsing
3189 *
3190 * GET /api/game/meta?path=<file>
3191 * Extracts param.json + icon0.png from sce_sys/ inside an exFAT image.
3192 * Returns JSON: { title_id, title_name, version, category, icon_base64 }
3193 *
3194 * GET /api/game/icon?path=<file>
3195 * Returns the raw PNG icon directly (Content-Type: image/png).
3196 *===========================================================================*/
3197 
3198/* Simple SFO string extraction */
3199static uint16_t le16(const uint8_t *p) { return (uint16_t)(p[0] | ((uint16_t)p[1] << 8)); }
3200static uint32_t le32(const uint8_t *p) { return (uint32_t)(p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24)); }
3201 
3202static int sfo_get_string(const uint8_t *sfo, size_t size, const char *req_key, char *out, size_t out_max) {
3203 if (!sfo || size < 20 || !req_key || !out || out_max == 0) return -1;
3204 /* 0x46535000 -> "\0PSF" in little-endian */
3205 if (le32(sfo) != 0x46535000) return -1;
3206
3207 uint32_t key_table_ofs = le32(sfo + 0x08);
3208 uint32_t data_table_ofs = le32(sfo + 0x0C);
3209 uint32_t count = le32(sfo + 0x10);
3210
3211 if (key_table_ofs >= size || data_table_ofs >= size) return -1;
3212 if (0x14 + count * 16 > size) return -1;
3213
3214 for (uint32_t i = 0; i < count; i++) {
3215 const uint8_t *entry = sfo + 0x14 + i * 16;
3216 uint16_t key_ofs = le16(entry + 0);
3217 uint16_t fmt = le16(entry + 2);
3218 uint32_t data_len = le32(entry + 4);
3219 uint32_t data_ofs = le32(entry + 12);
3220
3221 if (key_table_ofs + key_ofs >= size) return -1;
3222 const char *key = (const char *)(sfo + key_table_ofs + key_ofs);
3223
3224 if (strcmp(key, req_key) == 0) {
3225 if (fmt == 0x0204 || fmt == 0x0004 || fmt == 0x0000 || fmt == 0x0404) {
3226 if (data_table_ofs + data_ofs + data_len <= size) {
3227 snprintf(out, out_max, "%.*s", (int)(data_len > 0 ? data_len - 1 : 0), sfo + data_table_ofs + data_ofs);
3228 return 0; /* success */
3229 }
3230 }
3231 }
3232 }
3233 return -1;
3234}
3235 
3236/* Simple JSON string extraction: get value for "key":"value" */
3237static int json_get_string(const char *json, const char *key,
3238 char *out, size_t out_size) {
3239 if (!json || !key || !out || out_size == 0) return -1;
3240 out[0] = '\0';
3241 
3242 char needle[128];
3243 int nlen = snprintf(needle, sizeof(needle), "\"%s\"", key);
3244 if (nlen < 0 || (size_t)nlen >= sizeof(needle)) return -1;
3245 
3246 const char *pos = strstr(json, needle);
3247 if (!pos) return -1;
3248 pos += (size_t)nlen;
3249 
3250 /* Skip whitespace and colon */
3251 while (*pos == ' ' || *pos == '\t' || *pos == '\n' || *pos == ':') pos++;
3252 if (*pos != '"') return -1;
3253 pos++; /* skip opening quote */
3254 
3255 size_t i = 0;
3256 while (*pos && *pos != '"' && i < out_size - 1) {
3257 if (*pos == '\\' && *(pos + 1)) {
3258 pos++;
3259 if (*pos == 'n') out[i++] = '\n';
3260 else if (*pos == 't') out[i++] = '\t';
3261 else out[i++] = *pos;
3262 } else {
3263 out[i++] = *pos;
3264 }
3265 pos++;
3266 }
3267 out[i] = '\0';
3268 return 0;
3269}
3270 
3271static int title_id_from_content_id(const char *content_id,
3272 char *out, size_t out_size) {
3273 if ((content_id == NULL) || (out == NULL) || (out_size < 10U)) {
3274 return -1;
3275 }
3276 
3277 size_t n = strlen(content_id);
3278 for (size_t i = 0; (i + 9U) <= n; i++) {
3279 if (isalpha((unsigned char)content_id[i + 0]) &&
3280 isalpha((unsigned char)content_id[i + 1]) &&
3281 isalpha((unsigned char)content_id[i + 2]) &&
3282 isalpha((unsigned char)content_id[i + 3]) &&
3283 isdigit((unsigned char)content_id[i + 4]) &&
3284 isdigit((unsigned char)content_id[i + 5]) &&
3285 isdigit((unsigned char)content_id[i + 6]) &&
3286 isdigit((unsigned char)content_id[i + 7]) &&
3287 isdigit((unsigned char)content_id[i + 8])) {
3288 for (size_t j = 0; j < 9U; j++) {
3289 out[j] = (char)toupper((unsigned char)content_id[i + j]);
3290 }
3291 out[9] = '\0';
3292 return 0;
3293 }
3294 }
3295 
3296 return -1;
3297}
3298 
3299static int read_file_to_buffer(const char *path, uint8_t **out_data,
3300 size_t *out_size, size_t max_size) {
3301 if ((path == NULL) || (out_data == NULL) || (out_size == NULL)) {
3302 return -1;
3303 }
3304 
3305 FILE *fp = fopen(path, "rb");
3306 if (fp == NULL) {
3307 return -1;
3308 }
3309 
3310 if (fseek(fp, 0, SEEK_END) != 0) {
3311 fclose(fp);
3312 return -1;
3313 }
3314 long flen = ftell(fp);
3315 if (flen <= 0 || (size_t)flen > max_size) {
3316 fclose(fp);
3317 return -1;
3318 }
3319 if (fseek(fp, 0, SEEK_SET) != 0) {
3320 fclose(fp);
3321 return -1;
3322 }
3323 
3324 uint8_t *buf = (uint8_t *)malloc((size_t)flen);
3325 if (buf == NULL) {
3326 fclose(fp);
3327 return -1;
3328 }
3329 
3330 size_t got = fread(buf, 1, (size_t)flen, fp);
3331 fclose(fp);
3332 if (got != (size_t)flen) {
3333 free(buf);
3334 return -1;
3335 }
3336 
3337 *out_data = buf;
3338 *out_size = got;
3339 return 0;
3340}
3341 
3342static int read_installed_game_sfo(const char *app_dir, char *title_id,
3343 size_t title_id_size, char *title_name,
3344 size_t title_name_size) {
3345 if ((app_dir == NULL) || (title_id == NULL) || (title_name == NULL)) {
3346 return -1;
3347 }
3348 
3349 char sfo_path[FTP_PATH_MAX];
3350 int n = snprintf(sfo_path, sizeof(sfo_path), "%s/sce_sys/param.sfo", app_dir);
3351 if (n < 0 || (size_t)n >= sizeof(sfo_path) || access(sfo_path, R_OK) != 0) {
3352 n = snprintf(sfo_path, sizeof(sfo_path), "%s/param.sfo", app_dir);
3353 if (n < 0 || (size_t)n >= sizeof(sfo_path) || access(sfo_path, R_OK) != 0) {
3354 return -1;
3355 }
3356 }
3357 
3358 uint8_t *sfo = NULL;
3359 size_t sfo_size = 0U;
3360 if (read_file_to_buffer(sfo_path, &sfo, &sfo_size, 65536U) != 0) {
3361 return -1;
3362 }
3363 
3364 (void)sfo_get_string(sfo, sfo_size, "TITLE_ID", title_id, title_id_size);
3365 (void)sfo_get_string(sfo, sfo_size, "TITLE", title_name, title_name_size);
3366 if (title_name[0] == '\0') {
3367 (void)sfo_get_string(sfo, sfo_size, "TITLE_01", title_name,
3368 title_name_size);
3369 }
3370 
3371 free(sfo);
3372 return 0;
3373}
3374 
3375#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
3376static int read_installed_game_sfo_field(const char *app_dir,
3377 const char *key,
3378 char *out,
3379 size_t out_size) {
3380 if ((app_dir == NULL) || (key == NULL) || (out == NULL) || (out_size < 2U)) {
3381 return -1;
3382 }
3383 out[0] = '\0';
3384 
3385 char sfo_path[FTP_PATH_MAX];
3386 int n = snprintf(sfo_path, sizeof(sfo_path), "%s/sce_sys/param.sfo", app_dir);
3387 if (n < 0 || (size_t)n >= sizeof(sfo_path) || access(sfo_path, R_OK) != 0) {
3388 n = snprintf(sfo_path, sizeof(sfo_path), "%s/param.sfo", app_dir);
3389 if (n < 0 || (size_t)n >= sizeof(sfo_path) || access(sfo_path, R_OK) != 0) {
3390 return -1;
3391 }
3392 }
3393 
3394 uint8_t *sfo = NULL;
3395 size_t sfo_size = 0U;
3396 if (read_file_to_buffer(sfo_path, &sfo, &sfo_size, 65536U) != 0) {
3397 return -1;
3398 }
3399 
3400 int rc = sfo_get_string(sfo, sfo_size, key, out, out_size);
3401 free(sfo);
3402 return (rc == 0 && out[0] != '\0') ? 0 : -1;
3403}
3404#endif
3405 
3406static int resolve_installed_icon_path(const char *title_id,
3407 const char *app_dir,
3408 char *out_path,
3409 size_t out_size) {
3410 if (!out_path || out_size < 2U) {
3411 return -1;
3412 }
3413 out_path[0] = '\0';
3414 
3415 if (app_dir && app_dir[0] != '\0') {
3416 int n = snprintf(out_path, out_size, "%s/sce_sys/icon0.png", app_dir);
3417 if (n > 0 && (size_t)n < out_size && access(out_path, R_OK) == 0) {
3418 return 0;
3419 }
3420 n = snprintf(out_path, out_size, "%s/icon0.png", app_dir);
3421 if (n > 0 && (size_t)n < out_size && access(out_path, R_OK) == 0) {
3422 return 0;
3423 }
3424 }
3425 
3426 if (title_id && title_id[0] != '\0') {
3427 int n = snprintf(out_path, out_size, "/user/appmeta/%s/icon0.png", title_id);
3428 if (n > 0 && (size_t)n < out_size && access(out_path, R_OK) == 0) {
3429 return 0;
3430 }
3431 n = snprintf(out_path, out_size, "/system_data/priv/appmeta/%s/icon0.png",
3432 title_id);
3433 if (n > 0 && (size_t)n < out_size && access(out_path, R_OK) == 0) {
3434 return 0;
3435 }
3436 }
3437 
3438 return -1;
3439}
3440 
3441static int resolve_installed_app_dir_by_title(const char *title_id,
3442 char *out_path,
3443 size_t out_size) {
3444 if ((title_id == NULL) || (title_id[0] == '\0') || (out_path == NULL) ||
3445 (out_size < 2U)) {
3446 return -1;
3447 }
3448 out_path[0] = '\0';
3449 
3450 const char *bases[] = {
3451 "/user/app",
3452 "/mnt/ext0/user/app",
3453 "/system_ex/app",
3454 "/system/app",
3455 NULL,
3456 };
3457 
3458 for (size_t i = 0; bases[i] != NULL; i++) {
3459 int n = snprintf(out_path, out_size, "%s/%s", bases[i], title_id);
3460 if (n <= 0 || (size_t)n >= out_size) {
3461 continue;
3462 }
3463 
3464 struct stat st;
3465 if (stat(out_path, &st) == 0 && S_ISDIR(st.st_mode)) {
3466 return 0;
3467 }
3468 }
3469 
3470 out_path[0] = '\0';
3471 return -1;
3472}
3473 
3474static int extract_title_id_from_app_dir(const char *safe_path,
3475 char *title_id,
3476 size_t title_id_size) {
3477 if ((safe_path == NULL) || (title_id == NULL) || (title_id_size < 10U)) {
3478 return -1;
3479 }
3480 title_id[0] = '\0';
3481 
3482 struct stat st;
3483 if (stat(safe_path, &st) != 0 || !S_ISDIR(st.st_mode)) {
3484 return -1;
3485 }
3486 
3487 char title_name[128] = {0};
3488 if (read_installed_game_sfo(safe_path, title_id, title_id_size, title_name,
3489 sizeof(title_name)) == 0 &&
3490 title_id[0] != '\0') {
3491 return 0;
3492 }
3493 
3494 const char *base = strrchr(safe_path, '/');
3495 if (base && base[1] != '\0') {
3496 base++;
3497 size_t len = strlen(base);
3498 if (len >= 9U && len < title_id_size) {
3499 int valid = 1;
3500 for (size_t i = 0; i < len; i++) {
3501 unsigned char c = (unsigned char)base[i];
3502 if (!(isalnum(c) || c == '_' || c == '-')) {
3503 valid = 0;
3504 break;
3505 }
3506 }
3507 if (valid) {
3508 (void)snprintf(title_id, title_id_size, "%s", base);
3509 return 0;
3510 }
3511 }
3512 }
3513 
3514 return -1;
3515}
3516 
3517static int is_valid_title_id_for_uninstall(const char *title_id) {
3518 if (title_id == NULL) {
3519 return 0;
3520 }
3521 size_t len = strlen(title_id);
3522 if (len < 4U || len > 16U) {
3523 return 0;
3524 }
3525 for (size_t i = 0; i < len; i++) {
3526 unsigned char c = (unsigned char)title_id[i];
3527 if (!(isalnum(c) || c == '_' || c == '-')) {
3528 return 0;
3529 }
3530 }
3531 return 1;
3532}
3533 
3534static int has_pkg_extension(const char *path) {
3535 if (path == NULL) {
3536 return 0;
3537 }
3538 const char *dot = strrchr(path, '.');
3539 if (dot == NULL) {
3540 return 0;
3541 }
3542 dot++;
3543 return (strcasecmp(dot, "pkg") == 0 || strcasecmp(dot, "fpkg") == 0 ||
3544 strcasecmp(dot, "ffpkg") == 0);
3545}
3546 
3547typedef struct {
3548 int active;
3549 int task_id;
3550 int last_percent;
3551 int last_error;
3552 unsigned long last_length;
3553 unsigned long last_transferred;
3554 char title_id[64];
3555 char path[FTP_PATH_MAX];
3556} game_install_state_t;
3557 
3558static game_install_state_t g_game_install_state = {0};
3559 
3560#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
3561typedef int (*fn_sceAppInstUtilInitialize_t)(void);
3562typedef int (*fn_sceAppInstUtilTerminate_t)(void);
3563typedef int (*fn_sceAppInstUtilAppUnInstall_t)(const char *);
3564typedef int (*fn_sceAppInstUtilAppInstallPkg_t)(const char *, void *);
3565typedef int (*fn_sceAppInstUtilGetTitleIdFromPkg_t)(const char *, char *, int *);
3566typedef int (*fn_sceBgftServiceInit_t)(bgft_init_params *);
3567typedef int (*fn_sceBgftServiceTerm_t)(void);
3568typedef int (*fn_sceBgftServiceIntDownloadRegisterTaskByStorageEx_t)(
3569 bgft_download_param_ex *, int *);
3570typedef int (*fn_sceBgftServiceDownloadStartTask_t)(int);
3571typedef int (*fn_sceBgftServiceDownloadGetProgress_t)(int, SceBgftTaskProgress *);
3572 
3573static int g_bgft_initialized = 0;
3574static uint8_t g_bgft_heap[1024 * 1024];
3575 
3576static int psx_bgft_resolve(
3577 void **out_bgft,
3578 fn_sceBgftServiceInit_t *out_init,
3579 fn_sceBgftServiceTerm_t *out_term,
3580 fn_sceBgftServiceIntDownloadRegisterTaskByStorageEx_t *out_register,
3581 fn_sceBgftServiceDownloadStartTask_t *out_start,
3582 fn_sceBgftServiceDownloadGetProgress_t *out_progress) {
3583 if (!out_bgft || !out_init || !out_term || !out_register || !out_start ||
3584 !out_progress) {
3585 return -1;
3586 }
3587 
3588 *out_bgft = dlopen("/system/common/lib/libSceBgft.sprx",
3589 RTLD_NOW | RTLD_GLOBAL);
3590 if (!*out_bgft) {
3591 return -1;
3592 }
3593 
3594 *out_init =
3595 (fn_sceBgftServiceInit_t)dlsym(*out_bgft, "sceBgftServiceInit");
3596 *out_term =
3597 (fn_sceBgftServiceTerm_t)dlsym(*out_bgft, "sceBgftServiceTerm");
3598 *out_register = (fn_sceBgftServiceIntDownloadRegisterTaskByStorageEx_t)dlsym(
3599 *out_bgft, "sceBgftServiceIntDownloadRegisterTaskByStorageEx");
3600 *out_start = (fn_sceBgftServiceDownloadStartTask_t)dlsym(
3601 *out_bgft, "sceBgftServiceDownloadStartTask");
3602 *out_progress = (fn_sceBgftServiceDownloadGetProgress_t)dlsym(
3603 *out_bgft, "sceBgftServiceDownloadGetProgress");
3604 
3605 if (!*out_init || !*out_term || !*out_register || !*out_start ||
3606 !*out_progress) {
3607 dlclose(*out_bgft);
3608 *out_bgft = NULL;
3609 return -1;
3610 }
3611 
3612 return 0;
3613}
3614 
3615static int psx_bgft_ensure_initialized(fn_sceBgftServiceInit_t f_bgft_init) {
3616 if (!f_bgft_init) {
3617 return -1;
3618 }
3619 if (g_bgft_initialized) {
3620 return 0;
3621 }
3622 
3623 bgft_init_params params;
3624 memset(&params, 0, sizeof(params));
3625 params.heap = g_bgft_heap;
3626 params.heapSize = sizeof(g_bgft_heap);
3627 int rc = f_bgft_init(&params);
3628 if (rc == 0) {
3629 g_bgft_initialized = 1;
3630 return 0;
3631 }
3632 /* already initialized */
3633 if ((uint32_t)rc == 0x80990001U) {
3634 g_bgft_initialized = 1;
3635 return 0;
3636 }
3637 return rc;
3638}
3639 
3640static int psx_get_title_id_from_pkg(const char *pkg_path, char *out_title_id,
3641 size_t out_title_id_size) {
3642 if (!pkg_path || !out_title_id || out_title_id_size == 0U) {
3643 return -1;
3644 }
3645 out_title_id[0] = '\0';
3646 
3647 void *appinst = dlopen("/system/common/lib/libSceAppInstUtil.sprx",
3648 RTLD_NOW | RTLD_GLOBAL);
3649 if (!appinst) {
3650 return -1;
3651 }
3652 
3653 fn_sceAppInstUtilInitialize_t f_init =
3654 (fn_sceAppInstUtilInitialize_t)dlsym(appinst, "sceAppInstUtilInitialize");
3655 fn_sceAppInstUtilTerminate_t f_term =
3656 (fn_sceAppInstUtilTerminate_t)dlsym(appinst, "sceAppInstUtilTerminate");
3657 fn_sceAppInstUtilGetTitleIdFromPkg_t f_get_tid =
3658 (fn_sceAppInstUtilGetTitleIdFromPkg_t)dlsym(
3659 appinst, "sceAppInstUtilGetTitleIdFromPkg");
3660 
3661 if (!f_init || !f_term || !f_get_tid) {
3662 dlclose(appinst);
3663 return -1;
3664 }
3665 
3666 (void)f_init();
3667 int is_app = 0;
3668 int rc = f_get_tid(pkg_path, out_title_id, &is_app);
3669 (void)f_term();
3670 dlclose(appinst);
3671 return rc;
3672}
3673 
3674static int psx_install_pkg_bgft(const char *pkg_path, const char *content_name,
3675 char *out_title_id,
3676 size_t out_title_id_size,
3677 int *out_task_id,
3678 int *out_register_rc) {
3679 if (!pkg_path || !out_task_id || !out_register_rc) {
3680 return -1;
3681 }
3682 
3683 void *bgft = NULL;
3684 fn_sceBgftServiceInit_t f_bgft_init = NULL;
3685 fn_sceBgftServiceTerm_t f_bgft_term = NULL;
3686 fn_sceBgftServiceIntDownloadRegisterTaskByStorageEx_t f_register = NULL;
3687 fn_sceBgftServiceDownloadStartTask_t f_start = NULL;
3688 fn_sceBgftServiceDownloadGetProgress_t f_progress = NULL;
3689 
3690 if (psx_bgft_resolve(&bgft, &f_bgft_init, &f_bgft_term, &f_register,
3691 &f_start, &f_progress) != 0) {
3692 return -1;
3693 }
3694 
3695 int init_rc = psx_bgft_ensure_initialized(f_bgft_init);
3696 if (init_rc != 0) {
3697 dlclose(bgft);
3698 return init_rc;
3699 }
3700 
3701 if (out_title_id && out_title_id_size > 0U) {
3702 (void)psx_get_title_id_from_pkg(pkg_path, out_title_id, out_title_id_size);
3703 }
3704 
3705 bgft_download_param_ex params;
3706 memset(&params, 0, sizeof(params));
3707 params.param.entitlement_type = 5;
3708 params.param.id = "";
3709 params.param.content_url = pkg_path;
3710 params.param.content_name =
3711 (content_name && content_name[0] != '\0') ? content_name : "Remote Install";
3712 params.param.icon_path = "/update/fakepic.png";
3713 params.param.playgo_scenario_id = "0";
3714 params.param.option = BGFT_TASK_OPTION_DELETE_AFTER_UPLOAD;
3715 params.slot = 0;
3716 
3717 int task_id = -1;
3718 int rc = f_register(&params, &task_id);
3719 if (rc == 0) {
3720 rc = f_start(task_id);
3721 }
3722 
3723 *out_register_rc = rc;
3724 *out_task_id = task_id;
3725 dlclose(bgft);
3726 return 0;
3727}
3728 
3729static int psx_bgft_progress(int task_id, SceBgftTaskProgress *out_progress,
3730 int *out_rc) {
3731 if (!out_progress || !out_rc) {
3732 return -1;
3733 }
3734 
3735 void *bgft = NULL;
3736 fn_sceBgftServiceInit_t f_bgft_init = NULL;
3737 fn_sceBgftServiceTerm_t f_bgft_term = NULL;
3738 fn_sceBgftServiceIntDownloadRegisterTaskByStorageEx_t f_register = NULL;
3739 fn_sceBgftServiceDownloadStartTask_t f_start = NULL;
3740 fn_sceBgftServiceDownloadGetProgress_t f_progress = NULL;
3741 
3742 if (psx_bgft_resolve(&bgft, &f_bgft_init, &f_bgft_term, &f_register,
3743 &f_start, &f_progress) != 0) {
3744 return -1;
3745 }
3746 
3747 (void)psx_bgft_ensure_initialized(f_bgft_init);
3748 memset(out_progress, 0, sizeof(*out_progress));
3749 *out_rc = f_progress(task_id, out_progress);
3750 dlclose(bgft);
3751 return 0;
3752}
3753 
3754static int psx_uninstall_title_id(const char *title_id, int *out_rc) {
3755 if (!title_id) {
3756 return -1;
3757 }
3758 
3759 void *appinst = dlopen("/system/common/lib/libSceAppInstUtil.sprx",
3760 RTLD_NOW | RTLD_GLOBAL);
3761 if (!appinst) {
3762 return -1;
3763 }
3764 
3765 fn_sceAppInstUtilInitialize_t f_init =
3766 (fn_sceAppInstUtilInitialize_t)dlsym(appinst, "sceAppInstUtilInitialize");
3767 fn_sceAppInstUtilTerminate_t f_term =
3768 (fn_sceAppInstUtilTerminate_t)dlsym(appinst, "sceAppInstUtilTerminate");
3769 fn_sceAppInstUtilAppUnInstall_t f_uninstall =
3770 (fn_sceAppInstUtilAppUnInstall_t)dlsym(appinst,
3771 "sceAppInstUtilAppUnInstall");
3772 
3773 if (!f_init || !f_term || !f_uninstall) {
3774 dlclose(appinst);
3775 return -1;
3776 }
3777 
3778 (void)f_init();
3779 int rc = f_uninstall(title_id);
3780 (void)f_term();
3781 dlclose(appinst);
3782 if (out_rc) {
3783 *out_rc = rc;
3784 }
3785 return 0;
3786}
3787 
3788static int psx_install_pkg_path(const char *pkg_path, char *out_title_id,
3789 size_t out_title_id_size, int *out_install_rc) {
3790 if ((pkg_path == NULL) || (out_install_rc == NULL)) {
3791 return -1;
3792 }
3793 
3794 void *appinst = dlopen("/system/common/lib/libSceAppInstUtil.sprx",
3795 RTLD_NOW | RTLD_GLOBAL);
3796 if (!appinst) {
3797 return -1;
3798 }
3799 
3800 fn_sceAppInstUtilInitialize_t f_init =
3801 (fn_sceAppInstUtilInitialize_t)dlsym(appinst, "sceAppInstUtilInitialize");
3802 fn_sceAppInstUtilTerminate_t f_term =
3803 (fn_sceAppInstUtilTerminate_t)dlsym(appinst, "sceAppInstUtilTerminate");
3804 fn_sceAppInstUtilAppInstallPkg_t f_install =
3805 (fn_sceAppInstUtilAppInstallPkg_t)dlsym(appinst,
3806 "sceAppInstUtilAppInstallPkg");
3807 fn_sceAppInstUtilGetTitleIdFromPkg_t f_get_tid =
3808 (fn_sceAppInstUtilGetTitleIdFromPkg_t)dlsym(
3809 appinst, "sceAppInstUtilGetTitleIdFromPkg");
3810 
3811 if (!f_init || !f_term || !f_install) {
3812 dlclose(appinst);
3813 return -1;
3814 }
3815 
3816 (void)f_init();
3817 
3818 if (out_title_id && out_title_id_size > 0U) {
3819 out_title_id[0] = '\0';
3820 if (f_get_tid != NULL) {
3821 int is_app = 0;
3822 (void)f_get_tid(pkg_path, out_title_id, &is_app);
3823 }
3824 }
3825 
3826 int rc = f_install(pkg_path, NULL);
3827 (void)f_term();
3828 dlclose(appinst);
3829 
3830 *out_install_rc = rc;
3831 return 0;
3832}
3833 
3834typedef int (*sqlite3_cb_t)(void *, int, char **, char **);
3835 
3836typedef struct {
3837 int (*open_v2)(const char *, sqlite3 **, int, const char *);
3838 int (*close)(sqlite3 *);
3839 int (*exec)(sqlite3 *, const char *, sqlite3_cb_t, void *, char **);
3840 int (*changes)(sqlite3 *);
3841 void (*free_fn)(void *);
3842 const char *(*errmsg)(sqlite3 *);
3843 void *lib_handle;
3844} psx_sqlite_api_t;
3845 
3846#define SQLITE_OPEN_READWRITE 0x00000002
3847 
3848static int psx_sqlite_load_api(psx_sqlite_api_t *api) {
3849 if (api == NULL) {
3850 return -1;
3851 }
3852 memset(api, 0, sizeof(*api));
3853 
3854 api->open_v2 = (int (*)(const char *, sqlite3 **, int, const char *))dlsym(
3855 RTLD_DEFAULT, "sqlite3_open_v2");
3856 api->close = (int (*)(sqlite3 *))dlsym(RTLD_DEFAULT, "sqlite3_close");
3857 api->exec =
3858 (int (*)(sqlite3 *, const char *, sqlite3_cb_t, void *, char **))dlsym(
3859 RTLD_DEFAULT, "sqlite3_exec");
3860 api->changes = (int (*)(sqlite3 *))dlsym(RTLD_DEFAULT, "sqlite3_changes");
3861 api->free_fn = (void (*)(void *))dlsym(RTLD_DEFAULT, "sqlite3_free");
3862 api->errmsg = (const char *(*)(sqlite3 *))dlsym(RTLD_DEFAULT, "sqlite3_errmsg");
3863 
3864 if (api->open_v2 && api->close && api->exec && api->changes &&
3865 api->free_fn && api->errmsg) {
3866 return 0;
3867 }
3868 
3869 const char *sqlite_libs[] = {
3870 "/system/common/lib/libSceSqlite.sprx",
3871 "/system/common/lib/libsqlite3.sprx",
3872 NULL,
3873 };
3874 
3875 for (size_t i = 0; sqlite_libs[i] != NULL; i++) {
3876 void *h = dlopen(sqlite_libs[i], RTLD_NOW | RTLD_GLOBAL);
3877 if (h == NULL) {
3878 continue;
3879 }
3880 
3881 api->open_v2 =
3882 (int (*)(const char *, sqlite3 **, int, const char *))dlsym(h,
3883 "sqlite3_open_v2");
3884 api->close = (int (*)(sqlite3 *))dlsym(h, "sqlite3_close");
3885 api->exec =
3886 (int (*)(sqlite3 *, const char *, sqlite3_cb_t, void *, char **))dlsym(
3887 h, "sqlite3_exec");
3888 api->changes = (int (*)(sqlite3 *))dlsym(h, "sqlite3_changes");
3889 api->free_fn = (void (*)(void *))dlsym(h, "sqlite3_free");
3890 api->errmsg = (const char *(*)(sqlite3 *))dlsym(h, "sqlite3_errmsg");
3891 
3892 if (api->open_v2 && api->close && api->exec && api->changes &&
3893 api->free_fn && api->errmsg) {
3894 api->lib_handle = h;
3895 return 0;
3896 }
3897 
3898 dlclose(h);
3899 }
3900 
3901 memset(api, 0, sizeof(*api));
3902 return -1;
3903}
3904 
3905typedef struct {
3906 char names[64][64];
3907 size_t count;
3908} appdb_table_list_t;
3909 
3910typedef struct {
3911 char names[128][64];
3912 size_t count;
3913} appdb_column_list_t;
3914 
3915typedef struct {
3916 int value;
3917 int have_value;
3918} appdb_int_result_t;
3919 
3920static int psx_appdb_collect_tables_cb(void *ctx, int argc, char **argv,
3921 char **cols) {
3922 (void)cols;
3923 if (ctx == NULL || argc < 1 || argv == NULL || argv[0] == NULL) {
3924 return 0;
3925 }
3926 
3927 appdb_table_list_t *list = (appdb_table_list_t *)ctx;
3928 if (list->count >= (sizeof(list->names) / sizeof(list->names[0]))) {
3929 return 0;
3930 }
3931 
3932 size_t n = strlen(argv[0]);
3933 if (n == 0U || n >= sizeof(list->names[0])) {
3934 return 0;
3935 }
3936 memcpy(list->names[list->count], argv[0], n + 1U);
3937 list->count++;
3938 return 0;
3939}
3940 
3941static int psx_appdb_collect_columns_cb(void *ctx, int argc, char **argv,
3942 char **cols) {
3943 (void)cols;
3944 if (ctx == NULL || argc < 2 || argv == NULL || argv[1] == NULL) {
3945 return 0;
3946 }
3947 
3948 appdb_column_list_t *list = (appdb_column_list_t *)ctx;
3949 if (list->count >= (sizeof(list->names) / sizeof(list->names[0]))) {
3950 return 0;
3951 }
3952 
3953 size_t n = strlen(argv[1]);
3954 if (n == 0U || n >= sizeof(list->names[0])) {
3955 return 0;
3956 }
3957 memcpy(list->names[list->count], argv[1], n + 1U);
3958 list->count++;
3959 return 0;
3960}
3961 
3962static int psx_appdb_read_int_cb(void *ctx, int argc, char **argv, char **cols) {
3963 (void)cols;
3964 if (ctx == NULL || argc < 1 || argv == NULL || argv[0] == NULL) {
3965 return 0;
3966 }
3967 appdb_int_result_t *r = (appdb_int_result_t *)ctx;
3968 r->value = atoi(argv[0]);
3969 r->have_value = 1;
3970 return 0;
3971}
3972 
3973static void psx_sql_escape_text(const char *in, char *out, size_t out_size) {
3974 if (out == NULL || out_size == 0U) {
3975 return;
3976 }
3977 out[0] = '\0';
3978 if (in == NULL) {
3979 return;
3980 }
3981 
3982 size_t o = 0U;
3983 for (size_t i = 0; in[i] != '\0' && o + 1U < out_size; i++) {
3984 if (in[i] == '\'') {
3985 if (o + 2U >= out_size) {
3986 break;
3987 }
3988 out[o++] = '\'';
3989 out[o++] = '\'';
3990 continue;
3991 }
3992 out[o++] = in[i];
3993 }
3994 out[o] = '\0';
3995}
3996 
3997static int psx_appdb_insert_missing_from_template(psx_sqlite_api_t *sql,
3998 sqlite3 *db,
3999 const char *table_name,
4000 const char *title_id,
4001 const char *title_name,
4002 const char *meta_path,
4003 const char *content_id,
4004 const char *category) {
4005 if (!sql || !db || !table_name || !title_id) {
4006 return -1;
4007 }
4008 
4009 char q[256];
4010 int n = snprintf(q, sizeof(q), "PRAGMA table_info(\"%s\")", table_name);
4011 if (n <= 0 || (size_t)n >= sizeof(q)) {
4012 return -1;
4013 }
4014 
4015 appdb_column_list_t cols;
4016 memset(&cols, 0, sizeof(cols));
4017 char *err = NULL;
4018 if (sql->exec(db, q, psx_appdb_collect_columns_cb, &cols, &err) != 0) {
4019 if (err) {
4020 sql->free_fn(err);
4021 }
4022 return -1;
4023 }
4024 if (err) {
4025 sql->free_fn(err);
4026 err = NULL;
4027 }
4028 if (cols.count == 0U) {
4029 return -1;
4030 }
4031 
4032 char esc_tid[96], esc_tname[512], esc_meta[256], esc_cid[160], esc_cat[64];
4033 psx_sql_escape_text(title_id, esc_tid, sizeof(esc_tid));
4034 psx_sql_escape_text(title_name ? title_name : title_id, esc_tname,
4035 sizeof(esc_tname));
4036 psx_sql_escape_text(meta_path ? meta_path : "", esc_meta, sizeof(esc_meta));
4037 psx_sql_escape_text(content_id ? content_id : "", esc_cid, sizeof(esc_cid));
4038 psx_sql_escape_text(category ? category : "gd", esc_cat, sizeof(esc_cat));
4039 
4040 char col_sql[8192];
4041 char sel_sql[12288];
4042 col_sql[0] = '\0';
4043 sel_sql[0] = '\0';
4044 size_t col_pos = 0U;
4045 size_t sel_pos = 0U;
4046 
4047 for (size_t i = 0; i < cols.count; i++) {
4048 const char *c = cols.names[i];
4049 if (i > 0) {
4050 int nn = snprintf(col_sql + col_pos, sizeof(col_sql) - col_pos, ",");
4051 if (nn <= 0 || (size_t)nn >= (sizeof(col_sql) - col_pos)) {
4052 return -1;
4053 }
4054 col_pos += (size_t)nn;
4055 
4056 nn = snprintf(sel_sql + sel_pos, sizeof(sel_sql) - sel_pos, ",");
4057 if (nn <= 0 || (size_t)nn >= (sizeof(sel_sql) - sel_pos)) {
4058 return -1;
4059 }
4060 sel_pos += (size_t)nn;
4061 }
4062 
4063 int nn = snprintf(col_sql + col_pos, sizeof(col_sql) - col_pos, "\"%s\"", c);
4064 if (nn <= 0 || (size_t)nn >= (sizeof(col_sql) - col_pos)) {
4065 return -1;
4066 }
4067 col_pos += (size_t)nn;
4068 
4069 if (strcmp(c, "titleId") == 0) {
4070 nn = snprintf(sel_sql + sel_pos, sizeof(sel_sql) - sel_pos, "'%s'",
4071 esc_tid);
4072 } else if (strcmp(c, "titleName") == 0 ||
4073 strcmp(c, "entitlementTitleName") == 0) {
4074 nn = snprintf(sel_sql + sel_pos, sizeof(sel_sql) - sel_pos, "'%s'",
4075 esc_tname);
4076 } else if (strcmp(c, "metaDataPath") == 0) {
4077 nn = snprintf(sel_sql + sel_pos, sizeof(sel_sql) - sel_pos, "'%s'",
4078 esc_meta);
4079 } else if (strcmp(c, "contentId") == 0) {
4080 nn = snprintf(sel_sql + sel_pos, sizeof(sel_sql) - sel_pos, "'%s'",
4081 esc_cid);
4082 } else if (strcmp(c, "visible") == 0 || strcmp(c, "canRemove") == 0) {
4083 nn = snprintf(sel_sql + sel_pos, sizeof(sel_sql) - sel_pos, "1");
4084 } else if (strcmp(c, "externalHddAppStatus") == 0) {
4085 nn = snprintf(sel_sql + sel_pos, sizeof(sel_sql) - sel_pos, "0");
4086 } else if (strcmp(c, "category") == 0) {
4087 nn = snprintf(sel_sql + sel_pos, sizeof(sel_sql) - sel_pos, "'%s'",
4088 esc_cat);
4089 } else if (strcmp(c, "platform") == 0) {
4090 nn = snprintf(sel_sql + sel_pos, sizeof(sel_sql) - sel_pos,
4091 "CASE WHEN '%s'='gde' THEN 'app' ELSE 'game' END",
4092 esc_cat);
4093 } else {
4094 nn = snprintf(sel_sql + sel_pos, sizeof(sel_sql) - sel_pos,
4095 "src.\"%s\"", c);
4096 }
4097 
4098 if (nn <= 0 || (size_t)nn >= (sizeof(sel_sql) - sel_pos)) {
4099 return -1;
4100 }
4101 sel_pos += (size_t)nn;
4102 }
4103 
4104 char ins[24576];
4105 n = snprintf(ins, sizeof(ins),
4106 "INSERT OR IGNORE INTO \"%s\"(%s) SELECT %s FROM \"%s\" AS src "
4107 "LIMIT 1",
4108 table_name, col_sql, sel_sql, table_name);
4109 if (n <= 0 || (size_t)n >= sizeof(ins)) {
4110 return -1;
4111 }
4112 
4113 if (sql->exec(db, ins, NULL, NULL, &err) != 0) {
4114 if (err) {
4115 sql->free_fn(err);
4116 }
4117 return -1;
4118 }
4119 if (err) {
4120 sql->free_fn(err);
4121 }
4122 return 0;
4123}
4124 
4125static int psx_repair_appdb_visibility_for_title(const char *title_id,
4126 int *out_tables,
4127 int *out_rows) {
4128 if (out_tables) {
4129 *out_tables = 0;
4130 }
4131 if (out_rows) {
4132 *out_rows = 0;
4133 }
4134 if (title_id == NULL || title_id[0] == '\0') {
4135 return -1;
4136 }
4137 
4138 psx_sqlite_api_t sql;
4139 if (psx_sqlite_load_api(&sql) != 0) {
4140 return -2;
4141 }
4142 
4143 sqlite3 *db = NULL;
4144 int rc = sql.open_v2("/system_data/priv/mms/app.db", &db,
4145 SQLITE_OPEN_READWRITE, NULL);
4146 if (rc != 0 || db == NULL) {
4147 if (sql.lib_handle != NULL) {
4148 dlclose(sql.lib_handle);
4149 }
4150 return -3;
4151 }
4152 
4153 appdb_table_list_t tables;
4154 memset(&tables, 0, sizeof(tables));
4155 
4156 char *err = NULL;
4157 (void)sql.exec(db,
4158 "SELECT name FROM sqlite_master WHERE type='table' AND name "
4159 "LIKE 'tbl_appbrowse_%'",
4160 psx_appdb_collect_tables_cb, &tables, &err);
4161 if (err != NULL) {
4162 sql.free_fn(err);
4163 err = NULL;
4164 }
4165 
4166 int total_rows = 0;
4167 int touched_tables = 0;
4168 
4169 char app_dir[FTP_PATH_MAX] = {0};
4170 char title_name[256] = {0};
4171 char content_id[128] = {0};
4172 char category[32] = {0};
4173 char meta_path[FTP_PATH_MAX] = {0};
4174 
4175 if (resolve_installed_app_dir_by_title(title_id, app_dir, sizeof(app_dir)) ==
4176 0) {
4177 char tid_tmp[64] = {0};
4178 (void)read_installed_game_sfo(app_dir, tid_tmp, sizeof(tid_tmp), title_name,
4179 sizeof(title_name));
4180 (void)read_installed_game_sfo_field(app_dir, "CONTENT_ID", content_id,
4181 sizeof(content_id));
4182 (void)read_installed_game_sfo_field(app_dir, "CATEGORY", category,
4183 sizeof(category));
4184 }
4185 if (title_name[0] == '\0') {
4186 (void)snprintf(title_name, sizeof(title_name), "%s", title_id);
4187 }
4188 if (content_id[0] == '\0') {
4189 (void)snprintf(content_id, sizeof(content_id), "FAKE0000-%s_00-0000000000000000",
4190 title_id);
4191 }
4192 if (category[0] == '\0') {
4193 (void)snprintf(category, sizeof(category), "gd");
4194 }
4195 (void)snprintf(meta_path, sizeof(meta_path), "/user/appmeta/%s", title_id);
4196 
4197 for (size_t i = 0; i < tables.count; i++) {
4198 char q[1024];
4199 int n = snprintf(q, sizeof(q),
4200 "SELECT COUNT(*) FROM \"%s\" WHERE titleId='%s'",
4201 tables.names[i], title_id);
4202 int row_exists = 0;
4203 if (n > 0 && (size_t)n < sizeof(q)) {
4204 appdb_int_result_t cnt;
4205 memset(&cnt, 0, sizeof(cnt));
4206 if (sql.exec(db, q, psx_appdb_read_int_cb, &cnt, &err) == 0 &&
4207 cnt.have_value && cnt.value > 0) {
4208 row_exists = 1;
4209 }
4210 if (err != NULL) {
4211 sql.free_fn(err);
4212 err = NULL;
4213 }
4214 }
4215 
4216 if (!row_exists) {
4217 if (psx_appdb_insert_missing_from_template(&sql, db, tables.names[i],
4218 title_id, title_name,
4219 meta_path, content_id,
4220 category) == 0) {
4221 int ins = sql.changes(db);
4222 if (ins > 0) {
4223 touched_tables++;
4224 total_rows += ins;
4225 }
4226 }
4227 }
4228 
4229 n = snprintf(q, sizeof(q),
4230 "UPDATE \"%s\" SET visible=1 WHERE titleId='%s'",
4231 tables.names[i], title_id);
4232 if (n <= 0 || (size_t)n >= sizeof(q)) {
4233 continue;
4234 }
4235 
4236 if (sql.exec(db, q, NULL, NULL, &err) == 0) {
4237 int ch = sql.changes(db);
4238 if (ch > 0) {
4239 touched_tables++;
4240 total_rows += ch;
4241 }
4242 }
4243 if (err != NULL) {
4244 sql.free_fn(err);
4245 err = NULL;
4246 }
4247 
4248 n = snprintf(q, sizeof(q),
4249 "UPDATE \"%s\" SET externalHddAppStatus=0 WHERE "
4250 "titleId='%s'",
4251 tables.names[i], title_id);
4252 if (n > 0 && (size_t)n < sizeof(q)) {
4253 (void)sql.exec(db, q, NULL, NULL, &err);
4254 if (err != NULL) {
4255 sql.free_fn(err);
4256 err = NULL;
4257 }
4258 }
4259 }
4260 
4261 (void)sql.exec(db,
4262 "UPDATE tbl_appinfo SET val=0 WHERE "
4263 "key='_external_hdd_app_status'",
4264 NULL, NULL, &err);
4265 if (err != NULL) {
4266 sql.free_fn(err);
4267 err = NULL;
4268 }
4269 
4270 {
4271 char q[1024];
4272 int n = 0;
4273 n = snprintf(q, sizeof(q),
4274 "INSERT OR IGNORE INTO tbl_appinfo(titleId,key,val) VALUES('%s','TITLE_ID','%s')",
4275 title_id, title_id);
4276 if (n > 0 && (size_t)n < sizeof(q)) {
4277 (void)sql.exec(db, q, NULL, NULL, &err);
4278 if (err != NULL) {
4279 sql.free_fn(err);
4280 err = NULL;
4281 }
4282 }
4283 
4284 n = snprintf(q, sizeof(q),
4285 "INSERT OR IGNORE INTO tbl_appinfo(titleId,key,val) VALUES('%s','TITLE','%s')",
4286 title_id, title_name);
4287 if (n > 0 && (size_t)n < sizeof(q)) {
4288 (void)sql.exec(db, q, NULL, NULL, &err);
4289 if (err != NULL) {
4290 sql.free_fn(err);
4291 err = NULL;
4292 }
4293 }
4294 
4295 n = snprintf(q, sizeof(q),
4296 "INSERT OR IGNORE INTO tbl_appinfo(titleId,key,val) VALUES('%s','CONTENT_ID','%s')",
4297 title_id, content_id);
4298 if (n > 0 && (size_t)n < sizeof(q)) {
4299 (void)sql.exec(db, q, NULL, NULL, &err);
4300 if (err != NULL) {
4301 sql.free_fn(err);
4302 err = NULL;
4303 }
4304 }
4305 
4306 n = snprintf(q, sizeof(q),
4307 "INSERT OR IGNORE INTO tbl_appinfo(titleId,key,val) VALUES('%s','CATEGORY','%s')",
4308 title_id, category);
4309 if (n > 0 && (size_t)n < sizeof(q)) {
4310 (void)sql.exec(db, q, NULL, NULL, &err);
4311 if (err != NULL) {
4312 sql.free_fn(err);
4313 err = NULL;
4314 }
4315 }
4316 
4317 n = snprintf(q, sizeof(q),
4318 "INSERT OR IGNORE INTO tbl_appinfo(titleId,key,val) VALUES('%s','_metadata_path','%s')",
4319 title_id, meta_path);
4320 if (n > 0 && (size_t)n < sizeof(q)) {
4321 (void)sql.exec(db, q, NULL, NULL, &err);
4322 if (err != NULL) {
4323 sql.free_fn(err);
4324 err = NULL;
4325 }
4326 }
4327 
4328 n = snprintf(q, sizeof(q),
4329 "INSERT OR IGNORE INTO tbl_appinfo(titleId,key,val) VALUES('%s','_org_path','/user/app/%s')",
4330 title_id, title_id);
4331 if (n > 0 && (size_t)n < sizeof(q)) {
4332 (void)sql.exec(db, q, NULL, NULL, &err);
4333 if (err != NULL) {
4334 sql.free_fn(err);
4335 err = NULL;
4336 }
4337 }
4338 }
4339 
4340 (void)sql.close(db);
4341 if (sql.lib_handle != NULL) {
4342 dlclose(sql.lib_handle);
4343 }
4344 
4345 if (out_tables) {
4346 *out_tables = touched_tables;
4347 }
4348 if (out_rows) {
4349 *out_rows = total_rows;
4350 }
4351 return 0;
4352}
4353#endif
4354 
4355static int append_installed_entries_from_base(const char *base,
4356 char *body,
4357 size_t cap,
4358 size_t *pos,
4359 int *first,
4360 size_t *count_added) {
4361 if (!base || !body || !pos || !first || !count_added) {
4362 return -1;
4363 }
4364 
4365 DIR *dir = opendir(base);
4366 if (dir == NULL) {
4367 return 0;
4368 }
4369 
4370 struct dirent *ent;
4371 while ((ent = readdir(dir)) != NULL) {
4372 if ((strcmp(ent->d_name, ".") == 0) || (strcmp(ent->d_name, "..") == 0)) {
4373 continue;
4374 }
4375 
4376 char app_dir[FTP_PATH_MAX];
4377 int n = snprintf(app_dir, sizeof(app_dir), "%s/%s", base, ent->d_name);
4378 if (n < 0 || (size_t)n >= sizeof(app_dir)) {
4379 continue;
4380 }
4381 
4382 struct stat st;
4383 if (stat(app_dir, &st) != 0 || !S_ISDIR(st.st_mode)) {
4384 continue;
4385 }
4386 
4387 char title_id[64] = {0};
4388 char title_name[256] = {0};
4389 (void)snprintf(title_id, sizeof(title_id), "%s", ent->d_name);
4390 (void)read_installed_game_sfo(app_dir, title_id, sizeof(title_id), title_name,
4391 sizeof(title_name));
4392 if (title_name[0] == '\0') {
4393 (void)snprintf(title_name, sizeof(title_name), "%s", title_id);
4394 }
4395 
4396 char icon_path[FTP_PATH_MAX] = {0};
4397 int has_icon = (resolve_installed_icon_path(title_id, app_dir, icon_path,
4398 sizeof(icon_path)) == 0);
4399 
4400 if (!*first) {
4401 if (buf_append_cstr(body, cap, pos, ",") != 0) {
4402 closedir(dir);
4403 return -1;
4404 }
4405 }
4406 *first = 0;
4407 (*count_added)++;
4408 
4409 if (buf_append_cstr(body, cap, pos, "{\"id\":\"") != 0 ||
4410 json_escape_append(body, cap, pos, title_id) != 0 ||
4411 buf_append_cstr(body, cap, pos, "\",\"name\":\"") != 0 ||
4412 json_escape_append(body, cap, pos, title_name) != 0 ||
4413 buf_append_cstr(body, cap, pos, "\",\"path\":\"") != 0 ||
4414 json_escape_append(body, cap, pos, app_dir) != 0 ||
4415 buf_append_cstr(body, cap, pos, "\",\"source\":\"") != 0 ||
4416 json_escape_append(body, cap, pos, base) != 0 ||
4417 buf_append_cstr(body, cap, pos, "\",\"has_icon\":") != 0 ||
4418 buf_append_cstr(body, cap, pos, has_icon ? "true" : "false") != 0 ||
4419 buf_append_cstr(body, cap, pos, "}") != 0) {
4420 closedir(dir);
4421 return -1;
4422 }
4423 }
4424 
4425 closedir(dir);
4426 return 0;
4427}
4428 
4429static int extract_title_id_from_game_image(const char *safe_path,
4430 char *title_id,
4431 size_t title_id_size) {
4432 if ((safe_path == NULL) || (title_id == NULL) || (title_id_size < 10U)) {
4433 return -1;
4434 }
4435 
4436 title_id[0] = '\0';
4437 
4438 /* 1) Try PKG */
4439 pkg_context_t pkg_ctx;
4440 if (pkg_init(&pkg_ctx, safe_path) == PKG_OK) {
4441 const pkg_entry_t *sfo_entry =
4442 pkg_find_entry_by_id(&pkg_ctx, PKG_ENTRY_ID_PARAM_SFO);
4443 if (sfo_entry && sfo_entry->size > 0U && sfo_entry->size <= 65536U) {
4444 uint8_t *sfo_data = (uint8_t *)malloc((size_t)sfo_entry->size);
4445 if (sfo_data != NULL) {
4446 if (pkg_extract_to_buffer(&pkg_ctx, sfo_entry, sfo_data,
4447 (size_t)sfo_entry->size) > 0) {
4448 (void)sfo_get_string(sfo_data, (size_t)sfo_entry->size, "TITLE_ID",
4449 title_id, title_id_size);
4450 if (title_id[0] == '\0') {
4451 char cid[64] = "";
4452 (void)sfo_get_string(sfo_data, (size_t)sfo_entry->size,
4453 "CONTENT_ID", cid, sizeof(cid));
4454 (void)title_id_from_content_id(cid, title_id, title_id_size);
4455 }
4456 }
4457 free(sfo_data);
4458 }
4459 }
4460 
4461 if (title_id[0] == '\0') {
4462 (void)title_id_from_content_id(pkg_ctx.header.content_id,
4463 title_id, title_id_size);
4464 }
4465 
4466 pkg_cleanup(&pkg_ctx);
4467 return (title_id[0] != '\0') ? 0 : -1;
4468 }
4469 
4470 /* 2) Try exFAT image */
4471 exfat_context_t ctx;
4472 if (exfat_init(&ctx, safe_path) != 0) {
4473 return -1;
4474 }
4475 
4476 exfat_file_info_t root_entries[256];
4477 int root_count = exfat_read_directory(&ctx,
4478 ctx.boot_sector.root_dir_first_cluster,
4479 root_entries, 256);
4480 
4481 for (int i = 0; i < root_count; i++) {
4482 if (!root_entries[i].is_directory) {
4483 continue;
4484 }
4485 if (strcasecmp(root_entries[i].filename, "sce_sys") != 0) {
4486 continue;
4487 }
4488 
4489 exfat_file_info_t sce_entries[64];
4490 int sce_count = exfat_read_directory(&ctx,
4491 root_entries[i].first_cluster,
4492 sce_entries, 64);
4493 
4494 for (int j = 0; j < sce_count; j++) {
4495 if (sce_entries[j].is_directory) {
4496 continue;
4497 }
4498 
4499 if (strcasecmp(sce_entries[j].filename, "param.sfo") == 0 &&
4500 sce_entries[j].data_length > 0U &&
4501 sce_entries[j].data_length <= 65536U) {
4502 size_t slen = (size_t)sce_entries[j].data_length;
4503 uint8_t *sbuf = (uint8_t *)malloc(slen);
4504 if (sbuf != NULL) {
4505 ssize_t got = exfat_extract_to_buffer(&ctx, &sce_entries[j], sbuf,
4506 slen);
4507 if (got > 0) {
4508 (void)sfo_get_string(sbuf, (size_t)got, "TITLE_ID", title_id,
4509 title_id_size);
4510 if (title_id[0] == '\0') {
4511 char cid[64] = "";
4512 (void)sfo_get_string(sbuf, (size_t)got, "CONTENT_ID", cid,
4513 sizeof(cid));
4514 (void)title_id_from_content_id(cid, title_id, title_id_size);
4515 }
4516 }
4517 free(sbuf);
4518 }
4519 }
4520 
4521 if (title_id[0] == '\0' &&
4522 strcasecmp(sce_entries[j].filename, "param.json") == 0 &&
4523 sce_entries[j].data_length > 0U &&
4524 sce_entries[j].data_length <= (256U * 1024U)) {
4525 size_t plen = (size_t)sce_entries[j].data_length;
4526 uint8_t *pbuf = (uint8_t *)malloc(plen + 1U);
4527 if (pbuf != NULL) {
4528 ssize_t got = exfat_extract_to_buffer(&ctx, &sce_entries[j], pbuf,
4529 plen);
4530 if (got > 0) {
4531 pbuf[got] = '\0';
4532 (void)json_get_string((char *)pbuf, "titleId", title_id,
4533 title_id_size);
4534 if (title_id[0] == '\0') {
4535 (void)json_get_string((char *)pbuf, "title_id", title_id,
4536 title_id_size);
4537 }
4538 if (title_id[0] == '\0') {
4539 char cid[64] = "";
4540 (void)json_get_string((char *)pbuf, "contentId", cid,
4541 sizeof(cid));
4542 if (cid[0] == '\0') {
4543 (void)json_get_string((char *)pbuf, "content_id", cid,
4544 sizeof(cid));
4545 }
4546 (void)title_id_from_content_id(cid, title_id, title_id_size);
4547 }
4548 }
4549 free(pbuf);
4550 }
4551 }
4552 
4553 if (title_id[0] != '\0') {
4554 break;
4555 }
4556 }
4557 break;
4558 }
4559 
4560 exfat_cleanup(&ctx);
4561 return (title_id[0] != '\0') ? 0 : -1;
4562}
4563 
4564#define GAME_META_MAX_ENTRIES 256
4565#define GAME_META_SCE_ENTRIES 64
4566#define GAME_META_MAX_PARAM (256 * 1024)
4567#define GAME_META_MAX_ICON (2 * 1024 * 1024)
4568 
4569static http_response_t *api_game_meta(const http_request_t *request) {
4570 const char *query = strchr(request->uri, '?');
4571 char path[1024] = "/";
4572 if (query) (void)parse_path_param(query, path, sizeof(path));
4573 
4574 char safe[FTP_PATH_MAX];
4575 if (!validate_path(path, safe, sizeof(safe))) {
4576 return error_json(HTTP_STATUS_403_FORBIDDEN, "Path traversal blocked");
4577 }
4578 
4579 char title_id[64] = "";
4580 char title_name[256] = "";
4581 char version[64] = "";
4582 char category[64] = "";
4583 char content_id[48] = "";
4584 uint8_t *icon_data = NULL;
4585 size_t icon_size = 0;
4586 
4587 /* 1. Try PKG archive first */
4588 pkg_context_t pkg_ctx;
4589 if (pkg_init(&pkg_ctx, safe) == PKG_OK) {
4590 fprintf(stderr, "[PKG] Successfully opened %s (entries: %u)\n", safe, pkg_ctx.header.entry_count);
4591
4592 /* Always grab content_id from PKG header */
4593 snprintf(content_id, sizeof(content_id), "%.36s", pkg_ctx.header.content_id);
4594 
4595 const pkg_entry_t *sfo_entry = pkg_find_entry_by_id(&pkg_ctx, PKG_ENTRY_ID_PARAM_SFO);
4596 if (sfo_entry && sfo_entry->size > 0 && sfo_entry->size <= 65536) {
4597 uint8_t *sfo_data = (uint8_t *)malloc((size_t)sfo_entry->size);
4598 if (sfo_data) {
4599 if (pkg_extract_to_buffer(&pkg_ctx, sfo_entry, sfo_data, (size_t)sfo_entry->size) > 0) {
4600 sfo_get_string(sfo_data, (size_t)sfo_entry->size, "TITLE_ID", title_id, sizeof(title_id));
4601 sfo_get_string(sfo_data, (size_t)sfo_entry->size, "TITLE", title_name, sizeof(title_name));
4602 sfo_get_string(sfo_data, (size_t)sfo_entry->size, "APP_VER", version, sizeof(version));
4603 sfo_get_string(sfo_data, (size_t)sfo_entry->size, "CATEGORY", category, sizeof(category));
4604 /* Also try CONTENT_ID from SFO (more authoritative than PKG header) */
4605 {
4606 char sfo_cid[48] = "";
4607 sfo_get_string(sfo_data, (size_t)sfo_entry->size, "CONTENT_ID", sfo_cid, sizeof(sfo_cid));
4608 if (sfo_cid[0]) {
4609 strncpy(content_id, sfo_cid, sizeof(content_id) - 1);
4610 content_id[sizeof(content_id) - 1] = '\0';
4611 }
4612 }
4613 }
4614 free(sfo_data);
4615 }
4616 }
4617 
4618 if (!title_id[0]) {
4619 snprintf(title_id, sizeof(title_id), "%.36s", pkg_ctx.header.content_id);
4620 }
4621 if (!title_name[0]) {
4622 snprintf(title_name, sizeof(title_name), "%.36s", pkg_ctx.header.content_id);
4623 }
4624 
4625 const pkg_entry_t *entry = pkg_find_entry_by_id(&pkg_ctx, PKG_ENTRY_ID_ICON0_PNG);
4626 if (!entry) {
4627 entry = pkg_find_entry_by_id(&pkg_ctx, PKG_ENTRY_ID_PIC0_PNG); /* fallback */
4628 }
4629 
4630 if (entry) {
4631 if (pkg_entry_is_encrypted(entry)) {
4632 fprintf(stderr, "[PKG] Icon entry is encrypted! Cannot extract.\n");
4633 } else if (entry->size > GAME_META_MAX_ICON) {
4634 fprintf(stderr, "[PKG] Icon too large: %u\n", entry->size);
4635 } else {
4636 icon_size = (size_t)entry->size;
4637 icon_data = (uint8_t *)malloc(icon_size);
4638 if (icon_data) {
4639 ssize_t got = pkg_extract_to_buffer(&pkg_ctx, entry, icon_data, icon_size);
4640 if (got > 0) {
4641 icon_size = (size_t)got;
4642 fprintf(stderr, "[PKG] Icon successfully extracted (%zu bytes)\n", icon_size);
4643 } else {
4644 free(icon_data); icon_data = NULL; icon_size = 0;
4645 fprintf(stderr, "[PKG] Failed to extract icon (err: %zd)\n", got);
4646 }
4647 }
4648 }
4649 } else {
4650 fprintf(stderr, "[PKG] Could not find any icon entry in PKG\n");
4651 }
4652 pkg_cleanup(&pkg_ctx);
4653 } else {
4654 /* 2. Fall back to exFAT image */
4655 exfat_context_t ctx;
4656 if (exfat_init(&ctx, safe) != 0) {
4657 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Not a valid PKG or exFAT image");
4658 }
4659 
4660 /* Scan root directory for sce_sys */
4661 exfat_file_info_t root_entries[GAME_META_MAX_ENTRIES];
4662 int root_count = exfat_read_directory(&ctx,
4663 ctx.boot_sector.root_dir_first_cluster,
4664 root_entries, GAME_META_MAX_ENTRIES);
4665 
4666 for (int i = 0; i < root_count; i++) {
4667 if (!root_entries[i].is_directory) continue;
4668 if (strcasecmp(root_entries[i].filename, "sce_sys") != 0) continue;
4669 
4670 /* Read sce_sys contents */
4671 exfat_file_info_t sce_entries[GAME_META_SCE_ENTRIES];
4672 int sce_count = exfat_read_directory(&ctx,
4673 root_entries[i].first_cluster,
4674 sce_entries, GAME_META_SCE_ENTRIES);
4675 
4676 for (int j = 0; j < sce_count; j++) {
4677 if (sce_entries[j].is_directory) continue;
4678 
4679 /* param.sfo */
4680 if (strcasecmp(sce_entries[j].filename, "param.sfo") == 0 &&
4681 sce_entries[j].data_length > 0 &&
4682 sce_entries[j].data_length <= 65536) {
4683 size_t slen = (size_t)sce_entries[j].data_length;
4684 uint8_t *sbuf = (uint8_t *)malloc(slen);
4685 if (sbuf) {
4686 ssize_t got = exfat_extract_to_buffer(&ctx, &sce_entries[j], sbuf, slen);
4687 if (got > 0) {
4688 sfo_get_string(sbuf, (size_t)got, "TITLE_ID", title_id, sizeof(title_id));
4689 sfo_get_string(sbuf, (size_t)got, "TITLE", title_name, sizeof(title_name));
4690 sfo_get_string(sbuf, (size_t)got, "APP_VER", version, sizeof(version));
4691 sfo_get_string(sbuf, (size_t)got, "CATEGORY", category, sizeof(category));
4692 {
4693 char sfo_cid[48] = "";
4694 sfo_get_string(sbuf, (size_t)got, "CONTENT_ID", sfo_cid, sizeof(sfo_cid));
4695 if (sfo_cid[0]) {
4696 strncpy(content_id, sfo_cid, sizeof(content_id) - 1U);
4697 content_id[sizeof(content_id) - 1U] = '\0';
4698 }
4699 }
4700 }
4701 free(sbuf);
4702 }
4703 }
4704 
4705 /* param.json */
4706 if (strcasecmp(sce_entries[j].filename, "param.json") == 0 &&
4707 sce_entries[j].data_length > 0 &&
4708 sce_entries[j].data_length <= GAME_META_MAX_PARAM) {
4709 size_t plen = (size_t)sce_entries[j].data_length;
4710 uint8_t *pbuf = (uint8_t *)malloc(plen + 1);
4711 if (pbuf) {
4712 ssize_t got = exfat_extract_to_buffer(&ctx, &sce_entries[j], pbuf, plen);
4713 if (got > 0) {
4714 pbuf[got] = '\0';
4715 json_get_string((char *)pbuf, "titleId", title_id, sizeof(title_id));
4716 if (!title_id[0])
4717 json_get_string((char *)pbuf, "title_id", title_id, sizeof(title_id));
4718 json_get_string((char *)pbuf, "titleName", title_name, sizeof(title_name));
4719 json_get_string((char *)pbuf, "contentVersion", version, sizeof(version));
4720 if (!version[0])
4721 json_get_string((char *)pbuf, "appVer", version, sizeof(version));
4722 json_get_string((char *)pbuf, "category", category, sizeof(category));
4723 if (!content_id[0]) {
4724 json_get_string((char *)pbuf, "contentId", content_id, sizeof(content_id));
4725 if (!content_id[0]) {
4726 json_get_string((char *)pbuf, "content_id", content_id, sizeof(content_id));
4727 }
4728 }
4729 }
4730 free(pbuf);
4731 }
4732 }
4733 
4734 /* icon0.png */
4735 if (strcasecmp(sce_entries[j].filename, "icon0.png") == 0 &&
4736 sce_entries[j].data_length > 0 &&
4737 sce_entries[j].data_length <= GAME_META_MAX_ICON) {
4738 icon_size = (size_t)sce_entries[j].data_length;
4739 icon_data = (uint8_t *)malloc(icon_size);
4740 if (icon_data) {
4741 ssize_t got = exfat_extract_to_buffer(&ctx, &sce_entries[j],
4742 icon_data, icon_size);
4743 if (got > 0) icon_size = (size_t)got;
4744 else { free(icon_data); icon_data = NULL; icon_size = 0; }
4745 }
4746 }
4747 }
4748 break; /* found sce_sys */
4749 }
4750 
4751 exfat_cleanup(&ctx);
4752 }
4753 
4754 if (!title_id[0] && content_id[0]) {
4755 (void)title_id_from_content_id(content_id, title_id, sizeof(title_id));
4756 }
4757 
4758 /* Build JSON response */
4759 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
4760 http_response_add_header(resp, "Content-Type", "application/json");
4761 http_response_add_header(resp, "Cache-Control", "max-age=3600");
4762 
4763 size_t body_cap = 1024;
4764 char *body = (char *)malloc(body_cap);
4765 if (!body) {
4766 if (icon_data) free(icon_data);
4767 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
4768 }
4769 
4770 int blen = snprintf(body, body_cap,
4771 "{\"title_id\":\"%s\",\"title_name\":\"%s\",\"version\":\"%s\","
4772 "\"category\":\"%s\",\"content_id\":\"%s\",\"has_icon\":%s}",
4773 title_id, title_name, version, category, content_id,
4774 (icon_data && icon_size > 0) ? "true" : "false");
4775 
4776 http_response_set_body(resp, body, (size_t)blen);
4777 
4778 free(body);
4779 if (icon_data) free(icon_data);
4780 return resp;
4781}
4782 
4783static http_response_t *api_game_icon(const http_request_t *request) {
4784 const char *query = strchr(request->uri, '?');
4785 char path[1024] = "/";
4786 if (query) (void)parse_path_param(query, path, sizeof(path));
4787 
4788 char safe[FTP_PATH_MAX];
4789 if (!validate_path(path, safe, sizeof(safe))) {
4790 return error_json(HTTP_STATUS_403_FORBIDDEN, "Path traversal blocked");
4791 }
4792 
4793 uint8_t *icon_data = NULL;
4794 size_t icon_size = 0;
4795 
4796 /* 1. Try PKG archive first */
4797 pkg_context_t pkg_ctx;
4798 if (pkg_init(&pkg_ctx, safe) == PKG_OK) {
4799 fprintf(stderr, "[PKG-ICON] Successfully opened %s\n", safe);
4800 const pkg_entry_t *entry = pkg_find_entry_by_id(&pkg_ctx, PKG_ENTRY_ID_ICON0_PNG);
4801 if (!entry) {
4802 entry = pkg_find_entry_by_id(&pkg_ctx, PKG_ENTRY_ID_PIC0_PNG); /* fallback */
4803 }
4804 
4805 if (entry) {
4806 if (pkg_entry_is_encrypted(entry)) {
4807 fprintf(stderr, "[PKG-ICON] Icon entry is encrypted! Cannot extract.\n");
4808 } else if (entry->size > GAME_META_MAX_ICON) {
4809 fprintf(stderr, "[PKG-ICON] Icon too large: %u\n", entry->size);
4810 } else {
4811 icon_size = (size_t)entry->size;
4812 icon_data = (uint8_t *)malloc(icon_size);
4813 if (icon_data) {
4814 ssize_t got = pkg_extract_to_buffer(&pkg_ctx, entry, icon_data, icon_size);
4815 if (got > 0) icon_size = (size_t)got;
4816 else { free(icon_data); icon_data = NULL; icon_size = 0; }
4817 }
4818 }
4819 }
4820 pkg_cleanup(&pkg_ctx);
4821 } else {
4822 /* 2. Fall back to exFAT image */
4823 exfat_context_t ctx;
4824 if (exfat_init(&ctx, safe) != 0) {
4825 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Not a valid PKG or exFAT image");
4826 }
4827 
4828 exfat_file_info_t root_entries[GAME_META_MAX_ENTRIES];
4829 int root_count = exfat_read_directory(&ctx,
4830 ctx.boot_sector.root_dir_first_cluster,
4831 root_entries, GAME_META_MAX_ENTRIES);
4832 
4833 for (int i = 0; i < root_count; i++) {
4834 if (!root_entries[i].is_directory) continue;
4835 if (strcasecmp(root_entries[i].filename, "sce_sys") != 0) continue;
4836 
4837 exfat_file_info_t sce_entries[GAME_META_SCE_ENTRIES];
4838 int sce_count = exfat_read_directory(&ctx,
4839 root_entries[i].first_cluster,
4840 sce_entries, GAME_META_SCE_ENTRIES);
4841 
4842 for (int j = 0; j < sce_count; j++) {
4843 if (sce_entries[j].is_directory) continue;
4844 if (strcasecmp(sce_entries[j].filename, "icon0.png") != 0) continue;
4845 if (sce_entries[j].data_length == 0 ||
4846 sce_entries[j].data_length > GAME_META_MAX_ICON) continue;
4847 
4848 icon_size = (size_t)sce_entries[j].data_length;
4849 icon_data = (uint8_t *)malloc(icon_size);
4850 if (icon_data) {
4851 ssize_t got = exfat_extract_to_buffer(&ctx, &sce_entries[j],
4852 icon_data, icon_size);
4853 if (got > 0) icon_size = (size_t)got;
4854 else { free(icon_data); icon_data = NULL; icon_size = 0; }
4855 }
4856 break;
4857 }
4858 break;
4859 }
4860 
4861 exfat_cleanup(&ctx);
4862 }
4863 
4864 if (!icon_data || icon_size == 0) {
4865 return error_json(HTTP_STATUS_404_NOT_FOUND, "Icon not found in image");
4866 }
4867 
4868 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
4869 http_response_add_header(resp, "Content-Type", "image/png");
4870 http_response_add_header(resp, "Cache-Control", "max-age=86400");
4871
4872 if (http_response_set_body_owned(resp, icon_data, icon_size) != 0) {
4873 free(icon_data);
4874 http_response_destroy(resp);
4875 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Failed to send icon");
4876 }
4877 return resp;
4878}
4879 
4880/*===========================================================================*
4881 * ARCHIVE EXTRACTION (Phase 5 — libarchive)
4882 *
4883 * POST /api/extract?path=<archive>&dst=<dir>
4884 * Extracts an archive to the destination directory using libarchive.
4885 * Runs in a background thread with progress tracking.
4886 *
4887 * GET /api/extract_progress
4888 * Returns extraction progress: { done, bytes_extracted, total_bytes, error }
4889 *
4890 * POST /api/extract_cancel
4891 * Cancels the active extraction.
4892 *
4893 * NOTE: libarchive must be linked (-larchive) for this to compile.
4894 * When ENABLE_LIBARCHIVE is not defined, these return stub responses.
4895 *===========================================================================*/
4896 
4897/* Extraction state (single active extraction at a time) */
4898static struct {
4899 volatile int active;
4900 volatile int done;
4901 volatile int cancelled;
4902 volatile int error;
4903 volatile uint64_t bytes_extracted;
4904 volatile uint64_t total_bytes;
4905 char archive_path[1024];
4906 char dest_path[1024];
4907 char error_msg[256];
4908} g_extract = {0};
4909 
4910#if defined(ENABLE_LIBARCHIVE) && ENABLE_LIBARCHIVE
4911#include <archive.h>
4912#include <archive_entry.h>
4913#include <pthread.h>
4914 
4915static void *extract_thread(void *arg) {
4916 (void)arg;
4917 struct archive *a = archive_read_new();
4918 struct archive *ext = archive_write_disk_new();
4919 
4920 archive_read_support_format_all(a);
4921 archive_read_support_filter_all(a);
4922 archive_write_disk_set_options(ext,
4923 ARCHIVE_EXTRACT_TIME | ARCHIVE_EXTRACT_PERM | ARCHIVE_EXTRACT_ACL |
4924 ARCHIVE_EXTRACT_FFLAGS);
4925 archive_write_disk_set_standard_lookup(ext);
4926 
4927 if (archive_read_open_filename(a, g_extract.archive_path, 65536) != ARCHIVE_OK) {
4928 snprintf(g_extract.error_msg, sizeof(g_extract.error_msg),
4929 "Cannot open: %s", archive_error_string(a));
4930 g_extract.error = 1;
4931 g_extract.done = 1;
4932 g_extract.active = 0;
4933 archive_read_free(a);
4934 archive_write_free(ext);
4935 return NULL;
4936 }
4937 
4938 struct archive_entry *entry;
4939 while (!g_extract.cancelled) {
4940 int r = archive_read_next_header(a, &entry);
4941 if (r == ARCHIVE_EOF) break;
4942 if (r != ARCHIVE_OK && r != ARCHIVE_WARN) {
4943 snprintf(g_extract.error_msg, sizeof(g_extract.error_msg),
4944 "Read error: %s", archive_error_string(a));
4945 g_extract.error = 1;
4946 break;
4947 }
4948 
4949 /* Rewrite entry path to dest directory */
4950 const char *name = archive_entry_pathname(entry);
4951 char fullpath[2048];
4952 if (g_extract.dest_path[strlen(g_extract.dest_path) - 1] == '/') {
4953 snprintf(fullpath, sizeof(fullpath), "%s%s", g_extract.dest_path, name);
4954 } else {
4955 snprintf(fullpath, sizeof(fullpath), "%s/%s", g_extract.dest_path, name);
4956 }
4957 archive_entry_set_pathname(entry, fullpath);
4958 
4959 r = archive_write_header(ext, entry);
4960 if (r != ARCHIVE_OK) {
4961 /* Skip this entry on write error but continue */
4962 continue;
4963 }
4964 
4965 /* Copy data blocks */
4966 if (archive_entry_size(entry) > 0) {
4967 const void *buff;
4968 size_t size;
4969 int64_t offset;
4970 while (!g_extract.cancelled) {
4971 r = archive_read_data_block(a, &buff, &size, &offset);
4972 if (r == ARCHIVE_EOF) break;
4973 if (r != ARCHIVE_OK) break;
4974 archive_write_data_block(ext, buff, size, offset);
4975 g_extract.bytes_extracted += size;
4976 }
4977 }
4978 archive_write_finish_entry(ext);
4979 }
4980 
4981 archive_read_close(a);
4982 archive_read_free(a);
4983 archive_write_close(ext);
4984 archive_write_free(ext);
4985 
4986 if (g_extract.cancelled) {
4987 snprintf(g_extract.error_msg, sizeof(g_extract.error_msg), "Cancelled");
4988 }
4989 g_extract.done = 1;
4990 g_extract.active = 0;
4991 return NULL;
4992}
4993#endif /* ENABLE_LIBARCHIVE */
4994 
4995static http_response_t *api_extract(const http_request_t *request) {
4996 if (request->method != HTTP_METHOD_POST) {
4997 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED, "Use POST");
4998 }
4999 
5000 if (g_extract.active) {
5001 return error_json(HTTP_STATUS_409_CONFLICT, "Extraction already in progress");
5002 }
5003 
5004 const char *query = strchr(request->uri, '?');
5005 char path[1024] = "";
5006 char dst[1024] = "/";
5007 if (query) {
5008 (void)parse_path_param(query, path, sizeof(path));
5009 /* Parse dst param with URL decoding (%XX → byte) */
5010 const char *dp = strstr(query, "dst=");
5011 if (dp) {
5012 dp += 4;
5013 size_t ri = 0, wi = 0;
5014 while (dp[ri] && dp[ri] != '&' && wi < sizeof(dst) - 1) {
5015 if (dp[ri] == '%' && dp[ri + 1] && dp[ri + 2]) {
5016 unsigned char hi = (unsigned char)dp[ri + 1];
5017 unsigned char lo = (unsigned char)dp[ri + 2];
5018 unsigned int vh = (hi >= '0' && hi <= '9') ? hi - '0' :
5019 (hi >= 'A' && hi <= 'F') ? 10 + hi - 'A' :
5020 (hi >= 'a' && hi <= 'f') ? 10 + hi - 'a' : 0xFF;
5021 unsigned int vl = (lo >= '0' && lo <= '9') ? lo - '0' :
5022 (lo >= 'A' && lo <= 'F') ? 10 + lo - 'A' :
5023 (lo >= 'a' && lo <= 'f') ? 10 + lo - 'a' : 0xFF;
5024 if (vh <= 0xF && vl <= 0xF) {
5025 dst[wi++] = (char)((vh << 4) | vl);
5026 ri += 3;
5027 continue;
5028 }
5029 }
5030 dst[wi++] = dp[ri++];
5031 }
5032 dst[wi] = '\0';
5033 }
5034 }
5035 
5036 if (!path[0]) {
5037 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing path parameter");
5038 }
5039 
5040 char safe_path[FTP_PATH_MAX];
5041 char safe_dst[FTP_PATH_MAX];
5042 if (!validate_path(path, safe_path, sizeof(safe_path)) ||
5043 !validate_path(dst, safe_dst, sizeof(safe_dst))) {
5044 return error_json(HTTP_STATUS_403_FORBIDDEN, "Path traversal blocked");
5045 }
5046 
5047#if defined(ENABLE_LIBARCHIVE) && ENABLE_LIBARCHIVE
5048 /* Set up extraction state */
5049 memset(&g_extract, 0, sizeof(g_extract));
5050 strncpy(g_extract.archive_path, safe_path, sizeof(g_extract.archive_path) - 1);
5051 strncpy(g_extract.dest_path, safe_dst, sizeof(g_extract.dest_path) - 1);
5052 g_extract.active = 1;
5053 
5054 /* Get archive size for progress tracking */
5055 struct stat st;
5056 if (stat(safe_path, &st) == 0) {
5057 g_extract.total_bytes = (uint64_t)st.st_size;
5058 }
5059 
5060 /* Start extraction thread */
5061 pthread_t tid;
5062 pthread_attr_t attr;
5063 pthread_attr_init(&attr);
5064 pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
5065 if (pthread_create(&tid, &attr, extract_thread, NULL) != 0) {
5066 g_extract.active = 0;
5067 pthread_attr_destroy(&attr);
5068 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Failed to start extraction thread");
5069 }
5070 pthread_attr_destroy(&attr);
5071 
5072 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5073 http_response_add_header(resp, "Content-Type", "application/json");
5074 const char *body = "{\"ok\":true,\"message\":\"Extraction started\"}";
5075 http_response_set_body(resp, body, strlen(body));
5076 return resp;
5077#else
5078 return error_json(HTTP_STATUS_500_INTERNAL_ERROR,
5079 "libarchive not available — compile with ENABLE_LIBARCHIVE=1");
5080#endif
5081}
5082 
5083static http_response_t *api_extract_progress(const http_request_t *request) {
5084 (void)request;
5085 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5086 http_response_add_header(resp, "Content-Type", "application/json");
5087 http_response_add_header(resp, "Cache-Control", "no-store");
5088 
5089 char body[256];
5090 int len = snprintf(body, sizeof(body),
5091 "{\"active\":%s,\"done\":%s,\"cancelled\":%s,\"error\":%s,"
5092 "\"bytes_extracted\":%" PRIu64 ",\"total_bytes\":%" PRIu64
5093 ",\"error_msg\":\"%s\"}",
5094 g_extract.active ? "true" : "false",
5095 g_extract.done ? "true" : "false",
5096 g_extract.cancelled ? "true" : "false",
5097 g_extract.error ? "true" : "false",
5098 (uint64_t)g_extract.bytes_extracted,
5099 (uint64_t)g_extract.total_bytes,
5100 g_extract.error_msg);
5101 http_response_set_body(resp, body, (size_t)len);
5102 return resp;
5103}
5104 
5105static http_response_t *api_extract_cancel(const http_request_t *request) {
5106 if (request->method != HTTP_METHOD_POST) {
5107 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED, "Use POST");
5108 }
5109 g_extract.cancelled = 1;
5110 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5111 http_response_add_header(resp, "Content-Type", "application/json");
5112 const char *body = "{\"ok\":true}";
5113 http_response_set_body(resp, body, strlen(body));
5114 return resp;
5115}
5116 
5117/*===========================================================================*
5118 * DOWNLOAD MANAGER (Phase 6)
5119 *
5120 * POST /api/download/start { "url": "...", "dst": "/path/" }
5121 * Starts a background HTTP download to the console filesystem.
5122 *
5123 * GET /api/download/status
5124 * Returns all active downloads with progress.
5125 *
5126 * POST /api/download/pause { "id": N }
5127 * POST /api/download/cancel { "id": N }
5128 *
5129 * NOTE: Requires a socket-based HTTP client. On PS5 this uses the
5130 * kernel's socket API directly. On desktop, libcurl can be used.
5131 * When neither is available, returns stub responses.
5132 *===========================================================================*/
5133 
5134#define DL_MAX_ACTIVE 4
5135#define DL_URL_MAX 2048
5136#define DL_READ_BUF (256 * 1024)
5137 
5138/* Download entry state */
5139typedef struct {
5140 int active;
5141 int done;
5142 int paused;
5143 int error;
5144 int id;
5145 char url[DL_URL_MAX];
5146 char dst_path[1024];
5147 char filename[256];
5148 char error_msg[256];
5149 uint64_t total_size;
5150 uint64_t downloaded;
5151 double speed; /* bytes/sec */
5152 time_t start_time;
5153} dl_entry_t;
5154 
5155static dl_entry_t g_downloads[DL_MAX_ACTIVE];
5156static int g_dl_next_id = 1;
5157 
5158static dl_entry_t *dl_find_slot(void) {
5159 for (int i = 0; i < DL_MAX_ACTIVE; i++) {
5160 if (!g_downloads[i].active && g_downloads[i].done == 0) return &g_downloads[i];
5161 }
5162 /* Reuse a completed slot */
5163 for (int i = 0; i < DL_MAX_ACTIVE; i++) {
5164 if (g_downloads[i].done) {
5165 memset(&g_downloads[i], 0, sizeof(dl_entry_t));
5166 return &g_downloads[i];
5167 }
5168 }
5169 return NULL;
5170}
5171 
5172static dl_entry_t *dl_find_by_id(int id) {
5173 for (int i = 0; i < DL_MAX_ACTIVE; i++) {
5174 if (g_downloads[i].id == id) return &g_downloads[i];
5175 }
5176 return NULL;
5177}
5178 
5179/* Extract filename from URL (last path component) */
5180static void dl_extract_filename(const char *url, char *out, size_t out_size) {
5181 if (!url || !out || out_size == 0) return;
5182 const char *last_slash = strrchr(url, '/');
5183 const char *name = last_slash ? last_slash + 1 : url;
5184 /* Strip query string */
5185 const char *qmark = strchr(name, '?');
5186 size_t len = qmark ? (size_t)(qmark - name) : strlen(name);
5187 if (len == 0 || len >= out_size) {
5188 snprintf(out, out_size, "download_%d", g_dl_next_id);
5189 return;
5190 }
5191 memcpy(out, name, len);
5192 out[len] = '\0';
5193}
5194 
5195#if defined(ENABLE_LIBCURL) && ENABLE_LIBCURL
5196#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
5197#include "pal_curl.h"
5198#else
5199#include <curl/curl.h>
5200#endif
5201#include <pthread.h>
5202 
5203struct dl_write_ctx {
5204 dl_entry_t *dl;
5205 int fd;
5206};
5207 
5208static size_t dl_curl_write(void *ptr, size_t size, size_t nmemb, void *userdata) {
5209 struct dl_write_ctx *ctx = (struct dl_write_ctx *)userdata;
5210 size_t total = size * nmemb;
5211 if (ctx->dl->paused) return CURL_WRITEFUNC_PAUSE;
5212 ssize_t w = write(ctx->fd, ptr, total);
5213 if (w <= 0) return 0;
5214 ctx->dl->downloaded += (uint64_t)w;
5215 return (size_t)w;
5216}
5217 
5218static void *dl_thread(void *arg) {
5219 dl_entry_t *dl = (dl_entry_t *)arg;
5220 char filepath[2048];
5221 snprintf(filepath, sizeof(filepath), "%s/%s", dl->dst_path, dl->filename);
5222 
5223 int fd = open(filepath, O_WRONLY | O_CREAT | O_TRUNC, 0644);
5224 if (fd < 0) {
5225 snprintf(dl->error_msg, sizeof(dl->error_msg), "Cannot create file: %s", strerror(errno));
5226 dl->error = 1;
5227 dl->done = 1;
5228 dl->active = 0;
5229 return NULL;
5230 }
5231 
5232 CURL *curl = curl_easy_init();
5233 if (!curl) {
5234 close(fd);
5235 snprintf(dl->error_msg, sizeof(dl->error_msg), "curl_easy_init failed");
5236 dl->error = 1;
5237 dl->done = 1;
5238 dl->active = 0;
5239 return NULL;
5240 }
5241 
5242 struct dl_write_ctx wctx = { dl, fd };
5243 curl_easy_setopt(curl, CURLOPT_URL, dl->url);
5244 curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, dl_curl_write);
5245 curl_easy_setopt(curl, CURLOPT_WRITEDATA, &wctx);
5246 curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
5247 curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 10L);
5248 curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
5249 curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
5250 curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L);
5251 curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 60L);
5252 
5253 CURLcode res = curl_easy_perform(curl);
5254 if (res != CURLE_OK) {
5255 snprintf(dl->error_msg, sizeof(dl->error_msg), "curl: %s", curl_easy_strerror(res));
5256 dl->error = 1;
5257 }
5258 
5259 double total_size = 0;
5260 curl_easy_getinfo(curl, CURLINFO_CONTENT_LENGTH_DOWNLOAD, &total_size);
5261 if (total_size > 0) dl->total_size = (uint64_t)total_size;
5262 
5263 double speed = 0;
5264 curl_easy_getinfo(curl, CURLINFO_SPEED_DOWNLOAD, &speed);
5265 dl->speed = speed;
5266 
5267 curl_easy_cleanup(curl);
5268 close(fd);
5269 dl->done = 1;
5270 dl->active = 0;
5271 return NULL;
5272}
5273#endif /* ENABLE_LIBCURL */
5274 
5275static http_response_t *api_dl_start(const http_request_t *request) {
5276 if (request->method != HTTP_METHOD_POST) {
5277 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED, "Use POST");
5278 }
5279 
5280 /* Parse JSON body: {"url":"...","dst":"..."} — simple extraction */
5281 const char *body_data = request->body;
5282 size_t body_len = request->body_length;
5283 char url[DL_URL_MAX] = "";
5284 char dst[1024] = "/";
5285 
5286 if (body_data && body_len > 0) {
5287 /* Simple JSON extraction for "url" and "dst" */
5288 const char *u = strstr(body_data, "\"url\"");
5289 if (u) {
5290 u = strchr(u + 5, '"');
5291 if (u) {
5292 u++;
5293 size_t i = 0;
5294 while (*u && *u != '"' && i < sizeof(url) - 1) {
5295 if (*u == '\\' && *(u + 1)) { u++; }
5296 url[i++] = *u++;
5297 }
5298 url[i] = '\0';
5299 }
5300 }
5301 const char *d = strstr(body_data, "\"dst\"");
5302 if (d) {
5303 d = strchr(d + 5, '"');
5304 if (d) {
5305 d++;
5306 size_t i = 0;
5307 while (*d && *d != '"' && i < sizeof(dst) - 1) {
5308 if (*d == '\\' && *(d + 1)) { d++; }
5309 dst[i++] = *d++;
5310 }
5311 dst[i] = '\0';
5312 }
5313 }
5314 }
5315 
5316 if (!url[0]) {
5317 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing url parameter");
5318 }
5319 
5320 char safe_dst[FTP_PATH_MAX];
5321 if (!validate_path(dst, safe_dst, sizeof(safe_dst))) {
5322 return error_json(HTTP_STATUS_403_FORBIDDEN, "Invalid destination path");
5323 }
5324 
5325 dl_entry_t *dl = dl_find_slot();
5326 if (!dl) {
5327 return error_json(HTTP_STATUS_409_CONFLICT, "Max concurrent downloads reached");
5328 }
5329 
5330 memset(dl, 0, sizeof(dl_entry_t));
5331 dl->id = g_dl_next_id++;
5332 strncpy(dl->url, url, sizeof(dl->url) - 1);
5333 strncpy(dl->dst_path, safe_dst, sizeof(dl->dst_path) - 1);
5334 dl_extract_filename(url, dl->filename, sizeof(dl->filename));
5335 dl->start_time = time(NULL);
5336 dl->active = 1;
5337 
5338#if defined(ENABLE_LIBCURL) && ENABLE_LIBCURL
5339 pthread_t tid;
5340 pthread_attr_t attr;
5341 pthread_attr_init(&attr);
5342 pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
5343 if (pthread_create(&tid, &attr, dl_thread, dl) != 0) {
5344 dl->active = 0;
5345 pthread_attr_destroy(&attr);
5346 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Failed to start download thread");
5347 }
5348 pthread_attr_destroy(&attr);
5349#else
5350 /* Without libcurl, mark as error immediately */
5351 snprintf(dl->error_msg, sizeof(dl->error_msg),
5352 "Download not available — compile with ENABLE_LIBCURL=1");
5353 dl->error = 1;
5354 dl->done = 1;
5355 dl->active = 0;
5356#endif
5357 
5358 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5359 http_response_add_header(resp, "Content-Type", "application/json");
5360 char rbody[256];
5361 int rlen = snprintf(rbody, sizeof(rbody),
5362 "{\"ok\":true,\"id\":%d,\"name\":\"%s\",\"size\":0}",
5363 dl->id, dl->filename);
5364 http_response_set_body(resp, rbody, (size_t)rlen);
5365 return resp;
5366}
5367 
5368static http_response_t *api_dl_status(const http_request_t *request) {
5369 (void)request;
5370 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5371 http_response_add_header(resp, "Content-Type", "application/json");
5372 http_response_add_header(resp, "Cache-Control", "no-store");
5373 
5374 /* Build JSON array of all download entries */
5375 char body[4096];
5376 int pos = 0;
5377 pos += snprintf(body + pos, sizeof(body) - (size_t)pos, "{\"downloads\":[");
5378 
5379 int first = 1;
5380 for (int i = 0; i < DL_MAX_ACTIVE; i++) {
5381 dl_entry_t *dl = &g_downloads[i];
5382 if (dl->id == 0) continue;
5383 if (!first) pos += snprintf(body + pos, sizeof(body) - (size_t)pos, ",");
5384 first = 0;
5385 
5386 int progress = 0;
5387 if (dl->total_size > 0) {
5388 progress = (int)(dl->downloaded * 100 / dl->total_size);
5389 if (progress > 100) progress = 100;
5390 }
5391 
5392 pos += snprintf(body + pos, sizeof(body) - (size_t)pos,
5393 "{\"id\":%d,\"name\":\"%s\",\"url\":\"%.*s\","
5394 "\"progress\":%d,\"downloaded\":%" PRIu64 ",\"total_size\":%" PRIu64 ","
5395 "\"speed\":%.0f,\"done\":%s,\"error\":\"%s\",\"paused\":%s}",
5396 dl->id, dl->filename,
5397 (int)(sizeof(body) - (size_t)pos > 200 ? 100 : 40), dl->url,
5398 progress, (uint64_t)dl->downloaded, (uint64_t)dl->total_size,
5399 dl->speed, dl->done ? "true" : "false",
5400 dl->error ? dl->error_msg : "",
5401 dl->paused ? "true" : "false");
5402 }
5403 pos += snprintf(body + pos, sizeof(body) - (size_t)pos, "]}");
5404 
5405 http_response_set_body(resp, body, (size_t)pos);
5406 return resp;
5407}
5408 
5409static http_response_t *api_dl_pause(const http_request_t *request) {
5410 if (request->method != HTTP_METHOD_POST) {
5411 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED, "Use POST");
5412 }
5413 /* Parse {"id": N} from body */
5414 int id = 0;
5415 if (request->body && request->body_length > 0) {
5416 const char *idp = strstr(request->body, "\"id\"");
5417 if (idp) {
5418 idp += 4;
5419 while (*idp == ' ' || *idp == ':' || *idp == '\t') idp++;
5420 id = atoi(idp);
5421 }
5422 }
5423 
5424 dl_entry_t *dl = dl_find_by_id(id);
5425 if (!dl) return error_json(HTTP_STATUS_404_NOT_FOUND, "Download not found");
5426 
5427 dl->paused = !dl->paused;
5428 
5429 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5430 http_response_add_header(resp, "Content-Type", "application/json");
5431 char body[64];
5432 int len = snprintf(body, sizeof(body), "{\"ok\":true,\"paused\":%s}",
5433 dl->paused ? "true" : "false");
5434 http_response_set_body(resp, body, (size_t)len);
5435 return resp;
5436}
5437 
5438static http_response_t *api_dl_cancel(const http_request_t *request) {
5439 if (request->method != HTTP_METHOD_POST) {
5440 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED, "Use POST");
5441 }
5442 int id = 0;
5443 if (request->body && request->body_length > 0) {
5444 const char *idp = strstr(request->body, "\"id\"");
5445 if (idp) {
5446 idp += 4;
5447 while (*idp == ' ' || *idp == ':' || *idp == '\t') idp++;
5448 id = atoi(idp);
5449 }
5450 }
5451 
5452 dl_entry_t *dl = dl_find_by_id(id);
5453 if (!dl) return error_json(HTTP_STATUS_404_NOT_FOUND, "Download not found");
5454 
5455 dl->error = 1;
5456 snprintf(dl->error_msg, sizeof(dl->error_msg), "Cancelled by user");
5457 dl->done = 1;
5458 dl->active = 0;
5459 
5460 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5461 http_response_add_header(resp, "Content-Type", "application/json");
5462 const char *body = "{\"ok\":true}";
5463 http_response_set_body(resp, body, strlen(body));
5464 return resp;
5465}
5466 
5467/*===========================================================================*
5468 * STATIC RESOURCE SERVING
5469 *===========================================================================*/
5470 
5471/*---------------------------------------------------------------------------*
5472 * MIME type lookup — maps file extension to Content-Type.
5473 * Covers all file types used by the modular web UI.
5474 *---------------------------------------------------------------------------*/
5475static const char *mime_for_ext(const char *path) {
5476 const char *dot = strrchr(path, '.');
5477 if (dot == NULL) return "application/octet-stream";
5478 dot++; /* skip the '.' */
5479 if (strcasecmp(dot, "html") == 0) return "text/html; charset=utf-8";
5480 if (strcasecmp(dot, "css") == 0) return "text/css; charset=utf-8";
5481 if (strcasecmp(dot, "js") == 0) return "application/javascript; charset=utf-8";
5482 if (strcasecmp(dot, "json") == 0) return "application/json; charset=utf-8";
5483 if (strcasecmp(dot, "png") == 0) return "image/png";
5484 if (strcasecmp(dot, "jpg") == 0) return "image/jpeg";
5485 if (strcasecmp(dot, "jpeg") == 0) return "image/jpeg";
5486 if (strcasecmp(dot, "gif") == 0) return "image/gif";
5487 if (strcasecmp(dot, "svg") == 0) return "image/svg+xml";
5488 if (strcasecmp(dot, "ico") == 0) return "image/x-icon";
5489 if (strcasecmp(dot, "webp") == 0) return "image/webp";
5490 if (strcasecmp(dot, "woff") == 0) return "font/woff";
5491 if (strcasecmp(dot, "woff2")== 0) return "font/woff2";
5492 if (strcasecmp(dot, "ttf") == 0) return "font/ttf";
5493 if (strcasecmp(dot, "map") == 0) return "application/json";
5494 return "application/octet-stream";
5495}
5496 
5497/*---------------------------------------------------------------------------*
5498 * serve_static — read files from HTTP_WEB_ROOT on the filesystem.
5499 *
5500 * Replaces the previous embedded-resource approach (http_resources.c).
5501 * Files are read from disk at request time, which:
5502 * 1. Reduces payload binary size by ~10 MB
5503 * 2. Allows hot-reloading during development
5504 * 3. Supports the new modular CSS/JS file structure
5505 *
5506 * Path traversal is prevented by rejecting any URI containing "..".
5507 *---------------------------------------------------------------------------*/
5508static http_response_t *serve_static(const http_request_t *request) {
5509 static const char *k_frontend_patch =
5510 "<style>.nav-tab[data-view=\"stream\"],#view-stream{display:none !important;}</style>"
5511 "<script>(function(){function __zftpd_fix(){var b=document.body;if(!b)return;"
5512 "var h=document.querySelector('header.topbar');if(h){for(var n=b.firstChild;n&&n!==h;){var nx=n.nextSibling;"
5513 "if(n.nodeType===3){b.removeChild(n);}n=nx;}}"
5514 "var t=document.querySelector('.nav-tab[data-view=\\\"stream\\\"]');if(t&&t.parentNode)t.parentNode.removeChild(t);"
5515 "var v=document.getElementById('view-stream');if(v&&v.parentNode)v.parentNode.removeChild(v);}"
5516 "function __zftpd_is_blocked_url(u){return /google-analytics\\.com\\/mp\\/collect/i.test(String(u||''));}"
5517 "function __zftpd_patch_net(){"
5518 "var sb=navigator.sendBeacon;if(sb){navigator.sendBeacon=function(u,d){if(__zftpd_is_blocked_url(u))return true;return sb.apply(this,arguments);};}"
5519 "var of=window.fetch;if(of){window.fetch=function(input,init){var u=(typeof input==='string')?input:(input&&input.url?input.url:'');"
5520 "if(__zftpd_is_blocked_url(u)){return new Promise(function(resolve){resolve({ok:true,status:204,text:function(){return Promise.resolve('');},json:function(){return Promise.resolve({});}});});}"
5521 "return of.apply(this,arguments);};}"
5522 "var xo=XMLHttpRequest&&XMLHttpRequest.prototype&&XMLHttpRequest.prototype.open;"
5523 "var xs=XMLHttpRequest&&XMLHttpRequest.prototype&&XMLHttpRequest.prototype.send;"
5524 "if(xo&&xs){XMLHttpRequest.prototype.open=function(m,u){this.__zftpd_block=__zftpd_is_blocked_url(u);return xo.apply(this,arguments);};"
5525 "XMLHttpRequest.prototype.send=function(){if(this.__zftpd_block){try{this.readyState=4;this.status=204;if(this.onreadystatechange)this.onreadystatechange();if(this.onload)this.onload();}catch(e){}return;}return xs.apply(this,arguments);};}"
5526 "}"
5527 "__zftpd_patch_net();"
5528 "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',__zftpd_fix);}else{__zftpd_fix();}})();</script>";
5529 
5530 const char *path = request->uri;
5531 if (path[0] == '/') {
5532 path++;
5533 }
5534 if (path[0] == '\0') {
5535 path = "index.html";
5536 }
5537 
5538 /* ── Strip query string (e.g. "?v=3" cache busting) ──
5539 *
5540 * "css/base.css?v=3" → "css/base.css"
5541 * ^── stop here */
5542 char clean_path[1024];
5543 {
5544 const char *qmark = strchr(path, '?');
5545 size_t plen = qmark ? (size_t)(qmark - path) : strlen(path);
5546 if (plen >= sizeof(clean_path)) plen = sizeof(clean_path) - 1;
5547 memcpy(clean_path, path, plen);
5548 clean_path[plen] = '\0';
5549 }
5550 path = clean_path;
5551 
5552 /* Block path traversal */
5553 if (strstr(path, "..") != NULL) {
5554 return error_json(HTTP_STATUS_403_FORBIDDEN, "Path traversal blocked");
5555 }
5556 
5557 /* Build full filesystem path: HTTP_WEB_ROOT + relative path */
5558 char fspath[1024];
5559 int n = snprintf(fspath, sizeof(fspath), "%s%s", HTTP_WEB_ROOT, path);
5560 if (n < 0 || (size_t)n >= sizeof(fspath)) {
5561 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Path too long");
5562 }
5563 
5564 /* Open and stat the file */
5565 struct stat st;
5566 if (stat(fspath, &st) != 0 || !S_ISREG(st.st_mode)) {
5567 /* Fallback: try embedded resources (backward compat during transition) */
5568 size_t esize = 0;
5569 const char *econtent = http_get_resource(path, &esize);
5570 if (econtent != NULL) {
5571 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5572 http_response_add_header(resp, "Content-Type", mime_for_ext(path));
5573 
5574 /*
5575 * Embedded index.html may come from older blobs.
5576 * Force-disable legacy Stream UI and remove any stray text nodes that
5577 * can appear above the topbar due malformed payload bytes.
5578 */
5579 if (strstr(path, "index.html") != NULL) {
5580 char *src = (char *)malloc(esize + 1U);
5581 if (src != NULL) {
5582 memcpy(src, econtent, esize);
5583 src[esize] = '\0';
5584 
5585 const char *insert_at = strstr(src, "</head>");
5586 size_t patch_len = strlen(k_frontend_patch);
5587 
5588 if (insert_at != NULL) {
5589 size_t prefix_len = (size_t)(insert_at - src);
5590 size_t out_len = esize + patch_len;
5591 char *out = (char *)malloc(out_len + 1U);
5592 if (out != NULL) {
5593 memcpy(out, src, prefix_len);
5594 memcpy(out + prefix_len, k_frontend_patch, patch_len);
5595 memcpy(out + prefix_len + patch_len, src + prefix_len,
5596 esize - prefix_len);
5597 out[out_len] = '\0';
5598 free(src);
5599 if (http_response_set_body_owned(resp, out, out_len) == 0) {
5600 return resp;
5601 }
5602 free(out);
5603 }
5604 }
5605 
5606 free(src);
5607 }
5608 }
5609 
5610 if (http_response_set_body(resp, econtent, esize) != 0) {
5611 (void)http_response_set_body_ref(resp, econtent, esize);
5612 }
5613 return resp;
5614 }
5615 http_response_t *resp = http_response_create(HTTP_STATUS_404_NOT_FOUND);
5616 const char *msg = "404 Not Found";
5617 http_response_set_body(resp, msg, strlen(msg));
5618 return resp;
5619 }
5620 
5621 size_t size = (size_t)st.st_size;
5622 
5623 /* Read file into memory */
5624 FILE *fp = fopen(fspath, "rb");
5625 if (fp == NULL) {
5626 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Cannot open file");
5627 }
5628 char *content = (char *)malloc(size + 1);
5629 if (content == NULL) {
5630 fclose(fp);
5631 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
5632 }
5633 size_t got = fread(content, 1, size, fp);
5634 fclose(fp);
5635 content[got] = '\0';
5636 
5637 /* Apply runtime patch to disk-served index too (not only embedded). */
5638 if (strstr(path, "index.html") != NULL) {
5639 const char *insert_at = strstr(content, "</head>");
5640 size_t patch_len = strlen(k_frontend_patch);
5641 if (insert_at != NULL) {
5642 size_t prefix_len = (size_t)(insert_at - content);
5643 size_t out_len = got + patch_len;
5644 char *out = (char *)malloc(out_len + 1U);
5645 if (out != NULL) {
5646 memcpy(out, content, prefix_len);
5647 memcpy(out + prefix_len, k_frontend_patch, patch_len);
5648 memcpy(out + prefix_len + patch_len, content + prefix_len,
5649 got - prefix_len);
5650 out[out_len] = '\0';
5651 free(content);
5652 content = out;
5653 got = out_len;
5654 }
5655 }
5656 }
5657 
5658 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5659 http_response_add_header(resp, "Content-Type", mime_for_ext(path));
5660 http_response_add_header(resp, "Cache-Control", "no-cache");
5661 
5662#if ENABLE_WEB_UPLOAD
5663 /* Inject CSRF token into HTML */
5664 if (strstr(path, "index.html") != NULL) {
5665 const char *token = http_csrf_get_token();
5666 char meta_tag[128];
5667 snprintf(meta_tag, sizeof(meta_tag),
5668 "<meta name=\"csrf-token\" content=\"%s\">", token);
5669 
5670 const char *placeholder = "<!-- CSRF_TOKEN -->";
5671 const char *found = strstr(content, placeholder);
5672 
5673 if (found != NULL) {
5674 size_t prefix_len = (size_t)(found - content);
5675 size_t suffix_len = got - prefix_len - strlen(placeholder);
5676 if (http_response_set_body_splice(
5677 resp, content, prefix_len, meta_tag, strlen(meta_tag),
5678 found + strlen(placeholder), suffix_len) == 0) {
5679 free(content);
5680 return resp;
5681 }
5682 }
5683 }
5684#endif
5685 
5686 /*
5687 * Two paths for sending file content:
5688 *
5689 * SMALL FILE (<= ~7 KB): set_body copies into resp->data inline
5690 * LARGE FILE (> ~7 KB): set_body_owned transfers ownership of the
5691 * malloc'd buffer; http_handle_request()
5692 * streams it via the mem_body path after
5693 * sending headers.
5694 *
5695 * ┌──────────────────────────────────────────────────┐
5696 * │ resp->data (8 KB) │ mem_body (heap, any sz) │
5697 * │ [headers + body] │ [large body streamed] │
5698 * └──────────────────────────────────────────────────┘
5699 */
5700 if (http_response_set_body(resp, content, got) == 0) {
5701 /* Small file — fully contained in resp->data */
5702 free(content);
5703 return resp;
5704 }
5705 /* Large file — transfer ownership of malloc'd content */
5706 if (http_response_set_body_owned(resp, content, got) == 0) {
5707 /* content ownership transferred, do NOT free */
5708 return resp;
5709 }
5710 /* Both paths failed (should not happen) */
5711 http_response_destroy(resp);
5712 free(content);
5713 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Body allocation failed");
5714}
5715 
5716/*===========================================================================*
5717 * ERROR HELPERS
5718 *===========================================================================*/
5719 
5720static http_response_t *error_json(http_status_t code, const char *message) {
5721 http_response_t *resp = http_response_create(code);
5722 http_response_add_header(resp, "Content-Type", "application/json");
5723 http_response_add_header(resp, "Access-Control-Allow-Origin", "*");
5724 
5725 char body[512];
5726 int len = snprintf(body, sizeof(body), "{\"error\":\"%s\"}", message);
5727 
5728 http_response_set_body(resp, body, (size_t)len);
5729 return resp;
5730}
5731 
5732static http_response_t *status_json_200(int ok, const char *message,
5733 int code) {
5734 if (message == NULL) {
5735 message = ok ? "ok" : "error";
5736 }
5737 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5738 http_response_add_header(resp, "Content-Type", "application/json");
5739 http_response_add_header(resp, "Cache-Control", "no-store");
5740 
5741 char body[512];
5742 int n = snprintf(body, sizeof(body),
5743 "{\"ok\":%s,\"status\":\"%s\",\"message\":\"%s\",\"code\":%d}",
5744 ok ? "true" : "false", ok ? "ok" : "error", message,
5745 code);
5746 http_response_set_body(resp, body, (size_t)n);
5747 return resp;
5748}
5749 
5750static http_response_t *png_fallback_response(void) {
5751 static const uint8_t k_png_1x1[] = {
5752 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00,
5753 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
5754 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89,
5755 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63,
5756 0x60, 0x60, 0x60, 0xF8, 0x0F, 0x00, 0x01, 0x04, 0x01, 0x00, 0x5F,
5757 0xE2, 0x26, 0x05, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44,
5758 0xAE, 0x42, 0x60, 0x82};
5759 
5760 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5761 http_response_add_header(resp, "Content-Type", "image/png");
5762 http_response_add_header(resp, "Cache-Control", "public, max-age=3600");
5763 http_response_set_body(resp, k_png_1x1, sizeof(k_png_1x1));
5764 return resp;
5765}
5766 
5767static http_response_t *api_legacy_disabled_json(const char *json_body) {
5768 if (json_body == NULL) {
5769 json_body = "{\"ok\":false}";
5770 }
5771 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5772 http_response_add_header(resp, "Content-Type", "application/json");
5773 http_response_add_header(resp, "Cache-Control", "no-store");
5774 http_response_set_body(resp, json_body, strlen(json_body));
5775 return resp;
5776}
5777 
5778/*===========================================================================*
5779 * POST /api/network/reset — TCP buffer reset (Fix #4, #3, #7)
5780 *
5781 * ROOT CAUSE (repeated from pal_network.c for API-layer context):
5782 *
5783 * After transfers to the internal SSD or M.2, OrbisOS kernel socket buffer
5784 * accounting becomes inflated. New data connections receive smaller-than-
5785 * configured buffers, causing the 450 MB/s → 250 MB/s degradation.
5786 * The manual workaround (disable/enable PS5 networking) triggers a full NIC
5787 * buffer reallocation cycle. This endpoint replicates that cycle at the
5788 * application level by writing 0 then the target value to SO_SNDBUF /
5789 * SO_RCVBUF on each idle session socket.
5790 *
5791 * RESPONSE:
5792 * 200 {"ok":true,"message":"Network stack reset (N sessions)"}
5793 * 200 {"ok":false,"message":"..."} — with PAL notification as fallback
5794 * 405 If method is not POST
5795 *
5796 * SIDE EFFECTS:
5797 * - Closes orphaned data sockets (data_fd on non-TRANSFERRING sessions)
5798 * - Sends a PS4/PS5 notification if the reset succeeds or fails
5799 *===========================================================================*/
5800 
5801/**
5802 * @brief POST /api/network/reset
5803 *
5804 * @note Thread-safety: Runs in the HTTP event loop thread.
5805 * pal_network_reset_ftp_stack() is safe to call from any thread
5806 * provided no session is in the middle of accept().
5807 */
5808static http_response_t *api_network_reset(const http_request_t *request) {
5809 if (request == NULL) {
5810 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Null request");
5811 }
5812 
5813 /* Only POST is accepted */
5814 if (request->method != HTTP_METHOD_POST) {
5815 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED,
5816 "Use POST /api/network/reset");
5817 }
5818 
5819 char body[128];
5820 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5821 if (resp == NULL) {
5822 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "OOM");
5823 }
5824 http_response_add_header(resp, "Content-Type", "application/json");
5825 http_response_add_header(resp, "Cache-Control", "no-store");
5826 
5827 if (g_ftp_server_ctx == NULL) {
5828 /*
5829 * HTTP server running without an attached FTP context (unlikely in
5830 * production, but handle it gracefully). Send a PAL notification so
5831 * the user knows what happened, then return a soft failure.
5832 */
5833 pal_notification_send("zftpd: network reset unavailable (no FTP ctx)");
5834 (void)snprintf(body, sizeof(body),
5835 "{\"ok\":false,\"message\":\"FTP context not attached\"}");
5836 http_response_set_body(resp, body, strlen(body));
5837 return resp;
5838 }
5839 
5840 int rc =
5841 pal_network_reset_ftp_stack(g_ftp_server_ctx->sessions, FTP_MAX_SESSIONS);
5842 
5843 if (rc == 0) {
5844 pal_notification_send("zftpd: network stack reset OK");
5845 (void)snprintf(
5846 body, sizeof(body),
5847 "{\"ok\":true,\"message\":\"Network stack reset (%u sessions)\"}",
5848 (unsigned)FTP_MAX_SESSIONS);
5849 } else {
5850 /*
5851 * Partial failure (invalid args) — fall back to notification so the
5852 * user is still informed, even if the UI reset failed.
5853 */
5854 pal_notification_send("zftpd: network reset partial failure");
5855 (void)snprintf(body, sizeof(body),
5856 "{\"ok\":false,\"message\":\"Reset partial — check logs\"}");
5857 }
5858 
5859 http_response_set_body(resp, body, strlen(body));
5860 return resp;
5861}
5862 
5863/*===========================================================================*
5864 * GET /api/admin/fan?threshold=...
5865 * Sets the fan threshold on PS4/PS5 using /dev/icc_fan ioctl 0xC01C8F07.
5866 *===========================================================================*/
5867static http_response_t *api_admin_fan(const http_request_t *request) {
5868 const char *query = strchr(request->uri, '?');
5869 if (!query) {
5870 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing threshold parameter");
5871 }
5872 
5873 int threshold = 0;
5874 if (sscanf(query, "?threshold=%d", &threshold) != 1) {
5875 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Invalid threshold parameter format");
5876 }
5877 
5878 /* Clamp to safe operating values */
5879 if (threshold < 40) { threshold = 40; }
5880 if (threshold > 90) { threshold = 90; }
5881 
5882#ifndef _WIN32
5883 int fd = open("/dev/icc_fan", O_RDONLY, 0);
5884 if (fd < 0) {
5885 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Failed to open /dev/icc_fan (Unsupported OS)");
5886 }
5887 
5888 char data[10] = {0x00, 0x00, 0x00, 0x00, 0x00, (char)threshold, 0x00, 0x00, 0x00, 0x00};
5889 int ret = ioctl(fd, 0xC01C8F07, data);
5890 close(fd);
5891 
5892 if (ret < 0) {
5893 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Fan control ioctl failed");
5894 }
5895#else
5896 /* Mock for development environments */
5897 (void)threshold;
5898#endif
5899 
5900 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
5901 if (!resp) return NULL;
5902 http_response_add_header(resp, "Content-Type", "application/json");
5903
5904 char body[128];
5905 int blen = snprintf(body, sizeof(body), "{\"status\":\"ok\",\"threshold\":%d}", threshold);
5906 http_response_set_body(resp, body, (size_t)blen);
5907 return resp;
5908}
5909 
5910/*===========================================================================*
5911 * GET /api/admin/launch?id=... or /api/admin/launch?path=...
5912 *===========================================================================*/
5913static http_response_t *api_admin_launch(const http_request_t *request) {
5914 char title_id[64] = {0};
5915 
5916 const char *query = strchr(request->uri, '?');
5917 if (query == NULL) {
5918 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing query string");
5919 }
5920 
5921 const char *id_param = strstr(query, "id=");
5922 if (id_param != NULL) {
5923 id_param += 3; /* skip "id=" */
5924 size_t id_len = 0U;
5925 while ((id_param[id_len] != '\0') && (id_param[id_len] != '&')) {
5926 if (id_len >= (sizeof(title_id) - 1U)) {
5927 return error_json(HTTP_STATUS_400_BAD_REQUEST,
5928 "'id' parameter too long");
5929 }
5930 title_id[id_len] = id_param[id_len];
5931 id_len++;
5932 }
5933 title_id[id_len] = '\0';
5934 } else {
5935 char path[1024] = "";
5936 if (parse_path_param(query, path, sizeof(path)) != 0) {
5937 return error_json(HTTP_STATUS_400_BAD_REQUEST,
5938 "Missing 'id' or valid 'path' parameter");
5939 }
5940 
5941 char safe[FTP_PATH_MAX];
5942 if (!validate_path(path, safe, sizeof(safe))) {
5943 return error_json(HTTP_STATUS_403_FORBIDDEN, "Path traversal blocked");
5944 }
5945 
5946 if (extract_title_id_from_game_image(safe, title_id, sizeof(title_id)) != 0) {
5947 if (extract_title_id_from_app_dir(safe, title_id, sizeof(title_id)) != 0) {
5948 return error_json(HTTP_STATUS_400_BAD_REQUEST,
5949 "Unable to resolve TITLE_ID from image/app path");
5950 }
5951 }
5952 }
5953 
5954 if (title_id[0] == '\0') {
5955 launch_diag_log("input", title_id, -1, "missing launch target");
5956 return error_json(HTTP_STATUS_400_BAD_REQUEST,
5957 "Missing or invalid launch target");
5958 }
5959 
5960 /* sanitize title id for runtime launch API */
5961 for (size_t i = 0; title_id[i] != '\0'; i++) {
5962 unsigned char c = (unsigned char)title_id[i];
5963 if (!(isalnum(c) || c == '_' || c == '-')) {
5964 launch_diag_log("input", title_id, -2, "invalid title id format");
5965 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Invalid title id format");
5966 }
5967 title_id[i] = (char)toupper(c);
5968 }
5969 launch_diag_log("input", title_id, 0, "launch request received");
5970 
5971 char installed_app_dir[FTP_PATH_MAX];
5972 if (resolve_installed_app_dir_by_title(title_id, installed_app_dir,
5973 sizeof(installed_app_dir)) != 0) {
5974 launch_diag_log("preflight_fs", title_id, -30,
5975 "title id not found in installed app directories");
5976 return status_json_200(0,
5977 "Launch blocked: title not installed on this console",
5978 -30);
5979 }
5980 launch_diag_log("preflight_fs", title_id, 0, installed_app_dir);
5981 
5982#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
5983 /*
5984 * We dynamically load libSceLncUtil.sprx, libSceUserService.sprx,
5985 * and libSceSystemService.sprx. Since this is a payload, these are
5986 * not statically linked and must be resolved at runtime using POSIX dlopen.
5987 */
5988 void *userService = NULL;
5989 void *lncUtil = NULL;
5990 void *sysService = NULL;
5991 
5992 int mrc = 0;
5993 if (psx_sysmodule_load_internal(SCE_SYSMODULE_INTERNAL_SYS_CORE, &mrc) ==
5994 0) {
5995 launch_diag_log("sysmodule", title_id, mrc, "SYS_CORE");
5996 } else {
5997 launch_diag_log("sysmodule", title_id, mrc,
5998 "SYS_CORE loader unavailable");
5999 }
6000 if (psx_sysmodule_load_internal(SCE_SYSMODULE_INTERNAL_SYSTEM_SERVICE,
6001 &mrc) == 0) {
6002 launch_diag_log("sysmodule", title_id, mrc, "SYSTEM_SERVICE");
6003 }
6004 if (psx_sysmodule_load_internal(SCE_SYSMODULE_INTERNAL_USER_SERVICE,
6005 &mrc) == 0) {
6006 launch_diag_log("sysmodule", title_id, mrc, "USER_SERVICE");
6007 }
6008 
6009 int (*f_sceUserServiceGetForegroundUser)(uint32_t *) =
6010 (int (*)(uint32_t *))dlsym(RTLD_DEFAULT,
6011 "sceUserServiceGetForegroundUser");
6012 
6013 int (*f_sceUserServiceInitialize)(void *) =
6014 (int (*)(void *))dlsym(RTLD_DEFAULT, "sceUserServiceInitialize");
6015 
6016 int (*f_sceUserServiceGetLoginUserIdList)(void *) =
6017 (int (*)(void *))dlsym(RTLD_DEFAULT,
6018 "sceUserServiceGetLoginUserIdList");
6019 
6020 uint32_t (*f_sceLncUtilLaunchApp)(const char *, const char **,
6021 LncAppParam *) =
6022 (uint32_t(*)(const char *, const char **,
6023 LncAppParam *))dlsym(RTLD_DEFAULT,
6024 "sceLncUtilLaunchApp");
6025 
6026 int (*f_sceSystemServiceLaunchApp)(const char *, const char **,
6027 void *) =
6028 (int (*)(const char *, const char **,
6029 void *))dlsym(RTLD_DEFAULT,
6030 "sceSystemServiceLaunchApp");
6031 
6032 int (*f_sceSystemServiceLoadExec)(const char *, const char **) =
6033 (int (*)(const char *, const char **))dlsym(RTLD_DEFAULT,
6034 "sceSystemServiceLoadExec");
6035 
6036 int (*f_sceLncUtilInitialize)(void) =
6037 (int (*)(void))dlsym(RTLD_DEFAULT, "sceLncUtilInitialize");
6038 
6039 int (*f_sceLncUtilGetAppId)(const char *) =
6040 (int (*)(const char *))dlsym(RTLD_DEFAULT, "sceLncUtilGetAppId");
6041 
6042 if (f_sceLncUtilLaunchApp == NULL || f_sceLncUtilInitialize == NULL) {
6043 lncUtil = dlopen("/system/common/lib/libSceLncUtil.sprx",
6044 RTLD_NOW | RTLD_GLOBAL);
6045 if (lncUtil != NULL) {
6046 launch_diag_log("dlopen", title_id, 0, "libSceLncUtil.sprx");
6047 } else {
6048 const char *err = dlerror();
6049 launch_diag_log("dlopen", title_id, -10,
6050 (err != NULL) ? err : "libSceLncUtil.sprx failed");
6051 }
6052 if (lncUtil != NULL) {
6053 if (f_sceLncUtilLaunchApp == NULL) {
6054 f_sceLncUtilLaunchApp =
6055 (uint32_t(*)(const char *, const char **,
6056 LncAppParam *))dlsym(lncUtil,
6057 "sceLncUtilLaunchApp");
6058 }
6059 if (f_sceLncUtilInitialize == NULL) {
6060 f_sceLncUtilInitialize =
6061 (int (*)(void))dlsym(lncUtil, "sceLncUtilInitialize");
6062 }
6063 if (f_sceLncUtilGetAppId == NULL) {
6064 f_sceLncUtilGetAppId =
6065 (int (*)(const char *))dlsym(lncUtil, "sceLncUtilGetAppId");
6066 }
6067 }
6068 }
6069 
6070 if (f_sceSystemServiceLaunchApp == NULL) {
6071 sysService = dlopen("/system/common/lib/libSceSystemService.sprx",
6072 RTLD_NOW | RTLD_GLOBAL);
6073 if (sysService != NULL) {
6074 launch_diag_log("dlopen", title_id, 0, "libSceSystemService.sprx");
6075 f_sceSystemServiceLaunchApp =
6076 (int (*)(const char *, const char **,
6077 void *))dlsym(sysService,
6078 "sceSystemServiceLaunchApp");
6079 if (f_sceSystemServiceLoadExec == NULL) {
6080 f_sceSystemServiceLoadExec =
6081 (int (*)(const char *, const char **))dlsym(
6082 sysService, "sceSystemServiceLoadExec");
6083 }
6084 } else {
6085 const char *err = dlerror();
6086 launch_diag_log("dlopen", title_id, -11,
6087 (err != NULL) ? err : "libSceSystemService.sprx failed");
6088 }
6089 }
6090 
6091 if (f_sceUserServiceGetForegroundUser == NULL) {
6092 userService = dlopen("/system/common/lib/libSceUserService.sprx",
6093 RTLD_NOW | RTLD_GLOBAL);
6094 launch_diag_log("dlopen", title_id, (userService != NULL) ? 0 : -12,
6095 "libSceUserService.sprx");
6096 if (userService != NULL) {
6097 f_sceUserServiceGetForegroundUser =
6098 (int (*)(uint32_t *))dlsym(userService,
6099 "sceUserServiceGetForegroundUser");
6100 if (f_sceUserServiceInitialize == NULL) {
6101 f_sceUserServiceInitialize =
6102 (int (*)(void *))dlsym(userService,
6103 "sceUserServiceInitialize");
6104 }
6105 if (f_sceUserServiceGetLoginUserIdList == NULL) {
6106 f_sceUserServiceGetLoginUserIdList =
6107 (int (*)(void *))dlsym(userService,
6108 "sceUserServiceGetLoginUserIdList");
6109 }
6110 }
6111 }
6112 
6113 if ((f_sceLncUtilLaunchApp == NULL) &&
6114 (f_sceSystemServiceLaunchApp == NULL)) {
6115 launch_diag_log("symbols", title_id, -3,
6116 "missing sceLncUtilLaunchApp and sceSystemServiceLaunchApp");
6117 if (userService != NULL)
6118 dlclose(userService);
6119 if (lncUtil != NULL)
6120 dlclose(lncUtil);
6121 if (sysService != NULL)
6122 dlclose(sysService);
6123 return status_json_200(
6124 0,
6125 "Launch API unavailable (sceLncUtilLaunchApp/sceSystemServiceLaunchApp)",
6126 -3);
6127 }
6128 
6129 if (f_sceLncUtilInitialize != NULL) {
6130 int init_rc = f_sceLncUtilInitialize();
6131 launch_diag_log("lnc_init", title_id, init_rc,
6132 "sceLncUtilInitialize");
6133 if ((init_rc < 0) && ((uint32_t)init_rc != 0x80940018U)) {
6134 if (userService != NULL)
6135 dlclose(userService);
6136 if (lncUtil != NULL)
6137 dlclose(lncUtil);
6138 return status_json_200(0, "Failed to initialize Launch API", init_rc);
6139 }
6140 }
6141 
6142 if (f_sceLncUtilGetAppId != NULL) {
6143 int appid_rc = f_sceLncUtilGetAppId(title_id);
6144 launch_diag_log("preflight", title_id, appid_rc,
6145 "sceLncUtilGetAppId");
6146 }
6147 
6148 if (f_sceUserServiceInitialize != NULL) {
6149 int init_params[8];
6150 memset(init_params, 0, sizeof(init_params));
6151 init_params[0] = 256; /* priority (Itemzflow-like) */
6152 int uinit_rc = f_sceUserServiceInitialize((void *)init_params);
6153 launch_diag_log("user_init", title_id, uinit_rc,
6154 "sceUserServiceInitialize");
6155 }
6156 
6157 int32_t userId = 0;
6158 int have_fg_user = 0;
6159 if (f_sceUserServiceGetForegroundUser != NULL) {
6160 if (f_sceUserServiceGetForegroundUser((uint32_t *)&userId) < 0) {
6161 launch_diag_log("user", title_id, -20,
6162 "sceUserServiceGetForegroundUser failed");
6163 } else {
6164 have_fg_user = 1;
6165 launch_diag_log("user", title_id, 0,
6166 "sceUserServiceGetForegroundUser ok");
6167 }
6168 }
6169 
6170 if (!have_fg_user && (f_sceUserServiceGetLoginUserIdList != NULL)) {
6171 struct {
6172 int32_t userId[4];
6173 } login_list;
6174 for (size_t i = 0; i < 4; i++) {
6175 login_list.userId[i] = -1;
6176 }
6177 
6178 int lrc = f_sceUserServiceGetLoginUserIdList((void *)&login_list);
6179 if (lrc >= 0) {
6180 for (size_t i = 0; i < 4; i++) {
6181 if (login_list.userId[i] >= 0) {
6182 userId = login_list.userId[i];
6183 have_fg_user = 1;
6184 launch_diag_log("user", title_id, 0,
6185 "fallback sceUserServiceGetLoginUserIdList ok");
6186 break;
6187 }
6188 }
6189 }
6190 }
6191 
6192 if (!have_fg_user) {
6193 launch_diag_log("done", title_id, -21,
6194 "No logged-in user context; launch aborted");
6195 if (userService != NULL)
6196 dlclose(userService);
6197 if (lncUtil != NULL)
6198 dlclose(lncUtil);
6199 if (sysService != NULL)
6200 dlclose(sysService);
6201 return status_json_200(
6202 0,
6203 "Launch blocked: no logged-in user context (prevents ShellUI crash)",
6204 -21);
6205 }
6206 
6207 LncAppParam param;
6208 memset(&param, 0, sizeof(param));
6209 param.sz = sizeof(LncAppParam);
6210 param.user_id = (uint32_t)userId;
6211 param.app_opt = 0;
6212 param.crash_report = 0;
6213 param.check_flag = LNC_FLAG_NONE;
6214 
6215 uint32_t res = 0xFFFFFFFFU;
6216 if (f_sceLncUtilLaunchApp != NULL) {
6217 uint32_t candidates[1];
6218 size_t candidate_count = 0U;
6219 candidates[candidate_count++] = (uint32_t)userId;
6220 
6221 const LncAppParamFlag flag_candidates[] = {
6222 LNC_FLAG_NONE,
6223 LNC_SKIP_SYSTEM_UPDATE_CHECK,
6224 LNC_SKIP_LAUNCH_CHECK,
6225 };
6226 
6227 for (size_t i = 0; i < candidate_count; i++) {
6228 uint32_t uid = candidates[i];
6229 int dup = 0;
6230 for (size_t j = 0; j < i; j++) {
6231 if (candidates[j] == uid) {
6232 dup = 1;
6233 break;
6234 }
6235 }
6236 if (dup) {
6237 continue;
6238 }
6239 
6240 for (size_t f = 0; f < (sizeof(flag_candidates) / sizeof(flag_candidates[0]));
6241 f++) {
6242 param.user_id = uid;
6243 param.check_flag = flag_candidates[f];
6244 
6245 char detail[96];
6246 (void)snprintf(detail, sizeof(detail),
6247 "sceLncUtilLaunchApp uid=%u flag=0x%X",
6248 (unsigned)uid, (unsigned)param.check_flag);
6249 launch_diag_log("launch_call", title_id, 0, detail);
6250 res = f_sceLncUtilLaunchApp(title_id, NULL, &param);
6251 launch_diag_log("launch_try_result", title_id, (int)res, detail);
6252 
6253 if (res == 0 || res == 0x8094000cU) {
6254 break;
6255 }
6256 if (res == 0x80940005U) {
6257 /* invalid param: try another flag/uid */
6258 continue;
6259 }
6260 /* non-parameter launch error: keep last status and stop retries */
6261 break;
6262 }
6263 
6264 if ((res == 0) || (res == 0x8094000cU) || (res != 0x80940005U)) {
6265 break;
6266 }
6267 }
6268 
6269 if (res == 0x80940005U) {
6270 launch_diag_log("launch_call", title_id, 0,
6271 "sceLncUtilLaunchApp param=NULL");
6272 res = f_sceLncUtilLaunchApp(title_id, NULL, NULL);
6273 launch_diag_log("launch_try_result", title_id, (int)res,
6274 "sceLncUtilLaunchApp param=NULL");
6275 }
6276 
6277 if ((res == 0x80940005U) && (f_sceSystemServiceLaunchApp != NULL)) {
6278 launch_diag_log("launch_call", title_id, 0,
6279 "sceSystemServiceLaunchApp argv=NULL,param=NULL");
6280 res = (uint32_t)f_sceSystemServiceLaunchApp(title_id, NULL, NULL);
6281 launch_diag_log("launch_try_result", title_id, (int)res,
6282 "sceSystemServiceLaunchApp argv=NULL,param=NULL");
6283 
6284 if (res == 0x80940005U) {
6285 param.check_flag = LNC_FLAG_NONE;
6286 launch_diag_log("launch_call", title_id, 0,
6287 "sceSystemServiceLaunchApp argv=NULL,param=&LncAppParam(flag=0x0)");
6288 res =
6289 (uint32_t)f_sceSystemServiceLaunchApp(title_id, NULL, &param);
6290 launch_diag_log("launch_try_result", title_id, (int)res,
6291 "sceSystemServiceLaunchApp argv=NULL,param=&LncAppParam(flag=0x0)");
6292 }
6293 }
6294 } else {
6295 /* Fallback path on systems where LncUtil symbol is unavailable */
6296 launch_diag_log("launch_call", title_id, 0,
6297 "using sceSystemServiceLaunchApp fallback");
6298 res = (uint32_t)f_sceSystemServiceLaunchApp(title_id, NULL, NULL);
6299 launch_diag_log("launch_try_result", title_id, (int)res,
6300 "sceSystemServiceLaunchApp argv=NULL,param=NULL");
6301 
6302 if (res == 0x80940005U) {
6303 uint32_t candidates[1];
6304 size_t candidate_count = 0U;
6305 candidates[candidate_count++] = (uint32_t)userId;
6306 
6307 const LncAppParamFlag flag_candidates[] = {
6308 LNC_FLAG_NONE,
6309 };
6310 
6311 for (size_t i = 0; i < candidate_count; i++) {
6312 uint32_t uid = candidates[i];
6313 int dup = 0;
6314 for (size_t j = 0; j < i; j++) {
6315 if (candidates[j] == uid) {
6316 dup = 1;
6317 break;
6318 }
6319 }
6320 if (dup) {
6321 continue;
6322 }
6323 
6324 for (size_t f = 0;
6325 f < (sizeof(flag_candidates) / sizeof(flag_candidates[0])); f++) {
6326 param.user_id = uid;
6327 param.check_flag = flag_candidates[f];
6328 
6329 char detail[128];
6330 (void)snprintf(detail, sizeof(detail),
6331 "sceSystemServiceLaunchApp uid=%u flag=0x%X",
6332 (unsigned)uid, (unsigned)param.check_flag);
6333 launch_diag_log("launch_call", title_id, 0, detail);
6334 res =
6335 (uint32_t)f_sceSystemServiceLaunchApp(title_id, NULL, &param);
6336 launch_diag_log("launch_try_result", title_id, (int)res, detail);
6337 
6338 if (launch_result_is_success(res)) {
6339 break;
6340 }
6341 if (res == 0x80940005U) {
6342 continue;
6343 }
6344 break;
6345 }
6346 
6347 if (launch_result_is_success(res) || (res != 0x80940005U)) {
6348 break;
6349 }
6350 }
6351 }
6352 }
6353 
6354 if (res == 0x80940031U) {
6355 int fix_tables = 0;
6356 int fix_rows = 0;
6357 int fix_rc = psx_repair_appdb_visibility_for_title(title_id, &fix_tables,
6358 &fix_rows);
6359 char fix_detail[128];
6360 (void)snprintf(fix_detail, sizeof(fix_detail),
6361 "appdb self-heal rc=%d tables=%d rows=%d", fix_rc,
6362 fix_tables, fix_rows);
6363 launch_diag_log("selfheal", title_id, fix_rc, fix_detail);
6364 
6365 if (fix_rc == 0) {
6366 if (f_sceLncUtilLaunchApp != NULL) {
6367 param.user_id = have_fg_user ? (uint32_t)userId : 0U;
6368 param.check_flag = LNC_FLAG_NONE;
6369 launch_diag_log("launch_call", title_id, 0,
6370 "post-repair sceLncUtilLaunchApp uid=fg/0 flag=0x0");
6371 res = f_sceLncUtilLaunchApp(title_id, NULL, &param);
6372 launch_diag_log("launch_try_result", title_id, (int)res,
6373 "post-repair sceLncUtilLaunchApp uid=fg/0 flag=0x0");
6374 } else if (f_sceSystemServiceLaunchApp != NULL) {
6375 param.user_id = have_fg_user ? (uint32_t)userId : 0U;
6376 param.check_flag = LNC_FLAG_NONE;
6377 launch_diag_log("launch_call", title_id, 0,
6378 "post-repair sceSystemServiceLaunchApp uid=fg/0 flag=0x0");
6379 res =
6380 (uint32_t)f_sceSystemServiceLaunchApp(title_id, NULL, &param);
6381 launch_diag_log("launch_try_result", title_id, (int)res,
6382 "post-repair sceSystemServiceLaunchApp uid=fg/0 flag=0x0");
6383 }
6384 }
6385 }
6386 
6387 if ((res == 0x80940031U) && (f_sceSystemServiceLoadExec != NULL)) {
6388 char eboot_path[FTP_PATH_MAX];
6389 int en = snprintf(eboot_path, sizeof(eboot_path), "/user/app/%s/eboot.bin",
6390 title_id);
6391 if (en > 0 && (size_t)en < sizeof(eboot_path) && access(eboot_path, R_OK) == 0) {
6392 launch_diag_log("launch_call", title_id, 0,
6393 "fallback sceSystemServiceLoadExec /user/app/<TITLE_ID>/eboot.bin");
6394 res = (uint32_t)f_sceSystemServiceLoadExec(eboot_path, NULL);
6395 launch_diag_log("launch_try_result", title_id, (int)res,
6396 "fallback sceSystemServiceLoadExec /user/app/<TITLE_ID>/eboot.bin");
6397 }
6398 }
6399 launch_diag_log("launch_result", title_id, (int)res,
6400 "launch API returned");
6401 
6402 if (userService != NULL)
6403 dlclose(userService);
6404 if (lncUtil != NULL)
6405 dlclose(lncUtil);
6406 if (sysService != NULL)
6407 dlclose(sysService);
6408 
6409 char msg[128];
6410 if (launch_result_is_success(res)) {
6411 launch_diag_log("done", title_id, 0, "launch accepted");
6412 (void)snprintf(msg, sizeof(msg),
6413 "{\"status\": \"ok\", \"message\": \"Game %s launched successfully!\"}",
6414 title_id);
6415 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
6416 http_response_add_header(resp, "Content-Type", "application/json");
6417 http_response_set_body(resp, (const uint8_t *)msg, strlen(msg));
6418 pal_notification_send("Game Launch executed.");
6419 return resp;
6420 }
6421 
6422 (void)snprintf(msg, sizeof(msg), "Launch failed: 0x%08X", res);
6423 launch_diag_log("done", title_id, (int)res, msg);
6424 return status_json_200(0, msg, (int)res);
6425#else
6426 /* Mock fallback for local tests */
6427 char debug_msg[128];
6428 (void)snprintf(debug_msg, sizeof(debug_msg), "{\"status\": \"ok\", \"message\": \"Mock Launch %s initiated\"}", title_id);
6429 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
6430 http_response_add_header(resp, "Content-Type", "application/json");
6431 http_response_set_body(resp, (const uint8_t *)debug_msg, strlen(debug_msg));
6432 return resp;
6433#endif
6434}
6435 
6436static http_response_t *api_games_installed(const http_request_t *request) {
6437 (void)request;
6438 
6439 enum { GAMES_BODY_CAP = 512U * 1024U };
6440 char *body = (char *)malloc(GAMES_BODY_CAP);
6441 if (body == NULL) {
6442 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
6443 }
6444 
6445 size_t pos = 0U;
6446 if (buf_append_cstr(body, GAMES_BODY_CAP, &pos,
6447 "{\"ok\":true,\"entries\":[") != 0) {
6448 free(body);
6449 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
6450 }
6451 
6452 int first = 1;
6453 size_t count_added = 0U;
6454 const char *bases[] = {"/user/app", "/system_ex/app", "/mnt/ext0/user/app",
6455 NULL};
6456 for (size_t bi = 0; bases[bi] != NULL; bi++) {
6457 if (append_installed_entries_from_base(bases[bi], body, GAMES_BODY_CAP,
6458 &pos, &first,
6459 &count_added) != 0) {
6460 free(body);
6461 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Response too large");
6462 }
6463 }
6464 
6465 if (buf_append_cstr(body, GAMES_BODY_CAP, &pos, "]}") != 0) {
6466 free(body);
6467 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Response too large");
6468 }
6469 
6470 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
6471 if (resp == NULL) {
6472 free(body);
6473 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
6474 }
6475 http_response_add_header(resp, "Content-Type", "application/json");
6476 http_response_add_header(resp, "Cache-Control", "no-store");
6477 if (http_response_set_body_owned(resp, body, pos) != 0) {
6478 free(body);
6479 http_response_destroy(resp);
6480 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Body allocation failed");
6481 }
6482 return resp;
6483}
6484 
6485static http_response_t *api_games_icon(const http_request_t *request) {
6486 const char *query = strchr(request->uri, '?');
6487 if (query == NULL) {
6488 return png_fallback_response();
6489 }
6490 
6491 char title_id[64] = {0};
6492 if (parse_query_param(query, "id", title_id, sizeof(title_id)) != 0) {
6493 title_id[0] = '\0';
6494 }
6495 if (title_id[0] != '\0') {
6496 for (size_t i = 0; title_id[i] != '\0'; i++) {
6497 unsigned char c = (unsigned char)title_id[i];
6498 if (!(isalnum(c) || c == '_' || c == '-')) {
6499 title_id[0] = '\0';
6500 break;
6501 }
6502 title_id[i] = (char)toupper(c);
6503 }
6504 }
6505 
6506 char path_hint[FTP_PATH_MAX] = {0};
6507 (void)parse_query_param(query, "path", path_hint, sizeof(path_hint));
6508 
6509 char icon_path[FTP_PATH_MAX] = {0};
6510 if (resolve_installed_icon_path((title_id[0] != '\0') ? title_id : NULL,
6511 (path_hint[0] != '\0') ? path_hint : NULL,
6512 icon_path, sizeof(icon_path)) != 0) {
6513 return png_fallback_response();
6514 }
6515 
6516 FILE *fp = fopen(icon_path, "rb");
6517 if (fp == NULL) {
6518 return png_fallback_response();
6519 }
6520 if (fseek(fp, 0, SEEK_END) != 0) {
6521 fclose(fp);
6522 return png_fallback_response();
6523 }
6524 long flen = ftell(fp);
6525 if (flen <= 0 || flen > (8 * 1024 * 1024)) {
6526 fclose(fp);
6527 return png_fallback_response();
6528 }
6529 if (fseek(fp, 0, SEEK_SET) != 0) {
6530 fclose(fp);
6531 return png_fallback_response();
6532 }
6533 
6534 uint8_t *buf = (uint8_t *)malloc((size_t)flen);
6535 if (buf == NULL) {
6536 fclose(fp);
6537 return png_fallback_response();
6538 }
6539 size_t got = fread(buf, 1, (size_t)flen, fp);
6540 fclose(fp);
6541 if (got != (size_t)flen) {
6542 free(buf);
6543 return png_fallback_response();
6544 }
6545 
6546 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
6547 http_response_add_header(resp, "Content-Type", "image/png");
6548 http_response_add_header(resp, "Cache-Control", "no-store");
6549 if (http_response_set_body_owned(resp, buf, got) != 0) {
6550 free(buf);
6551 http_response_destroy(resp);
6552 return png_fallback_response();
6553 }
6554 return resp;
6555}
6556 
6557static http_response_t *api_games_repair_visibility(const http_request_t *request) {
6558 char body[2048];
6559 size_t pos = 0U;
6560 int first = 1;
6561 size_t count_added = 0U;
6562 int repaired_titles = 0;
6563 int repaired_tables = 0;
6564 int repaired_rows = 0;
6565 int sqlite_available = 0;
6566 
6567 char requested_id[32] = {0};
6568 const char *query = strchr(request->uri, '?');
6569 if (query != NULL) {
6570 (void)parse_query_param(query, "id", requested_id, sizeof(requested_id));
6571 for (size_t i = 0; requested_id[i] != '\0'; i++) {
6572 requested_id[i] = (char)toupper((unsigned char)requested_id[i]);
6573 }
6574 }
6575 
6576 if (buf_append_cstr(body, sizeof(body), &pos,
6577 "{\"ok\":true,\"message\":\"Visibility reindex completed\",\"scanned\":[") !=
6578 0) {
6579 return error_json(HTTP_STATUS_500_INTERNAL_ERROR, "Out of memory");
6580 }
6581 
6582 const char *bases[] = {"/user/app", "/system_ex/app", "/mnt/ext0/user/app",
6583 "/user/appmeta", "/system_data/priv/appmeta", NULL};
6584 
6585#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
6586 if (requested_id[0] != '\0') {
6587 int valid = 1;
6588 for (size_t i = 0; requested_id[i] != '\0'; i++) {
6589 unsigned char c = (unsigned char)requested_id[i];
6590 if (!(isalnum(c) || c == '_' || c == '-')) {
6591 valid = 0;
6592 break;
6593 }
6594 }
6595 if (valid) {
6596 int touched_tables = 0;
6597 int touched_rows = 0;
6598 int r =
6599 psx_repair_appdb_visibility_for_title(requested_id, &touched_tables,
6600 &touched_rows);
6601 if (r == 0) {
6602 sqlite_available = 1;
6603 repaired_titles = 1;
6604 repaired_tables += touched_tables;
6605 repaired_rows += touched_rows;
6606 }
6607 }
6608 } else {
6609 const char *repair_bases[] = {"/user/app", "/mnt/ext0/user/app",
6610 "/system_ex/app", NULL};
6611 for (size_t bi = 0; repair_bases[bi] != NULL; bi++) {
6612 DIR *d = opendir(repair_bases[bi]);
6613 if (d == NULL) {
6614 continue;
6615 }
6616 
6617 struct dirent *e;
6618 while ((e = readdir(d)) != NULL) {
6619 if (e->d_name[0] == '.') {
6620 continue;
6621 }
6622 
6623 int valid = 1;
6624 size_t len = strlen(e->d_name);
6625 if (len < 9U || len > 31U) {
6626 valid = 0;
6627 }
6628 for (size_t i = 0; valid && i < len; i++) {
6629 unsigned char c = (unsigned char)e->d_name[i];
6630 if (!(isalnum(c) || c == '_' || c == '-')) {
6631 valid = 0;
6632 break;
6633 }
6634 }
6635 if (!valid) {
6636 continue;
6637 }
6638 
6639 char title_id[32];
6640 memset(title_id, 0, sizeof(title_id));
6641 for (size_t i = 0; i < len && i < (sizeof(title_id) - 1U); i++) {
6642 title_id[i] = (char)toupper((unsigned char)e->d_name[i]);
6643 }
6644 
6645 int touched_tables = 0;
6646 int touched_rows = 0;
6647 int r =
6648 psx_repair_appdb_visibility_for_title(title_id, &touched_tables,
6649 &touched_rows);
6650 if (r == 0) {
6651 sqlite_available = 1;
6652 repaired_titles++;
6653 repaired_tables += touched_tables;
6654 repaired_rows += touched_rows;
6655 }
6656 }
6657 closedir(d);
6658 }
6659 }
6660#endif
6661 
6662 for (size_t i = 0; bases[i] != NULL; i++) {
6663 if (!first) {
6664 (void)buf_append_cstr(body, sizeof(body), &pos, ",");
6665 }
6666 first = 0;
6667 (void)buf_append_cstr(body, sizeof(body), &pos, "\"");
6668 (void)json_escape_append(body, sizeof(body), &pos, bases[i]);
6669 (void)buf_append_cstr(body, sizeof(body), &pos, "\"");
6670 
6671 DIR *d = opendir(bases[i]);
6672 if (d != NULL) {
6673 struct dirent *e;
6674 while ((e = readdir(d)) != NULL) {
6675 if (e->d_name[0] == '.') {
6676 continue;
6677 }
6678 count_added++;
6679 }
6680 closedir(d);
6681 }
6682 }
6683 
6684 (void)buf_append_cstr(body, sizeof(body), &pos, "],\"items_seen\":");
6685 {
6686 char num[32];
6687 int n = snprintf(num, sizeof(num), "%zu", count_added);
6688 if (n > 0 && (size_t)n < sizeof(num)) {
6689 (void)buf_append_bytes(body, sizeof(body), &pos, num, (size_t)n);
6690 }
6691 }
6692 (void)buf_append_cstr(body, sizeof(body), &pos, ",\"hints\":[");
6693 (void)buf_append_cstr(body, sizeof(body), &pos,
6694 "\"Use Refresh Installed in Games tab\",");
6695 (void)buf_append_cstr(body, sizeof(body), &pos,
6696 "\"If titles still missing, restart shell/console\"");
6697 (void)buf_append_cstr(body, sizeof(body), &pos, "],\"sqlite_repair\":{");
6698 (void)buf_append_cstr(body, sizeof(body), &pos, "\"available\":");
6699 (void)buf_append_cstr(body, sizeof(body), &pos,
6700 sqlite_available ? "true" : "false");
6701 (void)buf_append_cstr(body, sizeof(body), &pos, ",\"titles\":");
6702 {
6703 char num[32];
6704 int n = snprintf(num, sizeof(num), "%d", repaired_titles);
6705 if (n > 0 && (size_t)n < sizeof(num)) {
6706 (void)buf_append_bytes(body, sizeof(body), &pos, num, (size_t)n);
6707 }
6708 }
6709 (void)buf_append_cstr(body, sizeof(body), &pos, ",\"tables\":");
6710 {
6711 char num[32];
6712 int n = snprintf(num, sizeof(num), "%d", repaired_tables);
6713 if (n > 0 && (size_t)n < sizeof(num)) {
6714 (void)buf_append_bytes(body, sizeof(body), &pos, num, (size_t)n);
6715 }
6716 }
6717 (void)buf_append_cstr(body, sizeof(body), &pos, ",\"rows\":");
6718 {
6719 char num[32];
6720 int n = snprintf(num, sizeof(num), "%d", repaired_rows);
6721 if (n > 0 && (size_t)n < sizeof(num)) {
6722 (void)buf_append_bytes(body, sizeof(body), &pos, num, (size_t)n);
6723 }
6724 }
6725 (void)buf_append_cstr(body, sizeof(body), &pos, "}}");
6726 
6727 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
6728 http_response_add_header(resp, "Content-Type", "application/json");
6729 http_response_add_header(resp, "Cache-Control", "no-store");
6730 http_response_set_body(resp, body, strlen(body));
6731 return resp;
6732}
6733 
6734static http_response_t *api_games_install_status(const http_request_t *request) {
6735 (void)request;
6736 
6737 int active = g_game_install_state.active;
6738 int percent = g_game_install_state.last_percent;
6739 int error_rc = g_game_install_state.last_error;
6740 unsigned long len = g_game_install_state.last_length;
6741 unsigned long tx = g_game_install_state.last_transferred;
6742 
6743#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
6744 if (active && g_game_install_state.task_id >= 0) {
6745 SceBgftTaskProgress p;
6746 int rc = 0;
6747 if (psx_bgft_progress(g_game_install_state.task_id, &p, &rc) == 0) {
6748 if (rc == 0) {
6749 len = p.length;
6750 tx = p.transferred;
6751 error_rc = p.error_result;
6752 if (len > 0UL) {
6753 percent = (int)((tx * 100UL) / len);
6754 if (percent > 100) {
6755 percent = 100;
6756 }
6757 }
6758 
6759 g_game_install_state.last_percent = percent;
6760 g_game_install_state.last_error = error_rc;
6761 g_game_install_state.last_length = len;
6762 g_game_install_state.last_transferred = tx;
6763 
6764 if ((len > 0UL && tx >= len) || percent >= 100) {
6765 g_game_install_state.active = 0;
6766 active = 0;
6767 }
6768 } else {
6769 error_rc = rc;
6770 g_game_install_state.last_error = rc;
6771 }
6772 }
6773 }
6774#endif
6775 
6776 char body[768];
6777 int n = snprintf(
6778 body, sizeof(body),
6779 "{\"ok\":true,\"active\":%s,\"task_id\":%d,\"progress\":%d,\"error\":%d,\"length\":%lu,\"transferred\":%lu,\"title_id\":\"%s\",\"path\":\"%s\"}",
6780 active ? "true" : "false", g_game_install_state.task_id, percent,
6781 error_rc, len, tx, g_game_install_state.title_id,
6782 g_game_install_state.path);
6783 
6784 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
6785 http_response_add_header(resp, "Content-Type", "application/json");
6786 http_response_add_header(resp, "Cache-Control", "no-store");
6787 http_response_set_body(resp, body, (size_t)n);
6788 return resp;
6789}
6790 
6791static http_response_t *api_games_uninstall(const http_request_t *request) {
6792 if ((request->method != HTTP_METHOD_POST) &&
6793 (request->method != HTTP_METHOD_GET)) {
6794 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED, "Use POST or GET");
6795 }
6796 
6797 const char *query = strchr(request->uri, '?');
6798 if (query == NULL) {
6799 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing query string");
6800 }
6801 
6802 char title_id[64] = {0};
6803 if (parse_query_param(query, "id", title_id, sizeof(title_id)) != 0 ||
6804 !is_valid_title_id_for_uninstall(title_id)) {
6805 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing or invalid title id");
6806 }
6807 
6808#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
6809 int rc = -1;
6810 if (psx_uninstall_title_id(title_id, &rc) != 0) {
6811 return status_json_200(0, "Uninstall API unavailable", -1);
6812 }
6813 if (rc < 0) {
6814 char msg[96];
6815 (void)snprintf(msg, sizeof(msg), "Uninstall failed: 0x%08X", (unsigned)rc);
6816 return status_json_200(0, msg, rc);
6817 }
6818 
6819 char body[192];
6820 int n = snprintf(body, sizeof(body),
6821 "{\"ok\":true,\"message\":\"Uninstalled\",\"id\":\"%s\"}",
6822 title_id);
6823 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
6824 http_response_add_header(resp, "Content-Type", "application/json");
6825 http_response_set_body(resp, body, (size_t)n);
6826 return resp;
6827#else
6828 (void)title_id;
6829 return status_json_200(0, "Uninstall only available on PS4/PS5", -1);
6830#endif
6831}
6832 
6833static http_response_t *api_games_install(const http_request_t *request) {
6834 if ((request->method != HTTP_METHOD_POST) &&
6835 (request->method != HTTP_METHOD_GET)) {
6836 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED, "Use POST or GET");
6837 }
6838 
6839 const char *query = strchr(request->uri, '?');
6840 if (query == NULL) {
6841 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing query string");
6842 }
6843 
6844 char path[FTP_PATH_MAX] = {0};
6845 if (parse_path_param(query, path, sizeof(path)) != 0) {
6846 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing path parameter");
6847 }
6848 
6849 char safe[FTP_PATH_MAX];
6850 if (!validate_path(path, safe, sizeof(safe))) {
6851 return error_json(HTTP_STATUS_403_FORBIDDEN, "Path traversal blocked");
6852 }
6853 
6854 if (!has_pkg_extension(safe)) {
6855 return error_json(HTTP_STATUS_400_BAD_REQUEST,
6856 "Install supports only PKG/FPKG files");
6857 }
6858 
6859#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
6860 int install_rc = -1;
6861 int task_id = -1;
6862 char title_id[64] = {0};
6863 if (psx_install_pkg_bgft(safe, "Remote PKG Install", title_id,
6864 sizeof(title_id), &task_id,
6865 &install_rc) == 0) {
6866 if (install_rc == 0 && task_id >= 0) {
6867 g_game_install_state.active = 1;
6868 g_game_install_state.task_id = task_id;
6869 g_game_install_state.last_percent = 0;
6870 g_game_install_state.last_error = 0;
6871 g_game_install_state.last_length = 0UL;
6872 g_game_install_state.last_transferred = 0UL;
6873 (void)snprintf(g_game_install_state.title_id,
6874 sizeof(g_game_install_state.title_id), "%s",
6875 title_id[0] ? title_id : "");
6876 (void)snprintf(g_game_install_state.path, sizeof(g_game_install_state.path),
6877 "%s", safe);
6878 }
6879 } else if (psx_install_pkg_path(safe, title_id, sizeof(title_id),
6880 &install_rc) != 0) {
6881 return status_json_200(0, "Install API unavailable", -1);
6882 }
6883 if (install_rc < 0) {
6884 char msg[96];
6885 (void)snprintf(msg, sizeof(msg), "Install failed: 0x%08X",
6886 (unsigned)install_rc);
6887 return status_json_200(0, msg, install_rc);
6888 }
6889 
6890 char body[512];
6891 int n = snprintf(
6892 body, sizeof(body),
6893 "{\"ok\":true,\"message\":\"Install started\",\"title_id\":\"%s\",\"path\":\"%s\",\"task_id\":%d,\"task_based\":%s}",
6894 title_id[0] ? title_id : "", safe, task_id,
6895 (task_id >= 0) ? "true" : "false");
6896 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
6897 http_response_add_header(resp, "Content-Type", "application/json");
6898 http_response_set_body(resp, body, (size_t)n);
6899 return resp;
6900#else
6901 (void)safe;
6902 return status_json_200(0, "Install only available on PS4/PS5", -1);
6903#endif
6904}
6905 
6906static http_response_t *api_games_reinstall(const http_request_t *request) {
6907 if ((request->method != HTTP_METHOD_POST) &&
6908 (request->method != HTTP_METHOD_GET)) {
6909 return error_json(HTTP_STATUS_405_METHOD_NOT_ALLOWED, "Use POST or GET");
6910 }
6911 
6912 const char *query = strchr(request->uri, '?');
6913 if (query == NULL) {
6914 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing query string");
6915 }
6916 
6917 char path[FTP_PATH_MAX] = {0};
6918 if (parse_path_param(query, path, sizeof(path)) != 0) {
6919 return error_json(HTTP_STATUS_400_BAD_REQUEST, "Missing path parameter");
6920 }
6921 
6922 char safe[FTP_PATH_MAX];
6923 if (!validate_path(path, safe, sizeof(safe))) {
6924 return error_json(HTTP_STATUS_403_FORBIDDEN, "Path traversal blocked");
6925 }
6926 
6927 if (!has_pkg_extension(safe)) {
6928 return error_json(HTTP_STATUS_400_BAD_REQUEST,
6929 "Reinstall supports only PKG/FPKG files");
6930 }
6931 
6932 char title_id[64] = {0};
6933 (void)extract_title_id_from_game_image(safe, title_id, sizeof(title_id));
6934 
6935#if defined(PLATFORM_PS4) || defined(PLATFORM_PS5)
6936 int uninstall_rc = -1;
6937 if (title_id[0] != '\0') {
6938 if (psx_uninstall_title_id(title_id, &uninstall_rc) != 0) {
6939 uninstall_rc = -1;
6940 }
6941 }
6942 
6943 int install_rc = -1;
6944 int task_id = -1;
6945 char install_title[64] = {0};
6946 if (psx_install_pkg_bgft(safe, "Remote PKG Reinstall", install_title,
6947 sizeof(install_title), &task_id,
6948 &install_rc) == 0) {
6949 if (install_rc == 0 && task_id >= 0) {
6950 g_game_install_state.active = 1;
6951 g_game_install_state.task_id = task_id;
6952 g_game_install_state.last_percent = 0;
6953 g_game_install_state.last_error = 0;
6954 g_game_install_state.last_length = 0UL;
6955 g_game_install_state.last_transferred = 0UL;
6956 (void)snprintf(g_game_install_state.title_id,
6957 sizeof(g_game_install_state.title_id), "%s",
6958 install_title[0] ? install_title : title_id);
6959 (void)snprintf(g_game_install_state.path, sizeof(g_game_install_state.path),
6960 "%s", safe);
6961 }
6962 } else if (psx_install_pkg_path(safe, install_title, sizeof(install_title),
6963 &install_rc) != 0) {
6964 return status_json_200(0, "Install API unavailable", -1);
6965 }
6966 if (install_rc < 0) {
6967 char msg[96];
6968 (void)snprintf(msg, sizeof(msg), "Reinstall failed: 0x%08X",
6969 (unsigned)install_rc);
6970 return status_json_200(0, msg, install_rc);
6971 }
6972 
6973 char body[576];
6974 int n = snprintf(
6975 body, sizeof(body),
6976 "{\"ok\":true,\"message\":\"Reinstall started\",\"title_id\":\"%s\",\"uninstall_rc\":%d,\"task_id\":%d,\"task_based\":%s}",
6977 install_title[0] ? install_title : title_id, uninstall_rc, task_id,
6978 (task_id >= 0) ? "true" : "false");
6979 http_response_t *resp = http_response_create(HTTP_STATUS_200_OK);
6980 http_response_add_header(resp, "Content-Type", "application/json");
6981 http_response_set_body(resp, body, (size_t)n);
6982 return resp;
6983#else
6984 return status_json_200(0, "Reinstall only available on PS4/PS5", -1);
6985#endif
6986}
6987