Zero-copy FTP/HTTP Daemon compatible with all POSIX systems
| 1 | /* |
| 2 | MIT License |
| 3 | |
| 4 | Copyright (c) 2026 Seregon |
| 5 | |
| 6 | Permission is hereby granted, free of charge, to any person obtaining a copy |
| 7 | of this software and associated documentation files (the "Software"), to deal |
| 8 | in the Software without restriction, including without limitation the rights |
| 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 10 | copies of the Software, and to permit persons to whom the Software is |
| 11 | furnished to do so, subject to the following conditions: |
| 12 | |
| 13 | The above copyright notice and this permission notice shall be included in all |
| 14 | copies or substantial portions of the Software. |
| 15 | |
| 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 22 | SOFTWARE. |
| 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 | |
| 67 | typedef struct sqlite3 sqlite3; |
| 68 | |
| 69 | typedef 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 | |
| 78 | typedef 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 | |
| 90 | static 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 | |
| 116 | typedef 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 | |
| 128 | typedef 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 | |
| 145 | typedef struct { |
| 146 | bgft_download_param param; |
| 147 | unsigned int slot; |
| 148 | } bgft_download_param_ex; |
| 149 | |
| 150 | typedef struct { |
| 151 | void *heap; |
| 152 | size_t heapSize; |
| 153 | } bgft_init_params; |
| 154 | |
| 155 | typedef 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) |
| 174 | extern 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 | |
| 200 | extern 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 | |
| 213 | static 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 | */ |
| 225 | static 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 | */ |
| 234 | void http_api_set_server_ctx(ftp_server_context_t *ctx) { |
| 235 | g_ftp_server_ctx = ctx; |
| 236 | } |
| 237 | |
| 238 | void 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 | |
| 257 | const 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 | */ |
| 279 | static 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 | |
| 327 | static http_response_t *api_list(const http_request_t *request); |
| 328 | static http_response_t *api_dirsize(const http_request_t *request); |
| 329 | static http_response_t *api_download(const http_request_t *request); |
| 330 | static http_response_t *api_stats(const http_request_t *request); |
| 331 | static http_response_t *api_stats_ram(const http_request_t *request); |
| 332 | static http_response_t *api_stats_system(const http_request_t *request); |
| 333 | static http_response_t *api_disk_info(const http_request_t *request); |
| 334 | static http_response_t *api_disk_tree(const http_request_t *request); |
| 335 | static http_response_t *api_processes(const http_request_t *request); |
| 336 | static http_response_t *api_process_kill(const http_request_t *request); |
| 337 | static http_response_t *serve_static(const http_request_t *request); |
| 338 | static http_response_t *api_game_meta(const http_request_t *request); |
| 339 | static http_response_t *api_game_icon(const http_request_t *request); |
| 340 | static http_response_t *api_extract(const http_request_t *request); |
| 341 | static http_response_t *api_extract_progress(const http_request_t *request); |
| 342 | static http_response_t *api_extract_cancel(const http_request_t *request); |
| 343 | static http_response_t *api_dl_start(const http_request_t *request); |
| 344 | static http_response_t *api_dl_status(const http_request_t *request); |
| 345 | static http_response_t *api_dl_pause(const http_request_t *request); |
| 346 | static http_response_t *api_dl_cancel(const http_request_t *request); |
| 347 | #if ENABLE_WEB_UPLOAD |
| 348 | static http_response_t *api_create_file(const http_request_t *request); |
| 349 | static http_response_t *api_mkdir(const http_request_t *request); |
| 350 | static http_response_t *api_delete(const http_request_t *request); |
| 351 | static http_response_t *api_rename(const http_request_t *request); |
| 352 | static http_response_t *api_copy(const http_request_t *request); |
| 353 | static http_response_t *api_copy_progress(const http_request_t *request); |
| 354 | static http_response_t *api_copy_cancel(const http_request_t *request); |
| 355 | static http_response_t *api_copy_pause(const http_request_t *request); |
| 356 | #endif |
| 357 | static http_response_t *api_network_reset(const http_request_t *request); |
| 358 | static http_response_t *api_admin_fan(const http_request_t *request); |
| 359 | static http_response_t *api_admin_launch(const http_request_t *request); |
| 360 | static http_response_t *api_games_installed(const http_request_t *request); |
| 361 | static http_response_t *api_games_install_status(const http_request_t *request); |
| 362 | static http_response_t *api_games_icon(const http_request_t *request); |
| 363 | static http_response_t *api_games_repair_visibility(const http_request_t *request); |
| 364 | static http_response_t *api_games_uninstall(const http_request_t *request); |
| 365 | static http_response_t *api_games_install(const http_request_t *request); |
| 366 | static http_response_t *api_games_reinstall(const http_request_t *request); |
| 367 | static http_response_t *api_legacy_disabled_json(const char *json_body); |
| 368 | static http_response_t *error_json(http_status_t code, const char *message); |
| 369 | static http_response_t *status_json_200(int ok, const char *message, |
| 370 | int code); |
| 371 | static http_response_t *png_fallback_response(void); |
| 372 | |
| 373 | static 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) |
| 391 | static 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 | */ |
| 414 | static 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 | */ |
| 446 | static const char *forbidden_prefixes[] = {"/dev", "/proc", "/sys", "/kern", |
| 447 | NULL}; |
| 448 | |
| 449 | static 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 | */ |
| 475 | static 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 | |
| 495 | static 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 | |
| 513 | static 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 | |
| 521 | static 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 | |
| 530 | static 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 | |
| 539 | static 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 | |
| 548 | static 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 | |
| 560 | static 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 | |
| 591 | static 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 | |
| 672 | static 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 | |
| 730 | typedef 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. */ |
| 737 | static 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 | |
| 759 | static 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 | |
| 817 | uint64_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 | */ |
| 835 | static 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 | |
| 853 | static 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 | |
| 893 | static 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 | */ |
| 967 | static 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 | */ |
| 1030 | static 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 | |
| 1108 | static 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 |
| 1172 | static 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 | |
| 1247 | static 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 | |
| 1269 | http_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 | |
| 1500 | static 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 | |
| 1575 | static 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 | |
| 1627 | static 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 | |
| 1773 | static 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 |
| 1882 | static 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 | |
| 1955 | static 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 | |
| 2025 | static 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 | |
| 2150 | static 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 | |
| 2243 | typedef 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 | |
| 2255 | static copy_progress_t g_copy_progress = {0}; |
| 2256 | |
| 2257 | static 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 */ |
| 2275 | typedef 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 | |
| 2282 | static 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 */ |
| 2301 | static 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 */ |
| 2333 | static 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 */ |
| 2346 | static 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 | |
| 2376 | static 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 | |
| 2453 | static 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, ©_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 | |
| 2590 | static 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 | |
| 2693 | static 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 | |
| 2719 | static 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 | |
| 2771 | static 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 | |
| 2805 | static 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 | |
| 2937 | static 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 | |
| 3132 | static 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 | |
| 3152 | static 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 */ |
| 3199 | static uint16_t le16(const uint8_t *p) { return (uint16_t)(p[0] | ((uint16_t)p[1] << 8)); } |
| 3200 | static 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 | |
| 3202 | static 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" */ |
| 3237 | static 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 | |
| 3271 | static 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 | |
| 3299 | static 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 | |
| 3342 | static 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) |
| 3376 | static 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 | |
| 3406 | static 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 | |
| 3441 | static 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 | |
| 3474 | static 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 | |
| 3517 | static 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 | |
| 3534 | static 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 | |
| 3547 | typedef 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 | |
| 3558 | static game_install_state_t g_game_install_state = {0}; |
| 3559 | |
| 3560 | #if defined(PLATFORM_PS4) || defined(PLATFORM_PS5) |
| 3561 | typedef int (*fn_sceAppInstUtilInitialize_t)(void); |
| 3562 | typedef int (*fn_sceAppInstUtilTerminate_t)(void); |
| 3563 | typedef int (*fn_sceAppInstUtilAppUnInstall_t)(const char *); |
| 3564 | typedef int (*fn_sceAppInstUtilAppInstallPkg_t)(const char *, void *); |
| 3565 | typedef int (*fn_sceAppInstUtilGetTitleIdFromPkg_t)(const char *, char *, int *); |
| 3566 | typedef int (*fn_sceBgftServiceInit_t)(bgft_init_params *); |
| 3567 | typedef int (*fn_sceBgftServiceTerm_t)(void); |
| 3568 | typedef int (*fn_sceBgftServiceIntDownloadRegisterTaskByStorageEx_t)( |
| 3569 | bgft_download_param_ex *, int *); |
| 3570 | typedef int (*fn_sceBgftServiceDownloadStartTask_t)(int); |
| 3571 | typedef int (*fn_sceBgftServiceDownloadGetProgress_t)(int, SceBgftTaskProgress *); |
| 3572 | |
| 3573 | static int g_bgft_initialized = 0; |
| 3574 | static uint8_t g_bgft_heap[1024 * 1024]; |
| 3575 | |
| 3576 | static 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 | |
| 3615 | static 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(¶ms, 0, sizeof(params)); |
| 3625 | params.heap = g_bgft_heap; |
| 3626 | params.heapSize = sizeof(g_bgft_heap); |
| 3627 | int rc = f_bgft_init(¶ms); |
| 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 | |
| 3640 | static 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 | |
| 3674 | static 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(¶ms, 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(¶ms, &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 | |
| 3729 | static 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 | |
| 3754 | static 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 | |
| 3788 | static 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 | |
| 3834 | typedef int (*sqlite3_cb_t)(void *, int, char **, char **); |
| 3835 | |
| 3836 | typedef 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 | |
| 3848 | static 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 | |
| 3905 | typedef struct { |
| 3906 | char names[64][64]; |
| 3907 | size_t count; |
| 3908 | } appdb_table_list_t; |
| 3909 | |
| 3910 | typedef struct { |
| 3911 | char names[128][64]; |
| 3912 | size_t count; |
| 3913 | } appdb_column_list_t; |
| 3914 | |
| 3915 | typedef struct { |
| 3916 | int value; |
| 3917 | int have_value; |
| 3918 | } appdb_int_result_t; |
| 3919 | |
| 3920 | static 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 | |
| 3941 | static 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 | |
| 3962 | static 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 | |
| 3973 | static 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 | |
| 3997 | static 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 | |
| 4125 | static 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 | |
| 4355 | static 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 | |
| 4429 | static 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 | |
| 4569 | static 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 | |
| 4783 | static 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) */ |
| 4898 | static 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 | |
| 4915 | static 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 | |
| 4995 | static 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 | |
| 5083 | static 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 | |
| 5105 | static 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 */ |
| 5139 | typedef 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 | |
| 5155 | static dl_entry_t g_downloads[DL_MAX_ACTIVE]; |
| 5156 | static int g_dl_next_id = 1; |
| 5157 | |
| 5158 | static 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 | |
| 5172 | static 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) */ |
| 5180 | static 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 | |
| 5203 | struct dl_write_ctx { |
| 5204 | dl_entry_t *dl; |
| 5205 | int fd; |
| 5206 | }; |
| 5207 | |
| 5208 | static 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 | |
| 5218 | static 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 | |
| 5275 | static 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 | |
| 5368 | static 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 | |
| 5409 | static 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 | |
| 5438 | static 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 | *---------------------------------------------------------------------------*/ |
| 5475 | static 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 | *---------------------------------------------------------------------------*/ |
| 5508 | static 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 | |
| 5720 | static 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 | |
| 5732 | static 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 | |
| 5750 | static 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 | |
| 5767 | static 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 | */ |
| 5808 | static 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 | *===========================================================================*/ |
| 5867 | static 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 | *===========================================================================*/ |
| 5913 | static 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(¶m, 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, ¶m); |
| 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, ¶m); |
| 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, ¶m); |
| 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, ¶m); |
| 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, ¶m); |
| 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 | |
| 6436 | static 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 | |
| 6485 | static 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 | |
| 6557 | static 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 | |
| 6734 | static 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 | |
| 6791 | static 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 | |
| 6833 | static 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 | |
| 6906 | static 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 |