mirror of
https://github.com/mihawk-99/PS5_RetroArch.git
synced 2026-10-22 08:32:30 -05:00
- 0122: HTTPS uses MBEDTLS_SSL_VERIFY_REQUIRED (upstream verified optionally
and ignored the result), so nobody on the network path can stand in for
RetroAchievements. Its chain (GTS Root R4, cross-signed by GlobalSign Root
CA) verifies against the bundled 2017 roots.
- 0123 and src/ps5_cheevos.c: a sign-in that returns a token removes
cheevos_password from /app0/config/webui.cfg (with the empty token the
WebUI puts beside a new password) and from retroarch.cfg, which keeps its
token. The WebUI clears the old token when a new password or account is
saved, so the new account signs in with its password.
- WebUI: game, file, system and source names carry data-no-translate, and a
value inside a translated sentence is translated only on an exact match:
"Shadow of the Colossus" no longer reads as "{0} of {1}" ("Shadow sur the
Colossus" in French).
- LRPS2 leftovers: the 34 orphan option texts in each catalog, the stale
wording in ps5sx2_libretro.info and build-ps5sx2.sh. Patch count 255 -> 257.
Console (192.168.50.237, klog/retroarch-191705.log): DNS, "TLS connected",
mihawk99 logged in, game 2992 identified with verification required; after
the run webui.cfg holds no cheevos_password and retroarch.cfg an empty one
with its token kept.
Tests: test_webui_secrets (token cleared; helper removes the password, keeps
the token in retroarch.cfg), test_webui_language (a file named Shadow of the
Colossus stays as it is in French), test_frontend: OK. lint-shell PASS;
lint-format fails only in src/main.cpp and src/ps5_framegen_menu.c, not
touched here.
2642 lines
108 KiB
C++
2642 lines
108 KiB
C++
/* Copyright (C) 2026 Mihawk; SPDX-License-Identifier: GPL-3.0-or-later
|
|
* HTTP transport follows ps5-payload-dev/websrv: libmicrohttpd manages framing,
|
|
* partial bodies, timeouts and connections. Routes are restricted to RetroArch.
|
|
*/
|
|
#include "webui_ps5.h"
|
|
#include "webui_update.h"
|
|
#include "webui_transfer.h"
|
|
#include "scraper.h"
|
|
#include "ps5_frontend_choice.h"
|
|
#include "../vendor/retroarch/libretro-common/include/libretro.h"
|
|
#include <microhttpd.h>
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <arpa/inet.h>
|
|
#include <cerrno>
|
|
#include <climits>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <dirent.h>
|
|
#include <fcntl.h>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <netinet/tcp.h>
|
|
#include <new>
|
|
#include <netinet/in.h>
|
|
#include <string>
|
|
#include <sys/socket.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/statvfs.h>
|
|
#include <unistd.h>
|
|
#include <vector>
|
|
|
|
namespace
|
|
{
|
|
MHD_Daemon *web_daemon = nullptr;
|
|
std::string root_path, token;
|
|
// The page's build when this server started (webui/version.json): a page newer than
|
|
// it (files replaced by a deploy or an update while the server ran) says so.
|
|
std::string started_build;
|
|
std::mutex frontend_lock;
|
|
std::string frontend_name = "retroarch";
|
|
unsigned short listen_port;
|
|
constexpr uint64_t upload_limit = UINT64_C(64) * 1024 * 1024 * 1024;
|
|
struct Setting
|
|
{
|
|
const char *key;
|
|
const char *label;
|
|
const char *kind;
|
|
const char *initial;
|
|
int min, max;
|
|
};
|
|
const Setting settings[] = {
|
|
{"audio_volume", "Audio volume", "number", "0", -80, 12},
|
|
{"input_rumble_gain", "Rumble strength", "number", "100", 0, 100},
|
|
{"video_smooth", "Smooth image scaling", "bool", "false", 0, 0},
|
|
{"video_vsync", "Vertical sync", "bool", "true", 0, 0},
|
|
{"savestate_auto_save", "Save state when closing content", "bool", "false", 0, 0},
|
|
{"savestate_auto_load", "Load state when opening content", "bool", "false", 0, 0},
|
|
{"menu_show_advanced_settings", "Show advanced settings", "bool", "false", 0, 0},
|
|
{"menu_driver", "Console menu", "menu", "xmb", 0, 0},
|
|
};
|
|
// RetroArch's online settings the guided editor offers before RetroArch has written them
|
|
// to its configuration (RetroAchievements, netplay): RetroArch's own defaults.
|
|
const Setting online_settings[] = {
|
|
{"cheevos_enable", "RetroAchievements", "bool", "false", 0, 0},
|
|
{"cheevos_username", "RetroAchievements user name", "text", "", 0, 0},
|
|
{"cheevos_password", "RetroAchievements password", "text", "", 0, 0},
|
|
{"cheevos_hardcore_mode_enable", "Hardcore mode", "bool", "false", 0, 0},
|
|
{"netplay_nickname", "Netplay nickname", "text", "", 0, 0},
|
|
{"netplay_public_announce", "Announce netplay games", "bool", "true", 0, 0},
|
|
{"netplay_use_mitm_server", "Netplay relay server", "bool", "false", 0, 0},
|
|
};
|
|
// A password or token in RetroArch's configuration (cheevos_password, cheevos_token,
|
|
// netplay_password...): never sent to a browser. A password can be set, not read; an
|
|
// empty one leaves the saved one as it is. A token is RetroArch's own to write.
|
|
bool secret_setting(const std::string &key)
|
|
{
|
|
const auto ends = [&](const char *suffix)
|
|
{
|
|
const size_t n = std::strlen(suffix);
|
|
return key.size() >= n && key.compare(key.size() - n, n, suffix) == 0;
|
|
};
|
|
return ends("_password") || ends("_token");
|
|
}
|
|
// The server answers on a pool of threads (ps5_webui_start). Transfers write on
|
|
// their own; everything else (settings, folders, the updater) runs under this lock,
|
|
// as it did on the one thread before.
|
|
std::mutex state_lock;
|
|
ps5_transfer::Sessions sessions;
|
|
std::atomic<unsigned> transfers_in_flight{0};
|
|
constexpr std::time_t session_idle_seconds = 15 * 60;
|
|
struct BatchState;
|
|
enum class Transfer
|
|
{
|
|
none,
|
|
single, // PUT /api/upload
|
|
batch, // PUT /api/upload/batch
|
|
part, // PUT /api/upload/part
|
|
scraped, // PUT /api/scraper/pc/media: a file the PC helper downloaded
|
|
game_file, // PUT /api/library/file
|
|
game_add, // PUT /api/library/add
|
|
media // PUT /api/library/media: a media file the user chose for a game
|
|
};
|
|
struct Request
|
|
{
|
|
Transfer transfer = Transfer::none;
|
|
ps5_transfer::Writer writer;
|
|
std::unique_ptr<BatchState> batch;
|
|
std::shared_ptr<ps5_transfer::Session> session;
|
|
bool replace = false;
|
|
std::string temporary, destination, body;
|
|
uint64_t received = 0, expected = 0, offset = 0;
|
|
size_t body_limit = 16384;
|
|
std::string job;
|
|
unsigned task = 0;
|
|
std::string media_system, media_game, media_kind, media_why; // Transfer::media
|
|
unsigned error = 0;
|
|
const char *message = "";
|
|
bool counted = false; // in transfers_in_flight
|
|
~Request();
|
|
};
|
|
std::string quote(const std::string &text)
|
|
{
|
|
std::string out = "\"";
|
|
for (unsigned char c : text)
|
|
{
|
|
if (c == '"' || c == '\\')
|
|
{
|
|
out += '\\';
|
|
out += c;
|
|
}
|
|
else if (c < 32)
|
|
{
|
|
char escape[7];
|
|
std::snprintf(escape, sizeof escape, "\\u%04x", c);
|
|
out += escape;
|
|
}
|
|
else
|
|
out += c;
|
|
}
|
|
return out + '"';
|
|
}
|
|
std::string read_file(const std::string &name, size_t limit)
|
|
{
|
|
std::string out;
|
|
int fd = open(name.c_str(), O_RDONLY | O_NOFOLLOW);
|
|
if (fd < 0)
|
|
return out;
|
|
char buffer[4096];
|
|
while (out.size() < limit)
|
|
{
|
|
ssize_t n = read(fd, buffer, std::min(sizeof buffer, limit - out.size()));
|
|
if (n <= 0)
|
|
break;
|
|
out.append(buffer, size_t(n));
|
|
}
|
|
close(fd);
|
|
return out;
|
|
}
|
|
std::string nonce()
|
|
{
|
|
unsigned char bytes[16];
|
|
arc4random_buf(bytes, sizeof bytes);
|
|
std::string out;
|
|
for (auto b : bytes)
|
|
{
|
|
char hex[3];
|
|
std::snprintf(hex, sizeof hex, "%02x", b);
|
|
out += hex;
|
|
}
|
|
return out;
|
|
}
|
|
MHD_Result respond(MHD_Connection *c, unsigned status, const std::string &body,
|
|
const char *type = "application/json")
|
|
{
|
|
auto *response = MHD_create_response_from_buffer(body.size(), const_cast<char *>(body.data()),
|
|
MHD_RESPMEM_MUST_COPY);
|
|
if (!response)
|
|
return MHD_NO;
|
|
MHD_add_response_header(response, "Content-Type", type);
|
|
MHD_add_response_header(response, "Cache-Control", "no-store");
|
|
MHD_add_response_header(response, "X-Content-Type-Options", "nosniff");
|
|
MHD_add_response_header(response, "Referrer-Policy", "no-referrer");
|
|
MHD_add_response_header(response, "Content-Security-Policy",
|
|
"default-src 'self'; connect-src 'self' https://api.github.com; "
|
|
"img-src 'self'; style-src 'self'; script-src 'self'; font-src 'self'; "
|
|
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
|
|
auto result = MHD_queue_response(c, status, response);
|
|
MHD_destroy_response(response);
|
|
return result;
|
|
}
|
|
MHD_Result error(MHD_Connection *c, unsigned status, const char *message)
|
|
{
|
|
return respond(c, status, "{\"error\":" + quote(message) + "}");
|
|
}
|
|
const char *arg(MHD_Connection *c, const char *key)
|
|
{
|
|
const char *v = MHD_lookup_connection_value(c, MHD_GET_ARGUMENT_KIND, key);
|
|
return v ? v : "";
|
|
}
|
|
bool local_origin(MHD_Connection *c)
|
|
{
|
|
const char *host = MHD_lookup_connection_value(c, MHD_HEADER_KIND, "Host");
|
|
if (!host)
|
|
return false;
|
|
const auto *info = MHD_get_connection_info(c, MHD_CONNECTION_INFO_CONNECTION_FD);
|
|
if (!info)
|
|
return false;
|
|
sockaddr_in addr{};
|
|
socklen_t size = sizeof addr;
|
|
if (getsockname(info->connect_fd, reinterpret_cast<sockaddr *>(&addr), &size) != 0 ||
|
|
addr.sin_family != AF_INET)
|
|
return false;
|
|
char ip[INET_ADDRSTRLEN];
|
|
if (!inet_ntop(AF_INET, &addr.sin_addr, ip, sizeof ip))
|
|
return false;
|
|
const std::string expected = std::string(ip) + ":" + std::to_string(listen_port);
|
|
if (host != expected)
|
|
return false; // Literal console address also prevents DNS rebinding.
|
|
const char *origin = MHD_lookup_connection_value(c, MHD_HEADER_KIND, "Origin");
|
|
return !origin || std::string(origin) == "http://" + expected;
|
|
}
|
|
bool valid_path(const std::string &relative)
|
|
{
|
|
if (relative.size() > 1024 || (!relative.empty() && relative.front() == '/'))
|
|
return false;
|
|
size_t start = 0;
|
|
while (start < relative.size())
|
|
{
|
|
const auto end = relative.find('/', start);
|
|
const auto part = relative.substr(start, end - start);
|
|
if (part.empty() || part.front() == '.' || part.size() > 255)
|
|
return false;
|
|
for (unsigned char c : part)
|
|
if (c < 32 || c == 127 || c == '\\' || c == ':')
|
|
return false;
|
|
if (end == std::string::npos)
|
|
return true;
|
|
start = end + 1;
|
|
if (start == relative.size())
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
// A title cannot use lstat reliably. Opening without following links preserves
|
|
// the path boundary and lets fstat obtain the native file type and size.
|
|
int content_stat(const char *path, struct stat *st)
|
|
{
|
|
int fd = open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
if (fd < 0)
|
|
return -1;
|
|
int result = fstat(fd, st);
|
|
int saved_errno = errno;
|
|
close(fd);
|
|
errno = saved_errno;
|
|
return result;
|
|
}
|
|
bool storage_space(struct statvfs &storage)
|
|
{
|
|
#ifdef __PROSPERO__
|
|
// The SDK's statvfs compatibility answer is synthetic, not free disk space.
|
|
(void)storage;
|
|
return false;
|
|
#else
|
|
return statvfs((root_path + "/content").c_str(), &storage) == 0;
|
|
#endif
|
|
}
|
|
// Refuse symlinks at every component. Only files below content/ are exposed.
|
|
bool content_path(const std::string &relative, std::string &absolute, bool new_leaf = false)
|
|
{
|
|
if (!valid_path(relative))
|
|
return false;
|
|
absolute = root_path + "/content";
|
|
struct stat st{};
|
|
if (content_stat(absolute.c_str(), &st) || !S_ISDIR(st.st_mode))
|
|
return false;
|
|
size_t start = 0;
|
|
while (start < relative.size())
|
|
{
|
|
auto end = relative.find('/', start);
|
|
absolute += '/' + relative.substr(start, end - start);
|
|
if (end == std::string::npos && new_leaf)
|
|
return true;
|
|
if (content_stat(absolute.c_str(), &st) || S_ISLNK(st.st_mode))
|
|
return false;
|
|
if (end != std::string::npos && !S_ISDIR(st.st_mode))
|
|
return false;
|
|
if (end == std::string::npos)
|
|
break;
|
|
start = end + 1;
|
|
}
|
|
return true;
|
|
}
|
|
// A path below content/ for a new file, its missing folders created on the way
|
|
// (an upload of a folder's files, a batch's subfolders). The leaf is not checked.
|
|
bool content_parents(const std::string &relative, std::string &absolute)
|
|
{
|
|
if (relative.empty() || !valid_path(relative))
|
|
return false;
|
|
absolute = root_path + "/content";
|
|
struct stat st{};
|
|
if (content_stat(absolute.c_str(), &st) || !S_ISDIR(st.st_mode))
|
|
return false;
|
|
size_t start = 0;
|
|
for (;;)
|
|
{
|
|
const auto end = relative.find('/', start);
|
|
absolute += '/' + relative.substr(start, end - start);
|
|
if (end == std::string::npos)
|
|
return true;
|
|
if (content_stat(absolute.c_str(), &st))
|
|
{
|
|
if (errno != ENOENT || (mkdir(absolute.c_str(), 0755) && errno != EEXIST) ||
|
|
content_stat(absolute.c_str(), &st))
|
|
return false;
|
|
}
|
|
if (S_ISLNK(st.st_mode) || !S_ISDIR(st.st_mode))
|
|
return false;
|
|
start = end + 1;
|
|
}
|
|
}
|
|
bool exists(const std::string &absolute)
|
|
{
|
|
struct stat st{};
|
|
return content_stat(absolute.c_str(), &st) == 0 || errno != ENOENT;
|
|
}
|
|
std::string trim(std::string s)
|
|
{
|
|
auto first = s.find_first_not_of(" \t\r\n");
|
|
if (first == std::string::npos)
|
|
return "";
|
|
s = s.substr(first, s.find_last_not_of(" \t\r\n") - first + 1);
|
|
if (s.size() >= 2 && s.front() == '"' && s.back() == '"')
|
|
s = s.substr(1, s.size() - 2);
|
|
return s;
|
|
}
|
|
using Config = std::map<std::string, std::string>;
|
|
bool setting_key(const std::string &key)
|
|
{
|
|
return !key.empty() && key.size() <= 160 &&
|
|
key.find_first_not_of(
|
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.") ==
|
|
std::string::npos;
|
|
}
|
|
Config parse_config(const std::string &text)
|
|
{
|
|
Config values;
|
|
size_t start = 0;
|
|
while (start < text.size())
|
|
{
|
|
auto end = text.find('\n', start);
|
|
auto line = text.substr(start, end - start);
|
|
auto eq = line.find('=');
|
|
if (eq != std::string::npos)
|
|
{
|
|
auto key = trim(line.substr(0, eq));
|
|
if (setting_key(key))
|
|
values[key] = trim(line.substr(eq + 1));
|
|
}
|
|
if (end == std::string::npos)
|
|
break;
|
|
start = end + 1;
|
|
}
|
|
return values;
|
|
}
|
|
Config read_config(const std::string &path)
|
|
{
|
|
return parse_config(read_file(path, 2 * 1024 * 1024));
|
|
}
|
|
void overlay(Config &to, const Config &from)
|
|
{
|
|
for (const auto &entry : from)
|
|
to[entry.first] = entry.second;
|
|
}
|
|
bool write_config(const std::string &path, const Config &values, std::string text = {})
|
|
{
|
|
for (const auto &entry : values)
|
|
text += entry.first + " = \"" + entry.second + "\"\n";
|
|
const std::string temporary = path + ".webui-" + nonce();
|
|
int fd = open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
|
|
if (fd < 0)
|
|
return false;
|
|
size_t offset = 0;
|
|
while (offset < text.size())
|
|
{
|
|
ssize_t n = write(fd, text.data() + offset, text.size() - offset);
|
|
if (n < 0 && errno == EINTR)
|
|
continue;
|
|
if (n <= 0)
|
|
break;
|
|
offset += size_t(n);
|
|
}
|
|
bool ok = offset == text.size() && fsync(fd) == 0;
|
|
close(fd);
|
|
if (ok)
|
|
ok = rename(temporary.c_str(), path.c_str()) == 0;
|
|
unlink(temporary.c_str());
|
|
return ok;
|
|
}
|
|
// Only catalogs whose core binary is installed participate in the profile list.
|
|
std::vector<std::string> installed_cores()
|
|
{
|
|
std::vector<std::string> cores;
|
|
for (const auto &entry : read_config(root_path + "/webui/core-metadata/index.cfg"))
|
|
{
|
|
struct stat st{};
|
|
if (!valid_path(entry.first) || entry.first.find('/') != std::string::npos ||
|
|
!valid_path(entry.second) || entry.second.find('/') != std::string::npos)
|
|
continue;
|
|
if (!content_stat((root_path + "/cores/" + entry.first).c_str(), &st) &&
|
|
S_ISREG(st.st_mode))
|
|
cores.push_back(entry.second);
|
|
}
|
|
return cores;
|
|
}
|
|
std::vector<std::string> core_profiles()
|
|
{
|
|
auto cores = installed_cores();
|
|
DIR *dir = opendir((root_path + "/config").c_str());
|
|
if (!dir)
|
|
return cores;
|
|
while (auto *entry = readdir(dir))
|
|
{
|
|
std::string name = entry->d_name;
|
|
if (!valid_path(name) || name.find('/') != std::string::npos || name == "webui-cores")
|
|
continue;
|
|
struct stat st{};
|
|
std::string folder = root_path + "/config/" + name;
|
|
if (content_stat(folder.c_str(), &st) || !S_ISDIR(st.st_mode))
|
|
continue;
|
|
for (const char *ext : {".opt", ".cfg"})
|
|
if (!content_stat((folder + '/' + name + ext).c_str(), &st) && S_ISREG(st.st_mode))
|
|
{
|
|
cores.push_back(name);
|
|
break;
|
|
}
|
|
}
|
|
closedir(dir);
|
|
std::sort(cores.begin(), cores.end());
|
|
cores.erase(std::unique(cores.begin(), cores.end()), cores.end());
|
|
return cores;
|
|
}
|
|
Config global_values()
|
|
{
|
|
Config values;
|
|
for (const auto &s : settings)
|
|
values[s.key] = s.initial;
|
|
for (const auto &s : online_settings)
|
|
values[s.key] = s.initial;
|
|
overlay(values, read_config(root_path + "/retroarch.cfg"));
|
|
overlay(values, read_config(root_path + "/config/retroarch.cfg"));
|
|
overlay(values, read_config(root_path + "/config/webui.cfg"));
|
|
return values;
|
|
}
|
|
std::string update_status()
|
|
{
|
|
const auto u = ps5_update::status();
|
|
return "{\"state\":" + quote(u.state) + ",\"message\":" + quote(u.message) +
|
|
",\"tag\":" + quote(u.tag) + ",\"received\":" + std::to_string(u.received) + '}';
|
|
}
|
|
// What some cores need, where their .info files say it wrongly: a file of one region
|
|
// is enough (the .info marks every region's required), a core runs without its BIOS
|
|
// for most games (built-in replacements), or a BIOS is "optional" in the .info but no
|
|
// game starts without one. A core listed here is checked by these rules alone.
|
|
struct BiosNeed
|
|
{
|
|
const char *core; // the core's file
|
|
bool required; // no game runs without it; else only some games need it
|
|
const char *title; // what it is
|
|
const char *why; // when it is needed
|
|
std::vector<const char *> any_of; // any one of these files, in the system folder
|
|
};
|
|
const std::vector<BiosNeed> &bios_needs()
|
|
{
|
|
static const std::vector<BiosNeed> needs = {
|
|
{"mednafen_psx_hw_libretro.so",
|
|
true,
|
|
"A PS1 BIOS",
|
|
"One is enough: the one of your games' region (scph5500.bin Japan, scph5501.bin USA, "
|
|
"scph5502.bin Europe). A game of another region needs that region's.",
|
|
{"scph5500.bin", "scph5501.bin", "scph5502.bin"}},
|
|
{"mednafen_saturn_libretro.so",
|
|
true,
|
|
"A Saturn BIOS",
|
|
"One is enough: sega_101.bin (Japan) or mpr-17933.bin (USA and Europe).",
|
|
{"sega_101.bin", "mpr-17933.bin"}},
|
|
{"opera_libretro.so",
|
|
true,
|
|
"A 3DO BIOS",
|
|
"Any one of the 3DO BIOS files; no 3DO game starts without one.",
|
|
{"panafz1.bin", "panafz10.bin", "panafz10-norsa.bin", "panafz10e-anvil.bin",
|
|
"panafz10e-anvil-norsa.bin", "goldstar.bin", "sanyotry.bin", "3do_arcade_saot.bin",
|
|
"panafz1j.bin", "panafz1j-norsa.bin"}},
|
|
{"neocd_libretro.so",
|
|
true,
|
|
"A Neo Geo CD BIOS",
|
|
"Any one of the Neo Geo CD BIOS files, in a neocd folder.",
|
|
{"neocd/neocd_f.rom", "neocd/neocd_sf.rom", "neocd/front-sp1.bin", "neocd/neocd_t.rom",
|
|
"neocd/neocd_st.rom", "neocd/top-sp1.bin", "neocd/neocd_z.rom", "neocd/neocd_sz.rom",
|
|
"neocd/neocd.bin", "neocd/uni-bioscd.rom"}},
|
|
{"neocd_libretro.so",
|
|
true,
|
|
"The Neo Geo CD LO ROM",
|
|
"The zoom table ROM, beside the BIOS in the neocd folder.",
|
|
{"neocd/000-lo.lo", "neocd/ng-lo.rom"}},
|
|
{"mednafen_pcfx_libretro.so",
|
|
true,
|
|
"The PC-FX BIOS",
|
|
"No PC-FX game starts without it.",
|
|
{"pcfx.rom"}},
|
|
{"mednafen_pce_libretro.so",
|
|
false,
|
|
"A PC Engine CD System Card",
|
|
"Only PC Engine CD games need it (syscard3.pce plays them all); cartridges run without.",
|
|
{"syscard3.pce", "syscard2.pce", "syscard1.pce", "gexpress.pce"}},
|
|
{"puae_libretro.so",
|
|
false,
|
|
"An Amiga Kickstart",
|
|
"PUAE runs most games on its built-in replacement (AROS); some need the real "
|
|
"Kickstart of their model (A500: kick34005.A500, A1200: kick40068.A1200, CD32: "
|
|
"kick40060.CD32).",
|
|
{"kick34005.A500", "kick33180.A500", "kick37175.A500", "kick37350.A600", "kick40063.A600",
|
|
"kick39106.A1200", "kick40068.A1200", "kick39106.A4000", "kick40068.A4000",
|
|
"kick40060.CD32"}},
|
|
{"flycast_libretro.so",
|
|
false,
|
|
"The Dreamcast BIOS",
|
|
"Flycast plays most games on its built-in BIOS; a few need the real one (dc/dc_boot.bin).",
|
|
{"dc/dc_boot.bin"}},
|
|
};
|
|
return needs;
|
|
}
|
|
bool system_file(const std::string &path)
|
|
{
|
|
struct stat st{};
|
|
return !content_stat(path.c_str(), &st) && S_ISREG(st.st_mode) && st.st_size > 0;
|
|
}
|
|
std::string bios_alerts()
|
|
{
|
|
std::string out = "{\"alerts\":[";
|
|
auto add = [&](const std::string &core, const std::string &description, const std::string &path,
|
|
const std::string &message, bool required = true,
|
|
const std::vector<const char *> &any_of = {})
|
|
{
|
|
if (out.back() != '[')
|
|
out += ',';
|
|
std::string names;
|
|
for (const char *name : any_of)
|
|
names += std::string(names.empty() ? "" : ",") + quote(name);
|
|
out += "{\"core\":" + quote(core) + ",\"title\":" + quote(description) +
|
|
",\"path\":" + quote(path) + ",\"message\":" + quote(message) +
|
|
",\"level\":" + quote(required ? "required" : "some") + ",\"any_of\":[" + names +
|
|
"]}";
|
|
};
|
|
const auto global = global_values();
|
|
const auto known = read_config(root_path + "/webui/core-metadata/index.cfg");
|
|
// Every core installed, not only those with option catalogs.
|
|
std::vector<std::string> files;
|
|
if (DIR *dir = opendir((root_path + "/cores").c_str()))
|
|
{
|
|
while (auto *entry = readdir(dir))
|
|
{
|
|
const std::string name = entry->d_name;
|
|
if (name.size() > 12 && name.compare(name.size() - 12, 12, "_libretro.so") == 0 &&
|
|
valid_path(name) && name != "rpcs3_libretro.so")
|
|
files.push_back(name);
|
|
}
|
|
closedir(dir);
|
|
}
|
|
std::sort(files.begin(), files.end());
|
|
for (const auto &file : files)
|
|
{
|
|
struct stat st{};
|
|
const auto info =
|
|
read_config(root_path + "/info/" + file.substr(0, file.size() - 3) + ".info");
|
|
// Its settings live under its library name; it is shown by its display name.
|
|
const auto listed = known.find(file);
|
|
const std::string library = listed != known.end() ? listed->second
|
|
: info.count("corename") ? info.at("corename")
|
|
: file;
|
|
const std::string shown = info.count("display_name") ? info.at("display_name") : library;
|
|
auto values = global;
|
|
if (valid_path(library) && library.find('/') == std::string::npos)
|
|
{
|
|
overlay(values, read_config(root_path + "/config/" + library + '/' + library + ".cfg"));
|
|
overlay(values, read_config(root_path + "/config/webui-cores/" + library + ".cfg"));
|
|
}
|
|
std::string directory = values["system_directory"];
|
|
if (directory.empty() || directory == "default")
|
|
directory = root_path + "/system";
|
|
else if (directory.rfind(":/", 0) == 0)
|
|
directory = root_path + directory.substr(1);
|
|
else if (directory.rfind("/app0/", 0) == 0)
|
|
directory = root_path + directory.substr(5);
|
|
if (library == "Beetle Saturn" && directory == root_path + "/system")
|
|
directory += "/Saturn";
|
|
if (directory.front() != '/')
|
|
{
|
|
add(shown, "System folder needs attention", directory,
|
|
"Choose an absolute System/BIOS folder in this core's settings.");
|
|
continue;
|
|
}
|
|
bool ruled = false;
|
|
for (const auto &need : bios_needs())
|
|
{
|
|
if (file != need.core)
|
|
continue;
|
|
ruled = true;
|
|
bool present = false;
|
|
for (const char *name : need.any_of)
|
|
present = present || system_file(directory + '/' + name);
|
|
if (!present)
|
|
add(shown, need.title, directory, need.why, need.required, need.any_of);
|
|
}
|
|
if (ruled)
|
|
continue;
|
|
for (const auto &firmware : info)
|
|
{
|
|
if (firmware.first.rfind("firmware", 0) || firmware.first.size() < 6 ||
|
|
firmware.first.substr(firmware.first.size() - 5) != "_path")
|
|
continue;
|
|
const auto prefix = firmware.first.substr(0, firmware.first.size() - 5);
|
|
auto optional = info.find(prefix + "_opt");
|
|
if (optional == info.end() || optional->second != "false" ||
|
|
!valid_path(firmware.second))
|
|
continue;
|
|
const std::string path = directory + '/' + firmware.second;
|
|
bool present = !content_stat(path.c_str(), &st) &&
|
|
((S_ISREG(st.st_mode) && st.st_size > 0) || S_ISDIR(st.st_mode));
|
|
// A required BIOS directory exists after installation even when it is empty.
|
|
if (present && S_ISDIR(st.st_mode))
|
|
{
|
|
present = false;
|
|
DIR *folder = opendir(path.c_str());
|
|
if (folder)
|
|
{
|
|
while (auto *e = readdir(folder))
|
|
{
|
|
std::string name = e->d_name;
|
|
if (name.size() > 4 &&
|
|
(name.substr(name.size() - 4) == ".bin" ||
|
|
name.substr(name.size() - 4) == ".BIN") &&
|
|
!content_stat((path + '/' + name).c_str(), &st) &&
|
|
S_ISREG(st.st_mode) && st.st_size > 0)
|
|
{
|
|
present = true;
|
|
break;
|
|
}
|
|
}
|
|
closedir(folder);
|
|
}
|
|
}
|
|
if (!present)
|
|
{
|
|
auto desc = info.find(prefix + "_desc");
|
|
add(shown, desc == info.end() ? firmware.second : desc->second, path,
|
|
"Required firmware was not found here. Add your own BIOS/system files for the "
|
|
"regions and content you use. Presence only is checked; file authenticity is "
|
|
"not verified.");
|
|
}
|
|
}
|
|
}
|
|
auto update = ps5_update::status();
|
|
if (update.state == "error")
|
|
add("RetroArch update", "Update needs attention", "", update.message);
|
|
return out + "],\"scope\":\"Installed cores and configured system folders\"}";
|
|
}
|
|
|
|
std::string revision(const Config &values)
|
|
{
|
|
uint64_t hash = UINT64_C(14695981039346656037);
|
|
for (const auto &entry : values)
|
|
for (unsigned char c : entry.first + '=' + entry.second + '\n')
|
|
hash = (hash ^ c) * UINT64_C(1099511628211);
|
|
return std::to_string(hash);
|
|
}
|
|
std::string catalog_revision(const std::string &core)
|
|
{
|
|
// Catalogs include the staged core's binary hash, covering dynamic options
|
|
// even when its static option table has not changed in a new release.
|
|
return revision({{"catalog", read_file(root_path + "/webui/core-metadata/" + core + ".json",
|
|
2 * 1024 * 1024)}});
|
|
}
|
|
std::string runtime_metadata(const std::string &core, const char *extension)
|
|
{
|
|
const std::string folder = root_path + "/config/webui-metadata";
|
|
struct stat st{};
|
|
if (content_stat(folder.c_str(), &st) || !S_ISDIR(st.st_mode))
|
|
return {};
|
|
auto text = read_file(folder + '/' + core + extension, 2 * 1024 * 1024);
|
|
const auto tag = catalog_revision(core);
|
|
const auto prefix = std::strcmp(extension, ".json") == 0
|
|
? "{\"catalogRevision\":" + quote(tag) + ','
|
|
: "# catalog-revision: " + tag + '\n';
|
|
return text.compare(0, prefix.size(), prefix) == 0 ? text : std::string();
|
|
}
|
|
const char *value_kind(const std::string &value)
|
|
{
|
|
if (value == "true" || value == "false")
|
|
return "bool";
|
|
char *end = nullptr;
|
|
errno = 0;
|
|
double n = std::strtod(value.c_str(), &end);
|
|
if (!value.empty() && end && !*end && !errno && std::isfinite(n))
|
|
return "number";
|
|
return "text";
|
|
}
|
|
const char *setting_kind(const std::string &key, bool core_option)
|
|
{
|
|
if (!core_option)
|
|
{
|
|
for (const auto &s : settings)
|
|
if (key == s.key)
|
|
return std::strcmp(s.kind, "menu") == 0 ? "text" : s.kind;
|
|
for (const auto &s : online_settings)
|
|
if (key == s.key)
|
|
return s.kind;
|
|
}
|
|
// Config files do not carry type metadata. Numeric-looking bindings and
|
|
// enum choices remain strings, so changing their value cannot change type.
|
|
return "text";
|
|
}
|
|
bool valid_setting_value(const std::string &value, const std::string &kind)
|
|
{
|
|
if (value.size() > 4096)
|
|
return false;
|
|
// Config values are quoted. Reject syntax that could escape that value.
|
|
for (unsigned char c : value)
|
|
if (c < 32 || c == 127 || c == '"' || c == '\\')
|
|
return false;
|
|
return kind == "text" || kind == value_kind(value);
|
|
}
|
|
MHD_Result config_editor(MHD_Connection *c, const std::string &method, const std::string &body)
|
|
{
|
|
const std::string scope = arg(c, "scope"), core = arg(c, "core");
|
|
const bool global = scope == "global", options = scope == "core-options";
|
|
if (!global && !options && scope != "core-settings")
|
|
return error(c, 400, "Choose global settings or a core profile.");
|
|
std::string path = root_path + "/config/webui.cfg";
|
|
Config baseline = global_values(), saved;
|
|
if (!global)
|
|
{
|
|
auto cores = core_profiles();
|
|
if (std::find(cores.begin(), cores.end(), core) == cores.end())
|
|
return error(c, 404, "This core is not installed and has no saved profile.");
|
|
std::string ext = options ? ".opt" : ".cfg";
|
|
if (options)
|
|
{
|
|
baseline = parse_config(runtime_metadata(core, ".opt"));
|
|
overlay(baseline, read_config(root_path + "/webui/core-metadata/" + core + ".opt"));
|
|
}
|
|
overlay(baseline, read_config(root_path + "/config/" + core + '/' + core + ext));
|
|
path = root_path + "/config/webui-cores/" + core + ext;
|
|
}
|
|
saved = read_config(path);
|
|
overlay(baseline, saved);
|
|
const std::string tag = revision(baseline);
|
|
if (method == "GET")
|
|
{
|
|
std::string out = "{\"revision\":" + quote(tag) + ",\"settings\":[";
|
|
for (const auto &entry : baseline)
|
|
{
|
|
if (out.back() != '[')
|
|
out += ',';
|
|
const bool secret = secret_setting(entry.first);
|
|
out += "{\"key\":" + quote(entry.first) +
|
|
",\"value\":" + quote(secret ? std::string() : entry.second) +
|
|
",\"kind\":" + quote(setting_kind(entry.first, options)) +
|
|
(secret ? std::string(",\"secret\":true,\"set\":") +
|
|
(entry.second.empty() ? "false" : "true")
|
|
: std::string()) +
|
|
'}';
|
|
}
|
|
return respond(c, 200, out + "],\"apply\":\"next_launch\"}");
|
|
}
|
|
if (method != "POST")
|
|
return error(c, 405, "This action is not supported.");
|
|
const char *expected = MHD_lookup_connection_value(c, MHD_HEADER_KIND, "X-RetroArch-Revision");
|
|
if (!expected || tag != expected)
|
|
return error(c, 409,
|
|
"Settings changed since this page loaded. Refresh before saving again.");
|
|
size_t start = 0;
|
|
unsigned changed = 0;
|
|
while (start < body.size())
|
|
{
|
|
auto end = body.find('\n', start), eq = body.find('=', start);
|
|
if (eq == std::string::npos || (end != std::string::npos && eq > end))
|
|
return error(c, 400, "Invalid settings. Reload the page and try again.");
|
|
auto key = body.substr(start, eq - start);
|
|
auto value = body.substr(eq + 1, end == std::string::npos ? end : end - eq - 1);
|
|
auto original = baseline.find(key);
|
|
if (secret_setting(key) && key.size() > 6 && key.compare(key.size() - 6, 6, "_token") == 0)
|
|
return error(
|
|
c, 400, "RetroArch writes its sign-in tokens itself; they cannot be changed here.");
|
|
if (secret_setting(key) && value.empty())
|
|
{
|
|
// A password left empty: the saved one stays.
|
|
if (end == std::string::npos)
|
|
break;
|
|
start = end + 1;
|
|
continue;
|
|
}
|
|
if (original == baseline.end() || !valid_setting_value(value, setting_kind(key, options)))
|
|
return error(
|
|
c, 400, "Use an existing setting and a valid value without quotes or line breaks.");
|
|
if (!options)
|
|
for (const auto &s : settings)
|
|
if (key == s.key)
|
|
{
|
|
if (std::strcmp(s.kind, "menu") == 0 && value != "xmb" && value != "rgui")
|
|
return error(c, 400, "Choose XMB or RGUI for the console menu.");
|
|
if (std::strcmp(s.kind, "number") == 0 &&
|
|
(std::strtod(value.c_str(), nullptr) < s.min ||
|
|
std::strtod(value.c_str(), nullptr) > s.max))
|
|
return error(c, 400, "A setting is outside its supported range.");
|
|
}
|
|
// A new password or account signs in with the password: the old account's token,
|
|
// which RetroArch would try first, goes (src/ps5_cheevos.c removes both after).
|
|
if ((key == "cheevos_password" || key == "cheevos_username") &&
|
|
(original == baseline.end() || original->second != value))
|
|
saved["cheevos_token"] = "";
|
|
saved[key] = value;
|
|
++changed;
|
|
if (end == std::string::npos)
|
|
break;
|
|
start = end + 1;
|
|
}
|
|
if (!changed)
|
|
return error(c, 400, "No settings were provided.");
|
|
if (!global)
|
|
{
|
|
std::string folder = root_path + "/config/webui-cores";
|
|
mkdir(folder.c_str(), 0755);
|
|
struct stat st{};
|
|
if (content_stat(folder.c_str(), &st) || !S_ISDIR(st.st_mode))
|
|
return error(c, 500, "The settings folder is not available.");
|
|
}
|
|
return write_config(path, saved)
|
|
? respond(c, 200, "{\"saved\":true,\"apply\":\"next_launch\"}")
|
|
: error(c, 500, "Settings could not be saved. Check console storage.");
|
|
}
|
|
void apply_core_settings()
|
|
{
|
|
for (const auto &core : core_profiles())
|
|
for (const char *ext : {".opt", ".cfg"})
|
|
{
|
|
auto saved = read_config(root_path + "/config/webui-cores/" + core + ext);
|
|
if (saved.empty())
|
|
continue;
|
|
const std::string folder = root_path + "/config/" + core;
|
|
mkdir(folder.c_str(), 0755);
|
|
struct stat st{};
|
|
if (content_stat(folder.c_str(), &st) || !S_ISDIR(st.st_mode))
|
|
continue;
|
|
std::string destination = folder + '/' + core + ext;
|
|
auto values = read_config(destination);
|
|
overlay(values, saved);
|
|
if (!write_config(destination, values))
|
|
std::fprintf(stderr, "webui: could not apply saved core settings\n");
|
|
else
|
|
{
|
|
// These two ports retire pre-profile option files on first use.
|
|
// A deliberate WebUI edit is already a user profile: do not let
|
|
// that one-time migration rename it away when the core starts.
|
|
if (std::strcmp(ext, ".opt") == 0 && (core == "PPSSPP" || core == "dolphin-emu"))
|
|
{
|
|
const std::string marker = folder + "/ps5-default-profile-v1";
|
|
int fd = open(marker.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
|
|
if (fd >= 0)
|
|
close(fd);
|
|
else if (errno != EEXIST)
|
|
{
|
|
std::fprintf(stderr, "webui: could not preserve first-use core profile\n");
|
|
continue; // Keep the pending edit for a later retry.
|
|
}
|
|
}
|
|
if (unlink((root_path + "/config/webui-cores/" + core + ext).c_str()))
|
|
std::fprintf(stderr, "webui: could not clear applied core settings\n");
|
|
}
|
|
}
|
|
}
|
|
Config config_values(bool overrides_only = false)
|
|
{
|
|
return overrides_only ? read_config(root_path + "/config/webui.cfg") : global_values();
|
|
}
|
|
MHD_Result get_settings(MHD_Connection *c)
|
|
{
|
|
auto values = config_values();
|
|
std::string out = "{\"settings\":[";
|
|
for (const auto &s : settings)
|
|
{
|
|
if (out.back() != '[')
|
|
out += ',';
|
|
out += "{\"key\":" + quote(s.key) + ",\"label\":" + quote(s.label) +
|
|
",\"kind\":" + quote(s.kind) + ",\"value\":" + quote(values[s.key]) +
|
|
",\"min\":" + std::to_string(s.min) + ",\"max\":" + std::to_string(s.max) + '}';
|
|
}
|
|
// The frontend the title starts with is the title's, not RetroArch's: it lives in
|
|
// config/frontend.cfg (src/ps5_frontend_choice.h), never in webui.cfg.
|
|
out +=
|
|
",{\"key\":\"frontend_start\",\"label\":\"Start with\",\"kind\":\"frontend\",\"value\":" +
|
|
quote(ps5_frontend_choice_read((root_path + "/config/frontend.cfg").c_str())) +
|
|
",\"min\":0,\"max\":0}";
|
|
return respond(c, 200, out + "],\"apply\":\"next_launch\"}");
|
|
}
|
|
MHD_Result save_settings(MHD_Connection *c, const std::string &body)
|
|
{
|
|
// A bounded, plain key=value body: no arbitrary config paths or keys.
|
|
auto values = config_values(true);
|
|
size_t start = 0;
|
|
unsigned changed = 0;
|
|
const char *frontend = nullptr;
|
|
while (start < body.size())
|
|
{
|
|
auto end = body.find('\n', start);
|
|
auto line = body.substr(start, end - start);
|
|
auto eq = line.find('=');
|
|
if (eq == std::string::npos)
|
|
return error(c, 400, "Invalid settings. Reload the page and try again.");
|
|
auto key = line.substr(0, eq), value = line.substr(eq + 1);
|
|
if (key == "frontend_start")
|
|
{
|
|
frontend = ps5_frontend_choice_name(value.c_str());
|
|
if (!frontend)
|
|
return error(c, 400, "A setting is outside its supported range.");
|
|
++changed;
|
|
if (end == std::string::npos)
|
|
break;
|
|
start = end + 1;
|
|
continue;
|
|
}
|
|
const Setting *setting = nullptr;
|
|
for (auto &s : settings)
|
|
if (key == s.key)
|
|
setting = &s;
|
|
if (!setting)
|
|
return error(c, 400, "This setting cannot be changed through the WebUI.");
|
|
bool valid = false;
|
|
if (std::strcmp(setting->kind, "bool") == 0)
|
|
valid = value == "true" || value == "false";
|
|
else if (std::strcmp(setting->kind, "menu") == 0)
|
|
valid = value == "xmb" || value == "rgui";
|
|
else
|
|
{
|
|
char *tail = nullptr;
|
|
errno = 0;
|
|
long n = std::strtol(value.c_str(), &tail, 10);
|
|
valid = !value.empty() && !errno && !*tail && n >= setting->min && n <= setting->max;
|
|
}
|
|
if (!valid)
|
|
return error(c, 400, "A setting is outside its supported range.");
|
|
values[key] = value;
|
|
++changed;
|
|
if (end == std::string::npos)
|
|
break;
|
|
start = end + 1;
|
|
}
|
|
if (!changed)
|
|
return error(c, 400, "No settings were provided.");
|
|
if (frontend &&
|
|
ps5_frontend_choice_write((root_path + "/config/frontend.cfg").c_str(), frontend) != 0)
|
|
return error(c, 500, "Settings could not be saved. Check console storage.");
|
|
if (frontend && changed == 1)
|
|
return respond(c, 200, "{\"saved\":true,\"apply\":\"next_launch\"}");
|
|
// Keep advanced global overrides when updating a quick setting.
|
|
return write_config(root_path + "/config/webui.cfg", values)
|
|
? respond(c, 200, "{\"saved\":true,\"apply\":\"next_launch\"}")
|
|
: error(c, 500, "Settings could not be saved. Check console storage.");
|
|
}
|
|
|
|
MHD_Result list_content(MHD_Connection *c)
|
|
{
|
|
const std::string relative = arg(c, "path");
|
|
std::string path;
|
|
if (!content_path(relative, path))
|
|
return error(c, 400, "Choose a folder inside RetroArch content.");
|
|
DIR *dir = opendir(path.c_str());
|
|
if (!dir)
|
|
return error(c, 404, "This folder is not available.");
|
|
struct Entry
|
|
{
|
|
std::string name;
|
|
bool folder;
|
|
uint64_t size;
|
|
};
|
|
std::vector<Entry> entries;
|
|
bool truncated = false;
|
|
while (auto *item = readdir(dir))
|
|
{
|
|
std::string name = item->d_name;
|
|
if (!valid_path(name) || name.find('/') != std::string::npos)
|
|
continue;
|
|
struct stat st{};
|
|
if (content_stat((path + '/' + name).c_str(), &st) ||
|
|
(!S_ISDIR(st.st_mode) && !S_ISREG(st.st_mode)))
|
|
continue;
|
|
if (entries.size() >= 10000)
|
|
{
|
|
truncated = true;
|
|
break;
|
|
}
|
|
entries.push_back({name, S_ISDIR(st.st_mode), uint64_t(st.st_size)});
|
|
}
|
|
closedir(dir);
|
|
std::sort(entries.begin(), entries.end(), [](const Entry &a, const Entry &b)
|
|
{ return a.folder != b.folder ? a.folder > b.folder : a.name < b.name; });
|
|
std::string out = "{\"path\":" + quote(relative) + ",\"entries\":[";
|
|
for (const auto &e : entries)
|
|
{
|
|
if (out.back() != '[')
|
|
out += ',';
|
|
out += "{\"name\":" + quote(e.name) + ",\"directory\":" + (e.folder ? "true" : "false") +
|
|
",\"size\":" + std::to_string(e.size) + '}';
|
|
}
|
|
return respond(c, 200, out + "],\"truncated\":" + (truncated ? "true" : "false") + '}');
|
|
}
|
|
// A download is read in blocks rather than handed to MHD as a descriptor: MHD sends a
|
|
// descriptor with sendfile, which on the console stopped after its first 33 KB
|
|
// (the daemon, 2026-10-07: curl got 33,300 of 1.9 GB and a broken transfer).
|
|
struct DownloadSource
|
|
{
|
|
int fd;
|
|
uint64_t start;
|
|
};
|
|
ssize_t read_download(void *cls, uint64_t position, char *buffer, size_t size)
|
|
{
|
|
const auto *source = static_cast<DownloadSource *>(cls);
|
|
const ssize_t got =
|
|
pread(source->fd, buffer, size, static_cast<off_t>(source->start + position));
|
|
if (got == 0)
|
|
return MHD_CONTENT_READER_END_OF_STREAM;
|
|
return got < 0 ? MHD_CONTENT_READER_END_WITH_ERROR : got;
|
|
}
|
|
void close_download(void *cls)
|
|
{
|
|
auto *source = static_cast<DownloadSource *>(cls);
|
|
close(source->fd);
|
|
delete source;
|
|
}
|
|
MHD_Result serve_file(MHD_Connection *c, const std::string &path, const char *type,
|
|
bool attachment);
|
|
MHD_Result download(MHD_Connection *c)
|
|
{
|
|
std::string path;
|
|
if (!content_path(arg(c, "path"), path))
|
|
return error(c, 400, "Invalid content path.");
|
|
return serve_file(c, path, "application/octet-stream", true);
|
|
}
|
|
// A file streamed in 1 MiB blocks, one Range honoured: a content download (attachment)
|
|
// or a stored image or video the page shows.
|
|
MHD_Result serve_file(MHD_Connection *c, const std::string &path, const char *type, bool attachment)
|
|
{
|
|
int fd = open(path.c_str(), O_RDONLY | O_NOFOLLOW);
|
|
struct stat st{};
|
|
if (fd < 0)
|
|
return error(c, 404, "This file is not available.");
|
|
if (fstat(fd, &st) || !S_ISREG(st.st_mode))
|
|
{
|
|
close(fd);
|
|
return error(c, 400, "Choose a file to download.");
|
|
}
|
|
// One byte range (Range: bytes=a-b), so a client can fetch a large file on several
|
|
// connections or resume one.
|
|
const uint64_t size = static_cast<uint64_t>(st.st_size);
|
|
uint64_t start = 0, end = size;
|
|
const char *range = MHD_lookup_connection_value(c, MHD_HEADER_KIND, "Range");
|
|
const bool partial = range && ps5_transfer::parse_range(range, size, start, end);
|
|
if (range && !partial && size)
|
|
{
|
|
close(fd);
|
|
return error(c, 416, "This part of the file does not exist.");
|
|
}
|
|
auto *source = new (std::nothrow) DownloadSource{fd, start};
|
|
auto *response = source ? MHD_create_response_from_callback(
|
|
end - start, 1024 * 1024, read_download, source, close_download)
|
|
: nullptr;
|
|
if (!response)
|
|
{
|
|
delete source;
|
|
close(fd);
|
|
return MHD_NO;
|
|
}
|
|
MHD_add_response_header(response, "Accept-Ranges", "bytes");
|
|
if (partial)
|
|
{
|
|
const std::string content_range = "bytes " + std::to_string(start) + '-' +
|
|
std::to_string(end - 1) + '/' + std::to_string(size);
|
|
MHD_add_response_header(response, "Content-Range", content_range.c_str());
|
|
}
|
|
if (attachment)
|
|
{
|
|
const std::string disposition =
|
|
"attachment; filename=" + quote(path.substr(path.find_last_of('/') + 1));
|
|
MHD_add_response_header(response, "Content-Disposition", disposition.c_str());
|
|
}
|
|
MHD_add_response_header(response, "Content-Type", type);
|
|
MHD_add_response_header(response, "X-Content-Type-Options", "nosniff");
|
|
MHD_add_response_header(response, "Cache-Control", attachment ? "no-store" : "no-cache");
|
|
auto result = MHD_queue_response(c, partial ? 206 : 200, response);
|
|
MHD_destroy_response(response);
|
|
return result;
|
|
}
|
|
// Many small files in one request (src/webui_transfer.h, the batch stream): each is
|
|
// written under a temporary name and renamed when whole; one that exists is skipped
|
|
// unless replace. Results are counted, not answered one by one.
|
|
struct BatchUpload final : ps5_transfer::BatchSink
|
|
{
|
|
std::string folder; // below content/, may be empty
|
|
bool replace = false, sync = true;
|
|
ps5_transfer::Writer writer;
|
|
std::string name, temporary, destination;
|
|
uint64_t size = 0;
|
|
unsigned written = 0, skipped = 0;
|
|
uint64_t bytes = 0;
|
|
std::vector<std::pair<std::string, std::string>> failed;
|
|
std::vector<std::string> skipped_names; // the first 1024
|
|
void skip()
|
|
{
|
|
++skipped;
|
|
if (skipped_names.size() < 1024)
|
|
skipped_names.push_back(name);
|
|
}
|
|
bool fail(const char *why)
|
|
{
|
|
if (failed.size() < 64)
|
|
failed.emplace_back(name, why);
|
|
else
|
|
failed.back().second = "and more";
|
|
const int fd = writer.release();
|
|
if (fd >= 0)
|
|
close(fd);
|
|
if (!temporary.empty())
|
|
unlink(temporary.c_str());
|
|
temporary.clear();
|
|
return false;
|
|
}
|
|
bool begin(const std::string &record, uint64_t record_size) override
|
|
{
|
|
name = record;
|
|
size = record_size;
|
|
temporary.clear();
|
|
if (record_size > upload_limit ||
|
|
!content_parents(folder.empty() ? record : folder + '/' + record, destination))
|
|
return fail("invalid name");
|
|
if (!replace && exists(destination))
|
|
{
|
|
skip();
|
|
return false;
|
|
}
|
|
temporary = destination.substr(0, destination.find_last_of('/') + 1) + ".upload-" + nonce();
|
|
return writer.create(temporary) || fail("cannot create");
|
|
}
|
|
bool data(const char *bytes_in, size_t count) override
|
|
{
|
|
return writer.write(bytes_in, count) || fail("cannot write (storage full?)");
|
|
}
|
|
void end() override
|
|
{
|
|
const bool flushed = writer.flush();
|
|
const uint64_t got = writer.written();
|
|
const int fd = writer.release();
|
|
if (!flushed || fd < 0 || got != size)
|
|
{
|
|
if (fd >= 0)
|
|
close(fd);
|
|
fail("incomplete");
|
|
return;
|
|
}
|
|
const auto result = ps5_transfer::commit(fd, temporary, destination, replace, sync);
|
|
close(fd);
|
|
if (result == ps5_transfer::Commit::done)
|
|
{
|
|
temporary.clear();
|
|
++written;
|
|
bytes += size;
|
|
}
|
|
else if (result == ps5_transfer::Commit::exists)
|
|
{
|
|
unlink(temporary.c_str());
|
|
temporary.clear();
|
|
skip();
|
|
}
|
|
else
|
|
fail("cannot finish");
|
|
}
|
|
~BatchUpload() override
|
|
{
|
|
const int fd = writer.release();
|
|
if (fd >= 0)
|
|
close(fd);
|
|
if (!temporary.empty())
|
|
unlink(temporary.c_str());
|
|
}
|
|
};
|
|
struct BatchState
|
|
{
|
|
BatchUpload upload;
|
|
ps5_transfer::BatchReader reader{upload};
|
|
};
|
|
Request::~Request()
|
|
{
|
|
if (counted)
|
|
--transfers_in_flight;
|
|
if (transfer == Transfer::part && session)
|
|
{
|
|
std::lock_guard<std::mutex> guard(session->lock);
|
|
if (--session->writers == 0 && session->closing && session->fd >= 0)
|
|
{
|
|
close(session->fd);
|
|
session->fd = -1;
|
|
}
|
|
}
|
|
if (!temporary.empty())
|
|
{
|
|
unlink(temporary.c_str());
|
|
/* A helper's upload cut short: the file is handed out again at once. */
|
|
if (transfer == Transfer::scraped)
|
|
ps5_scraper::pc_delivered(job, task, false, 0);
|
|
}
|
|
}
|
|
bool content_length(MHD_Connection *c, uint64_t &length)
|
|
{
|
|
const char *value = MHD_lookup_connection_value(c, MHD_HEADER_KIND, "Content-Length");
|
|
char *end = nullptr;
|
|
errno = 0;
|
|
if (!value || !*value || *value == '-')
|
|
return false;
|
|
length = std::strtoull(value, &end, 10);
|
|
return !errno && !*end;
|
|
}
|
|
// File actions are bound to an exact library entry. Paths are never a general
|
|
// filesystem browser: only content storage and configured save locations qualify.
|
|
std::string game_real_path(std::string path)
|
|
{
|
|
// RetroArch serializes paths relative to its application directory as :/.
|
|
if (path.rfind(":/", 0) == 0)
|
|
path = root_path + path.substr(1);
|
|
if (path.rfind("/app0/", 0) == 0)
|
|
path = root_path + path.substr(5);
|
|
return path;
|
|
}
|
|
bool game_safe_path(const std::string &path, bool create_parents = false, bool new_leaf = false)
|
|
{
|
|
std::string base;
|
|
if (path.rfind(root_path + '/', 0) == 0)
|
|
base = root_path;
|
|
for (const char *mount : {"/mnt/usb0", "/mnt/usb1", "/mnt/usb2", "/mnt/usb3", "/mnt/usb4",
|
|
"/mnt/usb5", "/mnt/usb6", "/mnt/usb7", "/mnt/ext0", "/mnt/ext1"})
|
|
if (path.rfind(std::string(mount) + '/', 0) == 0)
|
|
base = mount;
|
|
if (base.empty() || !valid_path(path.substr(base.size() + 1)))
|
|
return false;
|
|
struct stat st{};
|
|
if (content_stat(base.c_str(), &st) || !S_ISDIR(st.st_mode))
|
|
return false;
|
|
for (size_t at = base.size() + 1; at < path.size();)
|
|
{
|
|
const auto end = path.find('/', at);
|
|
const bool leaf = end == std::string::npos;
|
|
const auto part = path.substr(0, end);
|
|
if (content_stat(part.c_str(), &st))
|
|
{
|
|
if (errno != ENOENT)
|
|
return false;
|
|
if (leaf && new_leaf)
|
|
return true;
|
|
if (!create_parents || leaf || (mkdir(part.c_str(), 0777) && errno != EEXIST) ||
|
|
content_stat(part.c_str(), &st))
|
|
return false;
|
|
}
|
|
if (leaf)
|
|
return S_ISREG(st.st_mode);
|
|
if (!S_ISDIR(st.st_mode))
|
|
return false;
|
|
at = end + 1;
|
|
}
|
|
return false;
|
|
}
|
|
std::string leaf_name(const std::string &path)
|
|
{
|
|
return path.substr(path.find_last_of('/') + 1);
|
|
}
|
|
struct GameFiles
|
|
{
|
|
std::string rom, stem, core, save_root, state_root, save_folder, state_folder, why;
|
|
std::vector<std::string> saves, states;
|
|
};
|
|
bool game_save_name(const std::string &name, const std::string &stem, bool state)
|
|
{
|
|
if (name.rfind(stem + '.', 0) != 0)
|
|
return false;
|
|
const std::string suffix = name.substr(stem.size());
|
|
if (!state)
|
|
return suffix == ".srm" || suffix == ".sav" || suffix == ".dsv" || suffix == ".rtc" ||
|
|
suffix == ".eep" || suffix == ".fla" || suffix == ".sra" || suffix == ".mpk";
|
|
if (suffix == ".state" || suffix == ".state.auto")
|
|
return true;
|
|
return suffix.rfind(".state", 0) == 0 && suffix.size() > 6 && suffix.size() <= 12 &&
|
|
suffix.find_first_not_of("0123456789", 6) == std::string::npos;
|
|
}
|
|
void scan_game_saves(const std::string &folder, const std::string &stem, bool state,
|
|
std::vector<std::string> &out, unsigned depth, unsigned &budget)
|
|
{
|
|
// Bounded traversal; no links, firmware, config files or unrelated basenames.
|
|
if (!depth || !budget || !game_safe_path(folder + "/probe", false, true))
|
|
return;
|
|
DIR *dir = opendir(folder.c_str());
|
|
if (!dir)
|
|
return;
|
|
while (auto *entry = readdir(dir))
|
|
{
|
|
if (!budget)
|
|
break;
|
|
--budget;
|
|
const std::string name = entry->d_name, path = folder + '/' + name;
|
|
if (name.empty() || name.front() == '.')
|
|
continue;
|
|
struct stat st{};
|
|
if (content_stat(path.c_str(), &st))
|
|
continue;
|
|
if (S_ISDIR(st.st_mode))
|
|
scan_game_saves(path, stem, state, out, depth - 1, budget);
|
|
else if (S_ISREG(st.st_mode) && game_save_name(name, stem, state))
|
|
out.push_back(path);
|
|
}
|
|
closedir(dir);
|
|
}
|
|
bool game_files(MHD_Connection *c, GameFiles &out)
|
|
{
|
|
std::string core_path;
|
|
const std::string path = arg(c, "path");
|
|
if (!ps5_scraper::game_identity(arg(c, "system"), arg(c, "game"), path, core_path))
|
|
{
|
|
out.why = "This exact game is no longer in the library. Refresh Games.";
|
|
return false;
|
|
}
|
|
out.rom = game_real_path(path.substr(0, path.find('#')));
|
|
const bool content = out.rom.rfind(root_path + "/content/", 0) == 0 ||
|
|
out.rom.rfind("/mnt/usb", 0) == 0 || out.rom.rfind("/mnt/ext", 0) == 0;
|
|
if (!content || !game_safe_path(out.rom))
|
|
{
|
|
out.why = "This game backup is unavailable or outside supported storage.";
|
|
return false;
|
|
}
|
|
auto name =
|
|
leaf_name(path.substr(path.find('#') == std::string::npos ? 0 : path.find('#') + 1));
|
|
out.stem = name.substr(0, name.find_last_of('.'));
|
|
auto core_file = leaf_name(core_path);
|
|
const auto dot = core_file.find_last_of('.');
|
|
if (dot != std::string::npos)
|
|
core_file.resize(dot);
|
|
out.core = read_config(root_path + "/info/" + core_file + ".info")["corename"];
|
|
Config cfg = read_config(root_path + "/config/retroarch.cfg");
|
|
const auto content_folder = out.rom.substr(0, out.rom.find_last_of('/'));
|
|
const auto content_name = leaf_name(content_folder);
|
|
if (!out.core.empty() && valid_path(out.core) && out.core.find('/') == std::string::npos)
|
|
{
|
|
const auto prefix = root_path + "/config/" + out.core + '/';
|
|
overlay(cfg, read_config(prefix + out.core + ".cfg"));
|
|
overlay(cfg, read_config(prefix + content_name + ".cfg"));
|
|
overlay(cfg, read_config(prefix + out.stem + ".cfg"));
|
|
}
|
|
for (bool state : {false, true})
|
|
{
|
|
const std::string plural = state ? "savestates" : "savefiles";
|
|
const std::string key = state ? "savestate_directory" : "savefile_directory";
|
|
std::string base = cfg[key];
|
|
if (base.empty() || base == "default")
|
|
base = root_path + '/' + plural;
|
|
base = game_real_path(base);
|
|
while (!base.empty() && base.back() == '/')
|
|
base.pop_back();
|
|
std::string folder = base;
|
|
if (cfg[plural + "_in_content_dir"] == "true")
|
|
folder = base = content_folder;
|
|
else
|
|
{
|
|
if (cfg["sort_" + plural + "_by_content_enable"] == "true")
|
|
folder += '/' + content_name;
|
|
if (cfg["sort_" + plural + "_enable"] != "false")
|
|
{
|
|
if (out.core.empty())
|
|
folder.clear(); // Do not invent a core folder for a new save.
|
|
else
|
|
folder += '/' + out.core;
|
|
}
|
|
}
|
|
(state ? out.state_root : out.save_root) = base;
|
|
(state ? out.state_folder : out.save_folder) = folder;
|
|
unsigned budget = 10000;
|
|
auto &files = state ? out.states : out.saves;
|
|
scan_game_saves(base, out.stem, state, files, base == content_folder ? 1 : 5, budget);
|
|
std::sort(files.begin(), files.end());
|
|
if (!budget)
|
|
out.why =
|
|
"Some save folders could not be scanned. Check the selected path before importing.";
|
|
}
|
|
return true;
|
|
}
|
|
std::string game_files_json(const GameFiles &g)
|
|
{
|
|
auto entry = [&](const std::string &path)
|
|
{
|
|
struct stat st{};
|
|
content_stat(path.c_str(), &st);
|
|
const std::string shown =
|
|
path.rfind(root_path + '/', 0) == 0 ? "/app0" + path.substr(root_path.size()) : path;
|
|
return "{\"path\":" + quote(path) + ",\"name\":" + quote(leaf_name(path)) +
|
|
",\"display\":" + quote(shown) + ",\"bytes\":" + std::to_string(st.st_size) +
|
|
",\"preview\":" + (game_safe_path(path + ".png") ? "true" : "false") + '}';
|
|
};
|
|
auto list = [&](const std::vector<std::string> &files)
|
|
{
|
|
std::string out = "[";
|
|
for (const auto &file : files)
|
|
out += (out.size() > 1 ? "," : "") + entry(file);
|
|
return out + ']';
|
|
};
|
|
return "{\"rom\":" + entry(g.rom) + ",\"save\":" + list(g.saves) +
|
|
",\"state\":" + list(g.states) + ",\"saveFolder\":" + quote(g.save_folder) +
|
|
",\"stateFolder\":" + quote(g.state_folder) + ",\"stem\":" + quote(g.stem) +
|
|
",\"core\":" + quote(g.core) + ",\"warning\":" + quote(g.why) + '}';
|
|
}
|
|
bool game_file_target(MHD_Connection *c, const GameFiles &g, bool writing, std::string &target)
|
|
{
|
|
const std::string kind = arg(c, "kind"), selected = arg(c, "file");
|
|
if (kind == "rom")
|
|
target = g.rom;
|
|
else if (kind == "save" || kind == "state")
|
|
{
|
|
const auto &files = kind == "state" ? g.states : g.saves;
|
|
if (std::find(files.begin(), files.end(), selected) != files.end())
|
|
target = selected;
|
|
else if (writing)
|
|
{
|
|
const auto &folder = kind == "state" ? g.state_folder : g.save_folder;
|
|
if (folder.empty() || !game_save_name(selected, g.stem, kind == "state"))
|
|
return false;
|
|
target = folder + '/' + selected;
|
|
}
|
|
else
|
|
return false;
|
|
}
|
|
else
|
|
return false;
|
|
return game_safe_path(target, writing, writing);
|
|
}
|
|
void prepare_game_add(MHD_Connection *c, Request &r)
|
|
{
|
|
std::string relative;
|
|
r.media_system = arg(c, "system");
|
|
if (!ps5_scraper::add_target(r.media_system, arg(c, "filename"), relative, r.media_game,
|
|
r.media_why))
|
|
{
|
|
r.error = 400;
|
|
r.message = r.media_why.c_str();
|
|
return;
|
|
}
|
|
if (!content_parents(relative, r.destination) || !game_safe_path(r.destination, false, true))
|
|
{
|
|
r.error = 400;
|
|
r.message = "Choose a safe game location.";
|
|
return;
|
|
}
|
|
if (exists(r.destination))
|
|
{
|
|
r.error = 409;
|
|
r.message = "This backup already exists. Open the game to replace it.";
|
|
return;
|
|
}
|
|
if (!content_length(c, r.expected) || !r.expected || r.expected > upload_limit)
|
|
{
|
|
r.error = 413;
|
|
r.message = "Choose a non-empty game backup of at most 64 GiB.";
|
|
return;
|
|
}
|
|
r.transfer = Transfer::game_add;
|
|
r.temporary =
|
|
r.destination.substr(0, r.destination.find_last_of('/') + 1) + ".upload-" + nonce();
|
|
if (!r.writer.create(r.temporary))
|
|
{
|
|
r.error = 507;
|
|
r.message = "Cannot store this game. Check free space.";
|
|
}
|
|
}
|
|
void prepare_game_file(MHD_Connection *c, Request &r)
|
|
{
|
|
GameFiles game;
|
|
if (!game_files(c, game) || !game_file_target(c, game, true, r.destination))
|
|
{
|
|
r.error = 400;
|
|
r.media_why =
|
|
game.why.empty() ? "Choose a save file or slot belonging to this game." : game.why;
|
|
r.message = r.media_why.c_str();
|
|
return;
|
|
}
|
|
if (!content_length(c, r.expected) || !r.expected || r.expected > upload_limit)
|
|
{
|
|
r.error = 413;
|
|
r.message = "Choose a non-empty file of at most 64 GiB.";
|
|
return;
|
|
}
|
|
r.replace = std::strcmp(arg(c, "existing"), "replace") == 0;
|
|
if (exists(r.destination) && !r.replace)
|
|
{
|
|
r.error = 409;
|
|
r.message = "This file exists. Confirm replacement first.";
|
|
return;
|
|
}
|
|
r.transfer = Transfer::game_file;
|
|
r.temporary =
|
|
r.destination.substr(0, r.destination.find_last_of('/') + 1) + ".upload-" + nonce();
|
|
if (!r.writer.create(r.temporary))
|
|
{
|
|
r.error = 507;
|
|
r.message = "Cannot create the upload. Check storage and write access.";
|
|
}
|
|
}
|
|
|
|
void prepare_upload(MHD_Connection *c, Request &r)
|
|
{
|
|
auto fail = [&](unsigned code, const char *message)
|
|
{
|
|
r.error = code;
|
|
r.message = message;
|
|
};
|
|
const std::string relative = arg(c, "path");
|
|
if (relative.empty() || !content_path(relative, r.destination, true))
|
|
{
|
|
fail(400, "Choose a valid content folder and filename.");
|
|
return;
|
|
}
|
|
r.transfer = Transfer::single;
|
|
r.replace = std::strcmp(arg(c, "existing"), "replace") == 0;
|
|
if (!r.replace && exists(r.destination))
|
|
{
|
|
fail(409, "A file with this name already exists. Rename your file first.");
|
|
return;
|
|
}
|
|
const char *length = MHD_lookup_connection_value(c, MHD_HEADER_KIND, "Content-Length");
|
|
char *end = nullptr;
|
|
errno = 0;
|
|
if (!length || !*length || *length == '-')
|
|
{
|
|
fail(411, "A file size is required.");
|
|
return;
|
|
}
|
|
r.expected = std::strtoull(length, &end, 10);
|
|
if (errno || *end || r.expected > upload_limit)
|
|
{
|
|
fail(413, "Files must be 64 GiB or smaller.");
|
|
return;
|
|
}
|
|
struct statvfs storage{};
|
|
if (storage_space(storage) && r.expected > uint64_t(storage.f_bavail) * storage.f_frsize)
|
|
{
|
|
fail(507, "There is not enough free space on the console.");
|
|
return;
|
|
}
|
|
r.temporary =
|
|
r.destination.substr(0, r.destination.find_last_of('/') + 1) + ".upload-" + nonce();
|
|
if (!r.writer.create(r.temporary))
|
|
{
|
|
r.temporary.clear();
|
|
fail(507, "Could not create the upload. Check console storage.");
|
|
}
|
|
}
|
|
void prepare_batch(MHD_Connection *c, Request &r)
|
|
{
|
|
r.transfer = Transfer::batch;
|
|
if (!content_length(c, r.expected) || r.expected > upload_limit)
|
|
{
|
|
r.error = 411;
|
|
r.message = "A batch needs its size, at most 64 GiB.";
|
|
return;
|
|
}
|
|
auto state = std::make_unique<BatchState>();
|
|
state->upload.folder = arg(c, "folder");
|
|
state->upload.replace = std::strcmp(arg(c, "existing"), "replace") == 0;
|
|
state->upload.sync = std::strcmp(arg(c, "sync"), "0") != 0;
|
|
if (!state->upload.folder.empty() && !valid_path(state->upload.folder))
|
|
{
|
|
r.error = 400;
|
|
r.message = "Choose a valid content folder.";
|
|
return;
|
|
}
|
|
r.batch = std::move(state);
|
|
}
|
|
void prepare_part(MHD_Connection *c, Request &r)
|
|
{
|
|
r.transfer = Transfer::part;
|
|
r.session = sessions.find(arg(c, "id"));
|
|
char *end = nullptr;
|
|
const char *offset = arg(c, "offset");
|
|
errno = 0;
|
|
r.offset = std::strtoull(offset, &end, 10);
|
|
if (!r.session)
|
|
{
|
|
r.error = 404;
|
|
r.message = "This upload is no longer open. Start it again.";
|
|
return;
|
|
}
|
|
if (!*offset || errno || *end || !content_length(c, r.expected) || r.offset > r.session->size ||
|
|
r.expected > r.session->size - r.offset)
|
|
{
|
|
r.session.reset();
|
|
r.error = 416;
|
|
r.message = "This part does not fit the file.";
|
|
return;
|
|
}
|
|
std::lock_guard<std::mutex> guard(r.session->lock);
|
|
if (r.session->closing || r.session->fd < 0)
|
|
{
|
|
r.session.reset();
|
|
r.error = 409;
|
|
r.message = "This upload was closed.";
|
|
return;
|
|
}
|
|
++r.session->writers;
|
|
r.session->touched = std::time(nullptr);
|
|
r.writer.attach(r.session->fd, r.offset);
|
|
}
|
|
std::string names(const std::vector<std::string> &list)
|
|
{
|
|
std::string out = "[";
|
|
for (const auto &name : list)
|
|
out += (out.size() > 1 ? "," : "") + quote(name);
|
|
return out + ']';
|
|
}
|
|
// A media file from the PC helper (src/scraper.h, PC mode): written under a hidden
|
|
// name in the shared library and renamed into place when whole.
|
|
void prepare_scraped(MHD_Connection *c, Request &r)
|
|
{
|
|
r.transfer = Transfer::scraped;
|
|
r.job = arg(c, "id");
|
|
r.task = unsigned(std::strtoul(arg(c, "task"), nullptr, 10));
|
|
if (!content_length(c, r.expected) || r.expected > (uint64_t(256) << 20))
|
|
{
|
|
r.error = 413;
|
|
r.message = "A media file is at most 256 MiB.";
|
|
return;
|
|
}
|
|
if (!ps5_scraper::pc_target(r.job, r.task, r.temporary, r.destination) ||
|
|
!r.writer.create(r.temporary))
|
|
{
|
|
r.temporary.clear();
|
|
r.error = 409;
|
|
r.message = "This download is no longer wanted.";
|
|
}
|
|
}
|
|
// A media file the user uploads for one game (src/scraper.h): written under a hidden
|
|
// name beside its final one and renamed into place when whole.
|
|
void prepare_media(MHD_Connection *c, Request &r)
|
|
{
|
|
r.transfer = Transfer::media;
|
|
r.media_system = arg(c, "system");
|
|
r.media_game = arg(c, "game");
|
|
r.media_kind = arg(c, "kind");
|
|
std::string type = arg(c, "type"), why;
|
|
for (char &ch : type)
|
|
ch = char(std::tolower((unsigned char)ch));
|
|
if (!content_length(c, r.expected) || r.expected == 0 || r.expected > (uint64_t(1) << 30))
|
|
{
|
|
r.error = 413;
|
|
r.message = "A media file is at most 1 GiB.";
|
|
return;
|
|
}
|
|
if (!ps5_scraper::upload_target(r.media_system, r.media_game, r.media_kind, type, r.temporary,
|
|
r.destination, why) ||
|
|
!r.writer.create(r.temporary))
|
|
{
|
|
r.temporary.clear();
|
|
r.error = 415;
|
|
r.media_why = why.empty() ? "The console could not store this file." : why;
|
|
r.message = r.media_why.c_str();
|
|
}
|
|
}
|
|
std::string query(MHD_Connection *c, const char *key, const char *fallback)
|
|
{
|
|
const char *value = arg(c, key);
|
|
return *value ? value : fallback;
|
|
}
|
|
// The scraper's routes (src/scraper.h): they keep their own locks.
|
|
MHD_Result scraper_route(MHD_Connection *c, const std::string &url, const std::string &method,
|
|
Request &r)
|
|
{
|
|
std::string why, id;
|
|
auto options = [&]
|
|
{
|
|
ps5_scraper::Options o;
|
|
o.mode = query(c, "mode", "");
|
|
o.source = query(c, "source", "libretro");
|
|
o.region = query(c, "region", "us");
|
|
o.language = query(c, "language", "en");
|
|
o.overwrite = std::strcmp(arg(c, "overwrite"), "1") == 0;
|
|
o.details = std::strcmp(arg(c, "details"), "1") == 0;
|
|
// The sources in order ("libretro,screenscraper"): each asked for what is still missing.
|
|
const std::string sources = arg(c, "sources");
|
|
for (size_t at = 0; at < sources.size();)
|
|
{
|
|
size_t end = sources.find(',', at);
|
|
if (end == std::string::npos)
|
|
end = sources.size();
|
|
if (end > at)
|
|
o.sources.push_back(sources.substr(at, end - at));
|
|
at = end + 1;
|
|
}
|
|
if (!o.sources.empty())
|
|
o.source = o.sources.front();
|
|
const std::string kinds = arg(c, "kinds");
|
|
size_t start = 0;
|
|
while (start < kinds.size())
|
|
{
|
|
size_t end = kinds.find(',', start);
|
|
if (end == std::string::npos)
|
|
end = kinds.size();
|
|
o.kinds.push_back(kinds.substr(start, end - start));
|
|
start = end + 1;
|
|
}
|
|
return o;
|
|
};
|
|
if (method == "GET" && url == "/api/library/add-options")
|
|
return respond(c, 200, ps5_scraper::add_systems_json());
|
|
if (method == "POST" && url == "/api/library/launchbox")
|
|
{
|
|
const auto result = ps5_scraper::launchbox_lookup(arg(c, "system"), arg(c, "q"), why);
|
|
return result.empty() ? error(c, 400, why.c_str()) : respond(c, 200, result);
|
|
}
|
|
if (method == "PUT" && url == "/api/library/add")
|
|
{
|
|
if (r.transfer != Transfer::game_add || r.received != r.expected || !r.writer.flush())
|
|
return error(c, 400, "The upload was incomplete. Try again.");
|
|
const int fd = r.writer.release();
|
|
const auto result = ps5_transfer::commit(fd, r.temporary, r.destination, false, true);
|
|
if (fd >= 0)
|
|
close(fd);
|
|
if (result != ps5_transfer::Commit::done)
|
|
return error(c, result == ps5_transfer::Commit::exists ? 409 : 507,
|
|
"Could not add this game. No existing backup was replaced.");
|
|
chmod(r.destination.c_str(), 0777);
|
|
r.temporary.clear();
|
|
const auto path = "/app0" + r.destination.substr(root_path.size());
|
|
std::string core;
|
|
if (!ps5_scraper::game_identity(r.media_system, r.media_game, path, core))
|
|
{
|
|
unlink(r.destination.c_str());
|
|
return error(c, 415,
|
|
"This file cannot be listed as a game. For disc sets or folder-based "
|
|
"games, use Content to upload the complete folder.");
|
|
}
|
|
return respond(c, 201,
|
|
"{\"system\":" + quote(r.media_system) + ",\"key\":" + quote(r.media_game) +
|
|
",\"path\":" + quote(path) + "}");
|
|
}
|
|
if (url == "/api/library/files" || url == "/api/library/file")
|
|
{
|
|
GameFiles game;
|
|
if (!game_files(c, game))
|
|
return error(c, 404, game.why.c_str());
|
|
if (method == "GET" && url == "/api/library/files")
|
|
return respond(c, 200, game_files_json(game));
|
|
std::string target;
|
|
if (!game_file_target(c, game, method == "PUT", target))
|
|
return error(c, 400, "Choose a file belonging to this game.");
|
|
if (method == "GET")
|
|
{
|
|
if (std::strcmp(arg(c, "preview"), "1") == 0)
|
|
{
|
|
if (std::strcmp(arg(c, "kind"), "state") != 0 || !game_safe_path(target + ".png"))
|
|
return error(c, 404, "No screenshot was saved for this slot.");
|
|
return serve_file(c, target + ".png", "image/png", false);
|
|
}
|
|
return serve_file(c, target, "application/octet-stream", true);
|
|
}
|
|
if (r.transfer != Transfer::game_file || target != r.destination)
|
|
return error(c, 409, "The game location changed. Refresh and try again.");
|
|
if (r.received != r.expected || !r.writer.flush())
|
|
return error(c, 400, "The upload was incomplete; the original file was kept.");
|
|
const int fd = r.writer.release();
|
|
const auto result = ps5_transfer::commit(fd, r.temporary, target, r.replace, true);
|
|
if (fd >= 0)
|
|
close(fd);
|
|
if (result != ps5_transfer::Commit::done)
|
|
return error(c, result == ps5_transfer::Commit::exists ? 409 : 507,
|
|
"Could not finish the upload; the original file was kept.");
|
|
chmod(target.c_str(), 0777);
|
|
// A screenshot belongs to the old state; never show it for an imported one.
|
|
if (std::strcmp(arg(c, "kind"), "state") == 0 && game_safe_path(target + ".png"))
|
|
unlink((target + ".png").c_str());
|
|
r.temporary.clear();
|
|
return respond(c, 201, "{\"stored\":true}");
|
|
}
|
|
if (method == "POST" && url == "/api/library/game")
|
|
return ps5_scraper::edit_game(arg(c, "system"), arg(c, "game"), r.body, why)
|
|
? respond(c, 200, ps5_scraper::game_json(arg(c, "system"), arg(c, "game")))
|
|
: error(c, 400, why.c_str());
|
|
if (method == "GET" && url == "/api/library")
|
|
return respond(c, 200, ps5_scraper::library_json());
|
|
if (method == "GET" && url == "/api/library/game")
|
|
{
|
|
const std::string game = ps5_scraper::game_json(arg(c, "system"), arg(c, "game"));
|
|
return game.empty() ? error(c, 404, "This game is not in the library.")
|
|
: respond(c, 200, game);
|
|
}
|
|
if (r.transfer == Transfer::media)
|
|
{
|
|
const bool flushed = r.writer.flush();
|
|
const uint64_t got = r.writer.written();
|
|
const int fd = r.writer.release();
|
|
bool ok = flushed && fd >= 0 && got == r.expected && r.received == r.expected;
|
|
if (ok)
|
|
ok = ps5_transfer::commit(fd, r.temporary, r.destination, true, true) ==
|
|
ps5_transfer::Commit::done;
|
|
if (fd >= 0)
|
|
close(fd);
|
|
if (!ok)
|
|
return error(c, 507, "The file could not be stored. Check free space and try again.");
|
|
chmod(r.destination.c_str(), 0777);
|
|
r.temporary.clear();
|
|
ps5_scraper::uploaded(r.media_system, r.media_game, r.media_kind, r.destination);
|
|
return respond(c, 201, ps5_scraper::game_json(r.media_system, r.media_game));
|
|
}
|
|
if (method == "GET" && url == "/api/library/media")
|
|
{
|
|
const std::string file =
|
|
ps5_scraper::media_file(arg(c, "system"), arg(c, "game"), arg(c, "kind"));
|
|
if (file.empty())
|
|
return error(c, 404, "No such media.");
|
|
const std::string ext = file.substr(file.find_last_of('.') + 1);
|
|
const char *type = ext == "png" ? "image/png"
|
|
: ext == "jpg" || ext == "jpeg" ? "image/jpeg"
|
|
: ext == "webp" ? "image/webp"
|
|
: ext == "gif" ? "image/gif"
|
|
: ext == "mp4" ? "video/mp4"
|
|
: ext == "webm" ? "video/webm"
|
|
: ext == "pdf" ? "application/pdf"
|
|
: "application/octet-stream";
|
|
return serve_file(c, file, type, false);
|
|
}
|
|
if (method == "GET" && url == "/api/scraper/settings")
|
|
return respond(c, 200, ps5_scraper::settings_json());
|
|
if (method == "POST" && url == "/api/scraper/settings")
|
|
return ps5_scraper::save_settings(options())
|
|
? respond(c, 200, ps5_scraper::settings_json())
|
|
: error(c, 500, "The settings could not be saved.");
|
|
// A source's sign-in: the name and password come in the body (never the URL, so no
|
|
// log or history holds them); the answers never carry the password.
|
|
if (method == "GET" && url == "/api/scraper/account")
|
|
return respond(c, 200, ps5_scraper::account_json(query(c, "source", "screenscraper")));
|
|
if (method == "POST" && url == "/api/scraper/account")
|
|
{
|
|
const size_t line = r.body.find('\n');
|
|
const std::string user = r.body.substr(0, line);
|
|
const std::string password = line == std::string::npos ? "" : r.body.substr(line + 1);
|
|
const std::string source = query(c, "source", "screenscraper");
|
|
if (!ps5_scraper::sign_in(source, user, password, why))
|
|
return error(c, 403, why.c_str());
|
|
return respond(c, 200, ps5_scraper::account_json(source));
|
|
}
|
|
if (method == "DELETE" && url == "/api/scraper/account")
|
|
{
|
|
const std::string source = query(c, "source", "screenscraper");
|
|
ps5_scraper::sign_out(source);
|
|
return respond(c, 200, ps5_scraper::account_json(source));
|
|
}
|
|
if (method == "GET" && url == "/api/scraper/recap")
|
|
return respond(c, 200, ps5_scraper::recap_json(arg(c, "id")));
|
|
if (method == "GET" && url == "/api/scraper/job")
|
|
return respond(c, 200, ps5_scraper::job_json(arg(c, "id")));
|
|
if (method == "POST" && url == "/api/scraper/start")
|
|
{
|
|
// The body: one "system<TAB>path" a line, path empty for a whole system.
|
|
std::vector<ps5_scraper::Selection> selection;
|
|
size_t start = 0;
|
|
while (start < r.body.size())
|
|
{
|
|
size_t end = r.body.find('\n', start);
|
|
if (end == std::string::npos)
|
|
end = r.body.size();
|
|
const std::string line = r.body.substr(start, end - start);
|
|
const size_t tab = line.find('\t');
|
|
if (!line.empty())
|
|
selection.push_back(
|
|
{line.substr(0, tab), tab == std::string::npos ? "" : line.substr(tab + 1)});
|
|
start = end + 1;
|
|
}
|
|
if (!ps5_scraper::start(options(), selection, id, why))
|
|
return error(c, 409, why.c_str());
|
|
return respond(c, 201, ps5_scraper::job_json(id));
|
|
}
|
|
if (method == "POST" && url == "/api/scraper/cancel")
|
|
return respond(c, 200,
|
|
std::string("{\"cancelled\":") +
|
|
(ps5_scraper::cancel(arg(c, "id")) ? "true" : "false") + '}');
|
|
if (method == "POST" && url == "/api/scraper/resume")
|
|
return ps5_scraper::resume(arg(c, "id"), why)
|
|
? respond(c, 200, ps5_scraper::job_json(arg(c, "id")))
|
|
: error(c, 409, why.c_str());
|
|
if (method == "POST" && url == "/api/scraper/resolve")
|
|
return ps5_scraper::resolve(arg(c, "id"),
|
|
unsigned(std::strtoul(arg(c, "item"), nullptr, 10)),
|
|
arg(c, "action"), arg(c, "value"), why)
|
|
? respond(c, 200, ps5_scraper::job_json(arg(c, "id")))
|
|
: error(c, 409, why.c_str());
|
|
if (method == "GET" && url == "/api/scraper/pc/tasks")
|
|
return respond(
|
|
c, 200,
|
|
ps5_scraper::pc_tasks(arg(c, "id"),
|
|
unsigned(std::strtoul(query(c, "max", "8").c_str(), nullptr, 10)),
|
|
std::strcmp(arg(c, "fresh"), "1") == 0));
|
|
if (method == "POST" && url == "/api/scraper/pc/downloaded")
|
|
{
|
|
ps5_scraper::pc_downloaded(
|
|
arg(c, "id"), unsigned(std::strtoul(arg(c, "task"), nullptr, 10)),
|
|
std::strcmp(arg(c, "found"), "0") != 0, std::strtoull(arg(c, "bytes"), nullptr, 10));
|
|
return respond(c, 200, "{\"noted\":true}");
|
|
}
|
|
if (r.transfer == Transfer::scraped)
|
|
{
|
|
const bool flushed = r.writer.flush();
|
|
const uint64_t got = r.writer.written();
|
|
const int fd = r.writer.release();
|
|
bool ok = flushed && fd >= 0 && got == r.expected && r.received == r.expected;
|
|
if (ok)
|
|
ok = ps5_transfer::commit(fd, r.temporary, r.destination, true, true) ==
|
|
ps5_transfer::Commit::done;
|
|
if (fd >= 0)
|
|
close(fd);
|
|
if (ok)
|
|
{
|
|
chmod(r.destination.c_str(), 0777);
|
|
r.temporary.clear();
|
|
}
|
|
ps5_scraper::pc_delivered(r.job, r.task, ok, got);
|
|
return ok ? respond(c, 201, "{\"stored\":true}")
|
|
: error(c, 507, "The media file could not be stored.");
|
|
}
|
|
return error(c, 404, "This page was not found.");
|
|
}
|
|
// The transfers' own routes: they run beside each other, outside state_lock.
|
|
MHD_Result transfer_route(MHD_Connection *c, const std::string &url, const std::string &method,
|
|
Request &r)
|
|
{
|
|
if (r.transfer == Transfer::single)
|
|
{
|
|
if (r.received != r.expected)
|
|
return error(c, 400, "The upload was incomplete. Try again.");
|
|
const int fd = r.writer.release();
|
|
if (fd < 0)
|
|
return error(c, 507, "Could not finish writing the file. Check console storage.");
|
|
const auto result = ps5_transfer::commit(fd, r.temporary, r.destination, r.replace, true);
|
|
close(fd);
|
|
if (result == ps5_transfer::Commit::exists)
|
|
return error(c, 409, "A file with this name already exists. Rename your file first.");
|
|
if (result != ps5_transfer::Commit::done)
|
|
return error(c, 500, "Could not finish the upload.");
|
|
r.temporary.clear();
|
|
return respond(c, 201, "{\"uploaded\":true,\"bytes\":" + std::to_string(r.received) + '}');
|
|
}
|
|
if (r.transfer == Transfer::batch)
|
|
{
|
|
auto *state = r.batch.get();
|
|
auto &b = state->upload;
|
|
std::string failed = "[";
|
|
for (const auto &f : b.failed)
|
|
failed += (failed.size() > 1 ? "," : "") + std::string("{\"name\":") + quote(f.first) +
|
|
",\"error\":" + quote(f.second) + '}';
|
|
const bool whole = state->reader.finished() && r.received == r.expected;
|
|
return respond(c, whole ? 200 : 400,
|
|
"{\"complete\":" + std::string(whole ? "true" : "false") + ",\"written\":" +
|
|
std::to_string(b.written) + ",\"skipped\":" + std::to_string(b.skipped) +
|
|
",\"skippedNames\":" + names(b.skipped_names) + ",\"bytes\":" +
|
|
std::to_string(b.bytes) + ",\"failed\":" + failed + "],\"problem\":" +
|
|
quote(whole ? ""
|
|
: *state->reader.problem() ? state->reader.problem()
|
|
: "The batch ended early.") +
|
|
'}');
|
|
}
|
|
if (r.transfer == Transfer::part)
|
|
{
|
|
if (r.received != r.expected || !r.writer.flush())
|
|
return error(c, 507, "The part could not be written. Try it again.");
|
|
uint64_t covered;
|
|
{
|
|
// Released here, not when the request is freed after its answer: a commit
|
|
// sent the moment this answer arrives must find no writer left.
|
|
std::lock_guard<std::mutex> guard(r.session->lock);
|
|
r.session->add(r.offset, r.offset + r.received);
|
|
r.session->touched = std::time(nullptr);
|
|
covered = r.session->covered;
|
|
if (--r.session->writers == 0 && r.session->closing && r.session->fd >= 0)
|
|
{
|
|
close(r.session->fd);
|
|
r.session->fd = -1;
|
|
}
|
|
}
|
|
r.session.reset();
|
|
return respond(c, 200, "{\"covered\":" + std::to_string(covered) + '}');
|
|
}
|
|
if (method == "POST" && url == "/api/upload/session")
|
|
{
|
|
sessions.expire(std::time(nullptr), session_idle_seconds);
|
|
std::string destination;
|
|
char *end = nullptr;
|
|
const char *text = arg(c, "size");
|
|
errno = 0;
|
|
const uint64_t size = std::strtoull(text, &end, 10);
|
|
const bool size_ok = *text && !errno && !*end && size <= upload_limit;
|
|
const bool replace = std::strcmp(arg(c, "existing"), "replace") == 0;
|
|
if (!size_ok)
|
|
return error(c, 413, "Files must be 64 GiB or smaller.");
|
|
if (!content_parents(arg(c, "path"), destination))
|
|
return error(c, 400, "Choose a valid content folder and filename.");
|
|
if (!replace && exists(destination))
|
|
return error(c, 409, "A file with this name already exists. Rename your file first.");
|
|
struct statvfs storage{};
|
|
if (storage_space(storage) && size > uint64_t(storage.f_bavail) * storage.f_frsize)
|
|
return error(c, 507, "There is not enough free space on the console.");
|
|
const std::string id = nonce();
|
|
const char *why = "";
|
|
auto session = sessions.open(
|
|
id, destination.substr(0, destination.find_last_of('/') + 1) + ".upload-" + id,
|
|
destination, size, replace, why);
|
|
if (!session)
|
|
return error(c, 507, why);
|
|
return respond(c, 201,
|
|
"{\"id\":" + quote(id) + ",\"size\":" + std::to_string(size) +
|
|
",\"partSize\":" + std::to_string(32u << 20) + '}');
|
|
}
|
|
if (method == "POST" && url == "/api/upload/commit")
|
|
{
|
|
auto session = sessions.find(arg(c, "id"));
|
|
if (!session)
|
|
return error(c, 404, "This upload is no longer open. Start it again.");
|
|
{
|
|
std::lock_guard<std::mutex> guard(session->lock);
|
|
if (session->writers || session->covered != session->size)
|
|
return respond(c, 409,
|
|
"{\"error\":\"Parts are missing.\",\"covered\":" +
|
|
std::to_string(session->covered) + '}');
|
|
}
|
|
if (sessions.take(session->id) != session)
|
|
return error(c, 409, "This upload was already finished.");
|
|
const auto result = ps5_transfer::commit(session->fd, session->temporary,
|
|
session->destination, session->replace, true);
|
|
close(session->fd);
|
|
session->fd = -1;
|
|
if (result != ps5_transfer::Commit::done)
|
|
{
|
|
unlink(session->temporary.c_str());
|
|
return result == ps5_transfer::Commit::exists
|
|
? error(c, 409,
|
|
"A file with this name already exists. Rename your file first.")
|
|
: error(c, 500, "Could not finish the upload.");
|
|
}
|
|
return respond(c, 201,
|
|
"{\"uploaded\":true,\"bytes\":" + std::to_string(session->size) + '}');
|
|
}
|
|
if (method == "DELETE" && url == "/api/upload/session")
|
|
{
|
|
auto session = sessions.take(arg(c, "id"));
|
|
if (session)
|
|
ps5_transfer::discard(*session);
|
|
return respond(c, 200, "{\"cancelled\":" + std::string(session ? "true" : "false") + '}');
|
|
}
|
|
return error(c, 404, "This page was not found.");
|
|
}
|
|
bool is_transfer_route(const std::string &url)
|
|
{
|
|
return url.rfind("/api/upload", 0) == 0;
|
|
}
|
|
bool is_scraper_route(const std::string &url)
|
|
{
|
|
return url.rfind("/api/scraper", 0) == 0 || url.rfind("/api/library", 0) == 0;
|
|
}
|
|
std::string current_frontend()
|
|
{
|
|
std::lock_guard<std::mutex> guard(frontend_lock);
|
|
return quote(frontend_name);
|
|
}
|
|
// The WebUI's language (webui/i18n.js): kept on the console, so every device opening the
|
|
// page shows the one chosen last. Empty until a language is chosen.
|
|
bool webui_language_known(const std::string &code)
|
|
{
|
|
static const char *const known[] = {"en", "es", "fr", "de", "it", "pt-BR", "ru", "ja",
|
|
"ko", "zh-CN", "zh-TW", "vi", "tr", "pl", "nl", "id"};
|
|
for (const char *name : known)
|
|
if (code == name)
|
|
return true;
|
|
return false;
|
|
}
|
|
std::string webui_language()
|
|
{
|
|
const auto values = read_config(root_path + "/config/webui-preferences.cfg");
|
|
const auto found = values.find("webui_language");
|
|
return found != values.end() && webui_language_known(found->second) ? found->second : "";
|
|
}
|
|
MHD_Result route(MHD_Connection *c, const std::string &url, const std::string &method, Request &r)
|
|
{
|
|
if (method == "GET" && url == "/api/status")
|
|
{
|
|
struct statvfs fs{};
|
|
bool space_known = storage_space(fs);
|
|
return respond(
|
|
c, 200,
|
|
"{\"name\":\"RetroArch\",\"frontend\":" + current_frontend() +
|
|
",\"port\":" + std::to_string(listen_port) + ",\"token\":" + quote(token) +
|
|
",\"uploadLimit\":" + std::to_string(upload_limit) + ",\"freeBytes\":" +
|
|
(space_known ? std::to_string(uint64_t(fs.f_bavail) * fs.f_frsize) : "null") +
|
|
",\"build\":" + quote(started_build) + ",\"language\":" + quote(webui_language()) +
|
|
'}');
|
|
}
|
|
if (method == "POST" && url == "/api/preferences")
|
|
{
|
|
const std::string language = arg(c, "language");
|
|
if (!webui_language_known(language))
|
|
return error(c, 400, "Choose a supported language.");
|
|
if (!write_config(root_path + "/config/webui-preferences.cfg",
|
|
{{"webui_language", language}}))
|
|
return error(c, 500, "The language could not be saved. Try again.");
|
|
return respond(c, 200, "{\"language\":" + quote(language) + '}');
|
|
}
|
|
if (method == "GET" && url == "/api/alerts")
|
|
return respond(c, 200, bios_alerts());
|
|
if (method == "GET" && url == "/api/update")
|
|
return respond(c, 200, update_status());
|
|
if (method == "POST" && url == "/api/update/download")
|
|
return ps5_update::download(arg(c, "tag"))
|
|
? respond(c, 202, update_status())
|
|
: error(c, 409,
|
|
"An update is already active, or the release could not be prepared.");
|
|
if (method == "POST" && url == "/api/update/install")
|
|
return ps5_update::request_install()
|
|
? respond(c, 202, update_status())
|
|
: error(c, 409, "Download and verify an update before installing it.");
|
|
if (method == "GET" && url == "/api/content")
|
|
return list_content(c);
|
|
if (method == "GET" && url == "/api/download")
|
|
return download(c);
|
|
if (url == "/api/core-metadata" && method == "GET")
|
|
{
|
|
const char *name = MHD_lookup_connection_value(c, MHD_GET_ARGUMENT_KIND, "core");
|
|
const std::string core = name ? name : "";
|
|
if (!valid_path(core) || core.find('/') != std::string::npos)
|
|
return error(c, 400, "Choose a core profile.");
|
|
const std::string empty = "{\"categories\":[],\"settings\":[]}";
|
|
auto bundled =
|
|
read_file(root_path + "/webui/core-metadata/" + core + ".json", 2 * 1024 * 1024);
|
|
auto runtime = runtime_metadata(core, ".json");
|
|
// Keep bundled options when a game's runtime table exposes only a subset.
|
|
return respond(c, 200,
|
|
"{\"bundled\":" + (bundled.empty() ? empty : bundled) +
|
|
",\"runtime\":" + (runtime.empty() ? empty : runtime) + '}');
|
|
}
|
|
if (url == "/api/config")
|
|
return config_editor(c, method, r.body);
|
|
if (method == "GET" && url == "/api/cores")
|
|
{
|
|
std::string out = "{\"cores\":[";
|
|
for (const auto &core : core_profiles())
|
|
{
|
|
if (out.back() != '[')
|
|
out += ',';
|
|
out += quote(core);
|
|
}
|
|
return respond(c, 200, out + "]}");
|
|
}
|
|
if (method == "GET" && url == "/api/settings")
|
|
return get_settings(c);
|
|
if (method == "POST" && url == "/api/settings")
|
|
return save_settings(c, r.body);
|
|
if (method == "POST" && url == "/api/folder")
|
|
{
|
|
std::string path;
|
|
const std::string relative = arg(c, "path");
|
|
if (relative.empty() || !content_path(relative, path, true))
|
|
return error(c, 400, "Enter a folder name inside content.");
|
|
if (mkdir(path.c_str(), 0755))
|
|
return error(c, errno == EEXIST ? 409 : 500,
|
|
"This folder already exists or could not be created.");
|
|
return respond(c, 201, "{\"created\":true}");
|
|
}
|
|
if (method != "GET")
|
|
return error(c, 405, "This action is not supported.");
|
|
// Only shipped assets are reachable; no filesystem passthrough.
|
|
const std::map<std::string, const char *> assets = {
|
|
{"/", "text/html; charset=utf-8"},
|
|
{"/index.html", "text/html; charset=utf-8"},
|
|
{"/app.css", "text/css; charset=utf-8"},
|
|
{"/app.js", "text/javascript; charset=utf-8"},
|
|
{"/icons.js", "text/javascript; charset=utf-8"},
|
|
{"/add-game.js", "text/javascript; charset=utf-8"},
|
|
{"/assets/fluent/LICENSE", "text/plain; charset=utf-8"},
|
|
{"/assets/fluent/arrow-square-down.svg", "image/svg+xml"},
|
|
{"/assets/fluent/arrow-sync.svg", "image/svg+xml"},
|
|
{"/assets/fluent/board.svg", "image/svg+xml"},
|
|
{"/assets/fluent/book-open.svg", "image/svg+xml"},
|
|
{"/assets/fluent/book.svg", "image/svg+xml"},
|
|
{"/assets/fluent/briefcase.svg", "image/svg+xml"},
|
|
{"/assets/fluent/building.svg", "image/svg+xml"},
|
|
{"/assets/fluent/checkmark-circle.svg", "image/svg+xml"},
|
|
{"/assets/fluent/cloud.svg", "image/svg+xml"},
|
|
{"/assets/fluent/coin-multiple.svg", "image/svg+xml"},
|
|
{"/assets/fluent/content-view.svg", "image/svg+xml"},
|
|
{"/assets/fluent/database.svg", "image/svg+xml"},
|
|
{"/assets/fluent/document-folder.svg", "image/svg+xml"},
|
|
{"/assets/fluent/document-text.svg", "image/svg+xml"},
|
|
{"/assets/fluent/edit.svg", "image/svg+xml"},
|
|
{"/assets/fluent/flag.svg", "image/svg+xml"},
|
|
{"/assets/fluent/game-chat.svg", "image/svg+xml"},
|
|
{"/assets/fluent/globe.svg", "image/svg+xml"},
|
|
{"/assets/fluent/headphones.svg", "image/svg+xml"},
|
|
{"/assets/fluent/home.svg", "image/svg+xml"},
|
|
{"/assets/fluent/image.svg", "image/svg+xml"},
|
|
{"/assets/fluent/laptop.svg", "image/svg+xml"},
|
|
{"/assets/fluent/library.svg", "image/svg+xml"},
|
|
{"/assets/fluent/question-circle.svg", "image/svg+xml"},
|
|
{"/assets/fluent/settings.svg", "image/svg+xml"},
|
|
{"/assets/fluent/text-bullet-list-square.svg", "image/svg+xml"},
|
|
{"/assets/fluent/text-edit-style.svg", "image/svg+xml"},
|
|
{"/assets/fluent/vault.svg", "image/svg+xml"},
|
|
{"/assets/fluent/video.svg", "image/svg+xml"},
|
|
{"/assets/fluent/warning.svg", "image/svg+xml"},
|
|
{"/assets/fluent/weather-sunny-low.svg", "image/svg+xml"},
|
|
{"/assets/flags/LICENSE", "text/plain; charset=utf-8"},
|
|
{"/assets/flags/au.svg", "image/svg+xml"},
|
|
{"/assets/flags/br.svg", "image/svg+xml"},
|
|
{"/assets/flags/ca.svg", "image/svg+xml"},
|
|
{"/assets/flags/cn.svg", "image/svg+xml"},
|
|
{"/assets/flags/de.svg", "image/svg+xml"},
|
|
{"/assets/flags/es.svg", "image/svg+xml"},
|
|
{"/assets/flags/eu.svg", "image/svg+xml"},
|
|
{"/assets/flags/fr.svg", "image/svg+xml"},
|
|
{"/assets/flags/gb.svg", "image/svg+xml"},
|
|
{"/assets/flags/it.svg", "image/svg+xml"},
|
|
{"/assets/flags/jp.svg", "image/svg+xml"},
|
|
{"/assets/flags/kr.svg", "image/svg+xml"},
|
|
{"/assets/flags/ru.svg", "image/svg+xml"},
|
|
{"/assets/flags/tw.svg", "image/svg+xml"},
|
|
{"/assets/flags/us.svg", "image/svg+xml"},
|
|
{"/assets/flags/vn.svg", "image/svg+xml"},
|
|
{"/assets/flags/tr.svg", "image/svg+xml"},
|
|
{"/assets/flags/pl.svg", "image/svg+xml"},
|
|
{"/assets/flags/nl.svg", "image/svg+xml"},
|
|
{"/assets/flags/id.svg", "image/svg+xml"},
|
|
{"/i18n.js", "text/javascript; charset=utf-8"},
|
|
{"/i18n/es.json", "application/json"},
|
|
{"/i18n/fr.json", "application/json"},
|
|
{"/i18n/de.json", "application/json"},
|
|
{"/i18n/it.json", "application/json"},
|
|
{"/i18n/pt-BR.json", "application/json"},
|
|
{"/i18n/ru.json", "application/json"},
|
|
{"/i18n/ja.json", "application/json"},
|
|
{"/i18n/ko.json", "application/json"},
|
|
{"/i18n/zh-CN.json", "application/json"},
|
|
{"/i18n/zh-TW.json", "application/json"},
|
|
{"/i18n/vi.json", "application/json"},
|
|
{"/i18n/tr.json", "application/json"},
|
|
{"/i18n/pl.json", "application/json"},
|
|
{"/i18n/nl.json", "application/json"},
|
|
{"/i18n/id.json", "application/json"},
|
|
{"/assets/systems/3do.webp", "image/webp"},
|
|
{"/assets/systems/LICENSE", "text/plain; charset=utf-8"},
|
|
{"/assets/systems/amiga.webp", "image/webp"},
|
|
{"/assets/systems/arcade.webp", "image/webp"},
|
|
{"/assets/systems/atari2600.webp", "image/webp"},
|
|
{"/assets/systems/atari5200.webp", "image/webp"},
|
|
{"/assets/systems/atari7800.webp", "image/webp"},
|
|
{"/assets/systems/atarijaguar.webp", "image/webp"},
|
|
{"/assets/systems/atarilynx.webp", "image/webp"},
|
|
{"/assets/systems/atomiswave.webp", "image/webp"},
|
|
{"/assets/systems/c64.webp", "image/webp"},
|
|
{"/assets/systems/cps.webp", "image/webp"},
|
|
{"/assets/systems/dos.webp", "image/webp"},
|
|
{"/assets/systems/dreamcast.webp", "image/webp"},
|
|
{"/assets/systems/fbneo.webp", "image/webp"},
|
|
{"/assets/systems/fds.webp", "image/webp"},
|
|
{"/assets/systems/gamegear.webp", "image/webp"},
|
|
{"/assets/systems/gb.webp", "image/webp"},
|
|
{"/assets/systems/gba.webp", "image/webp"},
|
|
{"/assets/systems/gbc.webp", "image/webp"},
|
|
{"/assets/systems/gc.webp", "image/webp"},
|
|
{"/assets/systems/genesis.webp", "image/webp"},
|
|
{"/assets/systems/mame.webp", "image/webp"},
|
|
{"/assets/systems/mastersystem.webp", "image/webp"},
|
|
{"/assets/systems/msx.webp", "image/webp"},
|
|
{"/assets/systems/n3ds.webp", "image/webp"},
|
|
{"/assets/systems/n64.webp", "image/webp"},
|
|
{"/assets/systems/naomi.webp", "image/webp"},
|
|
{"/assets/systems/nds.webp", "image/webp"},
|
|
{"/assets/systems/neogeo.webp", "image/webp"},
|
|
{"/assets/systems/neogeocd.webp", "image/webp"},
|
|
{"/assets/systems/nes.webp", "image/webp"},
|
|
{"/assets/systems/ngp.webp", "image/webp"},
|
|
{"/assets/systems/ngpc.webp", "image/webp"},
|
|
{"/assets/systems/pcengine.webp", "image/webp"},
|
|
{"/assets/systems/pcfx.webp", "image/webp"},
|
|
{"/assets/systems/pokemini.webp", "image/webp"},
|
|
{"/assets/systems/ps2.webp", "image/webp"},
|
|
{"/assets/systems/psp.webp", "image/webp"},
|
|
{"/assets/systems/psx.webp", "image/webp"},
|
|
{"/assets/systems/saturn.webp", "image/webp"},
|
|
{"/assets/systems/scummvm.webp", "image/webp"},
|
|
{"/assets/systems/sega32x.webp", "image/webp"},
|
|
{"/assets/systems/segacd.webp", "image/webp"},
|
|
{"/assets/systems/sg-1000.webp", "image/webp"},
|
|
{"/assets/systems/snes.webp", "image/webp"},
|
|
{"/assets/systems/supergrafx.webp", "image/webp"},
|
|
{"/assets/systems/virtualboy.webp", "image/webp"},
|
|
{"/assets/systems/wii.webp", "image/webp"},
|
|
{"/assets/systems/wonderswan.webp", "image/webp"},
|
|
{"/assets/systems/wonderswancolor.webp", "image/webp"},
|
|
{"/assets/ICON-CREDITS.txt", "text/plain; charset=utf-8"},
|
|
{"/assets/lucide/LICENSE", "text/plain; charset=utf-8"},
|
|
{"/settings-guide.js", "text/javascript; charset=utf-8"},
|
|
{"/version.json", "application/json"},
|
|
{"/assets/mihawk.png", "image/png"},
|
|
{"/assets/ui.woff2", "font/woff2"},
|
|
{"/assets/retroarch.svg", "image/svg+xml"},
|
|
{"/ps5-media-helper.py", "text/x-python; charset=utf-8"}};
|
|
auto asset = assets.find(url);
|
|
if (asset == assets.end())
|
|
return error(c, 404, "This page was not found.");
|
|
auto content =
|
|
read_file(root_path + "/webui" + (url == "/" ? "/index.html" : url), 2 * 1024 * 1024);
|
|
if (content.empty())
|
|
return error(c, 404, "WebUI assets are missing. Reinstall the complete RetroArch package.");
|
|
return respond(c, 200, content, asset->second);
|
|
}
|
|
MHD_Result handle(void *, MHD_Connection *c, const char *url, const char *method, const char *,
|
|
const char *data, size_t *size, void **context)
|
|
{
|
|
if (!*context)
|
|
{
|
|
auto *r = new (std::nothrow) Request;
|
|
if (!r)
|
|
return MHD_NO;
|
|
*context = r;
|
|
if (!local_origin(c))
|
|
return error(c, 403, "Open this page using the console IP address and port.");
|
|
const bool write_request = std::strcmp(method, "GET") != 0;
|
|
const char *provided = MHD_lookup_connection_value(c, MHD_HEADER_KIND, "X-RetroArch-Token");
|
|
if (write_request && (!provided || token != provided))
|
|
return error(c, 403, "Your session expired. Reload the page and try again.");
|
|
if (std::strcmp(method, "PUT") == 0 && std::strcmp(url, "/api/upload") == 0)
|
|
prepare_upload(c, *r);
|
|
else if (std::strcmp(method, "PUT") == 0 && std::strcmp(url, "/api/upload/batch") == 0)
|
|
prepare_batch(c, *r);
|
|
else if (std::strcmp(method, "PUT") == 0 && std::strcmp(url, "/api/upload/part") == 0)
|
|
prepare_part(c, *r);
|
|
else if (std::strcmp(method, "PUT") == 0 && std::strcmp(url, "/api/scraper/pc/media") == 0)
|
|
prepare_scraped(c, *r);
|
|
else if (std::strcmp(method, "PUT") == 0 && std::strcmp(url, "/api/library/media") == 0)
|
|
prepare_media(c, *r);
|
|
else if (std::strcmp(method, "PUT") == 0 && std::strcmp(url, "/api/library/add") == 0)
|
|
prepare_game_add(c, *r);
|
|
else if (std::strcmp(method, "PUT") == 0 && std::strcmp(url, "/api/library/file") == 0)
|
|
prepare_game_file(c, *r);
|
|
if (std::strcmp(url, "/api/scraper/start") == 0)
|
|
r->body_limit = 4 << 20; // one line a game chosen
|
|
if (r->error)
|
|
return error(c, r->error, r->message);
|
|
if (r->transfer != Transfer::none)
|
|
{
|
|
++transfers_in_flight;
|
|
r->counted = true;
|
|
}
|
|
if (r->transfer == Transfer::none)
|
|
{
|
|
const char *length = MHD_lookup_connection_value(c, MHD_HEADER_KIND, "Content-Length");
|
|
if (length && std::strtoull(length, nullptr, 10) > r->body_limit)
|
|
return error(c, 413, "This request is too large.");
|
|
}
|
|
return MHD_YES;
|
|
}
|
|
auto &r = *static_cast<Request *>(*context);
|
|
if (*size)
|
|
{
|
|
if (r.transfer != Transfer::none && !r.error)
|
|
{
|
|
if (*size > r.expected - r.received)
|
|
{
|
|
r.error = 413;
|
|
r.message = "Upload exceeded its declared size.";
|
|
}
|
|
else if (r.transfer == Transfer::batch)
|
|
{
|
|
r.received += *size;
|
|
if (!r.batch->reader.feed(data, *size))
|
|
{
|
|
r.error = 400;
|
|
r.message = r.batch->reader.problem();
|
|
}
|
|
}
|
|
else if (!r.writer.write(data, *size))
|
|
{
|
|
r.error = 507;
|
|
r.message = "The console could not write the upload. Check free space.";
|
|
}
|
|
else
|
|
r.received += *size;
|
|
}
|
|
else if (!r.error)
|
|
{
|
|
if (*size > r.body_limit - r.body.size())
|
|
{
|
|
r.error = 413;
|
|
r.message = "This request is too large.";
|
|
}
|
|
else
|
|
r.body.append(data, *size);
|
|
}
|
|
*size = 0;
|
|
// MHD allows a response before receiving the body or after consuming it,
|
|
// never during a body callback. Drain failed streams without buffering.
|
|
return MHD_YES;
|
|
}
|
|
if (r.error && !(r.transfer == Transfer::batch && r.batch && r.error == 400))
|
|
return error(c, r.error, r.message);
|
|
if (r.transfer == Transfer::scraped || r.transfer == Transfer::media || is_scraper_route(url))
|
|
return scraper_route(c, url, method, r);
|
|
if (r.transfer != Transfer::none || is_transfer_route(url))
|
|
return transfer_route(c, url, method, r);
|
|
std::lock_guard<std::mutex> guard(state_lock);
|
|
return route(c, url, method, r);
|
|
}
|
|
constexpr unsigned transfer_threads = 4;
|
|
// Large socket buffers for each connection: a lane keeps the link full on its own.
|
|
void tune_connection(void *, MHD_Connection *c, void **, MHD_ConnectionNotificationCode code)
|
|
{
|
|
if (code != MHD_CONNECTION_NOTIFY_STARTED)
|
|
return;
|
|
const auto *info = MHD_get_connection_info(c, MHD_CONNECTION_INFO_CONNECTION_FD);
|
|
if (!info)
|
|
return;
|
|
int buffer = 4 << 20;
|
|
setsockopt(info->connect_fd, SOL_SOCKET, SO_RCVBUF, &buffer, sizeof buffer);
|
|
setsockopt(info->connect_fd, SOL_SOCKET, SO_SNDBUF, &buffer, sizeof buffer);
|
|
}
|
|
void completed(void *, MHD_Connection *, void **context, MHD_RequestTerminationCode)
|
|
{
|
|
delete static_cast<Request *>(*context);
|
|
*context = nullptr;
|
|
}
|
|
// Registration runs on the emulator thread. HTTP reads only the atomic snapshot.
|
|
void save_metadata(const char *name, std::string json, const Config &defaults)
|
|
{
|
|
if (!name || root_path.empty() || !valid_path(name) || std::strchr(name, '/') ||
|
|
json.size() > 2 * 1024 * 1024)
|
|
return;
|
|
const std::string folder = root_path + "/config/webui-metadata";
|
|
mkdir(folder.c_str(), 0755);
|
|
struct stat st{};
|
|
if (content_stat(folder.c_str(), &st) || !S_ISDIR(st.st_mode))
|
|
return;
|
|
const auto tag = catalog_revision(name);
|
|
json.insert(1, "\"catalogRevision\":" + quote(tag) + ',');
|
|
// Both files carry their revision inside the atomic replacement, so an
|
|
// interrupted write or an upgrade cannot validate a stale companion file.
|
|
write_config(folder + '/' + name + ".opt", defaults, "# catalog-revision: " + tag + '\n');
|
|
const std::string path = folder + '/' + name + ".json";
|
|
const std::string temporary = path + ".tmp";
|
|
int fd = open(temporary.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, 0600);
|
|
if (fd < 0)
|
|
return;
|
|
size_t offset = 0;
|
|
while (offset < json.size())
|
|
{
|
|
ssize_t count = write(fd, json.data() + offset, json.size() - offset);
|
|
if (count < 0 && errno == EINTR)
|
|
continue;
|
|
if (count <= 0)
|
|
break;
|
|
offset += static_cast<size_t>(count);
|
|
}
|
|
bool ok = offset == json.size() && fsync(fd) == 0;
|
|
close(fd);
|
|
if (!ok || rename(temporary.c_str(), path.c_str()))
|
|
unlink(temporary.c_str());
|
|
}
|
|
std::string metadata_text(const char *value)
|
|
{
|
|
return quote(value ? value : "");
|
|
}
|
|
} // namespace
|
|
extern "C" void ps5_webui_core_options(const char *name, const retro_core_options_v2 *options)
|
|
{
|
|
if (!options || !options->definitions)
|
|
return;
|
|
Config defaults;
|
|
std::string json = "{\"categories\":[";
|
|
if (options->categories)
|
|
for (const auto *c = options->categories; c->key; ++c)
|
|
{
|
|
if (c != options->categories)
|
|
json += ',';
|
|
json += "{\"key\":" + metadata_text(c->key) + ",\"label\":" + metadata_text(c->desc) +
|
|
",\"description\":" + metadata_text(c->info) + '}';
|
|
}
|
|
json += "],\"settings\":[";
|
|
for (const auto *s = options->definitions; s->key; ++s)
|
|
{
|
|
if (s != options->definitions)
|
|
json += ',';
|
|
const char *value = s->default_value ? s->default_value : s->values[0].value;
|
|
if (value && setting_key(s->key) && valid_setting_value(value, "text"))
|
|
defaults[s->key] = value;
|
|
json += "{\"key\":" + metadata_text(s->key) +
|
|
",\"label\":" + metadata_text(s->desc_categorized ? s->desc_categorized : s->desc) +
|
|
",\"description\":" +
|
|
metadata_text(s->info_categorized ? s->info_categorized : s->info) +
|
|
",\"category\":" + metadata_text(s->category_key) +
|
|
",\"default\":" + metadata_text(value) + ",\"choices\":[";
|
|
for (size_t i = 0; i < RETRO_NUM_CORE_OPTION_VALUES_MAX && s->values[i].value; ++i)
|
|
{
|
|
if (i)
|
|
json += ',';
|
|
json += '[' + metadata_text(s->values[i].value) + ',' +
|
|
metadata_text(s->values[i].label ? s->values[i].label : s->values[i].value) +
|
|
']';
|
|
}
|
|
json += "]}";
|
|
}
|
|
save_metadata(name, json + "]}", defaults);
|
|
}
|
|
extern "C" void ps5_webui_core_variables(const char *name, const retro_variable *vars)
|
|
{
|
|
if (!vars)
|
|
return;
|
|
Config defaults;
|
|
std::string json = "{\"categories\":[],\"settings\":[";
|
|
bool first = true;
|
|
for (const auto *s = vars; s->key; ++s)
|
|
{
|
|
std::string value = s->value ? s->value : "";
|
|
const size_t separator = value.find("; ");
|
|
if (separator == std::string::npos)
|
|
continue;
|
|
if (!first)
|
|
json += ',';
|
|
first = false;
|
|
json += "{\"key\":" + metadata_text(s->key) +
|
|
",\"label\":" + quote(value.substr(0, separator)) +
|
|
",\"description\":\"\",\"category\":\"\",\"choices\":[";
|
|
size_t start = separator + 2;
|
|
while (start <= value.size())
|
|
{
|
|
size_t end = value.find('|', start);
|
|
if (end == std::string::npos)
|
|
end = value.size();
|
|
if (start != separator + 2)
|
|
json += ',';
|
|
const auto raw = value.substr(start, end - start);
|
|
if (start == separator + 2 && setting_key(s->key) && valid_setting_value(raw, "text"))
|
|
defaults[s->key] = raw;
|
|
auto choice = quote(raw);
|
|
json += '[' + choice + ',' + choice + ']';
|
|
start = end + 1;
|
|
}
|
|
json += "]}";
|
|
}
|
|
save_metadata(name, json + "]}", defaults);
|
|
}
|
|
void ps5_webui_prepare(const char *root)
|
|
{
|
|
root_path = root;
|
|
apply_core_settings();
|
|
}
|
|
void ps5_webui_set_frontend(const char *frontend)
|
|
{
|
|
std::lock_guard<std::mutex> guard(frontend_lock);
|
|
frontend_name = frontend ? frontend : "";
|
|
}
|
|
unsigned ps5_webui_transfers()
|
|
{
|
|
return transfers_in_flight.load();
|
|
}
|
|
bool ps5_webui_scraping()
|
|
{
|
|
return ps5_scraper::busy();
|
|
}
|
|
unsigned ps5_webui_connections()
|
|
{
|
|
if (!web_daemon)
|
|
return 0;
|
|
const MHD_DaemonInfo *info =
|
|
MHD_get_daemon_info(web_daemon, MHD_DAEMON_INFO_CURRENT_CONNECTIONS);
|
|
return info ? info->num_connections : 0;
|
|
}
|
|
bool ps5_webui_start(const char *root, unsigned short port, bool apply_settings)
|
|
{
|
|
if (web_daemon)
|
|
return true;
|
|
root_path = root;
|
|
{
|
|
const std::string version = read_file(root_path + "/webui/version.json", 4096);
|
|
const size_t at = version.find("\"build\"");
|
|
const size_t open = at == std::string::npos ? at : version.find('"', version.find(':', at));
|
|
const size_t close = open == std::string::npos ? open : version.find('"', open + 1);
|
|
started_build =
|
|
close == std::string::npos ? "" : version.substr(open + 1, close - open - 1);
|
|
}
|
|
ps5_update::initialize(root_path);
|
|
// The scraper's jobs live with the server: one left running resumes now.
|
|
ps5_scraper::configure(root_path, std::getenv("PS5_SCRAPER_LIBRETRO_BASE")
|
|
? std::getenv("PS5_SCRAPER_LIBRETRO_BASE")
|
|
: "");
|
|
if (apply_settings)
|
|
apply_core_settings();
|
|
listen_port = port;
|
|
token = nonce();
|
|
mkdir((root_path + "/content").c_str(), 0755);
|
|
// A pool of threads, so parallel connections progress side by side (one thread
|
|
// held a 4-connection upload to 15 MB/s, 2026-10-07); 512 KiB a connection, so a
|
|
// body arrives in large pieces; up to 32 connections for the page's lanes.
|
|
web_daemon = MHD_start_daemon(
|
|
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_ITC | MHD_USE_ERROR_LOG, port, nullptr, nullptr,
|
|
handle, nullptr, MHD_OPTION_THREAD_POOL_SIZE, unsigned(transfer_threads),
|
|
MHD_OPTION_CONNECTION_LIMIT, unsigned(32), MHD_OPTION_CONNECTION_TIMEOUT, unsigned(30),
|
|
MHD_OPTION_CONNECTION_MEMORY_LIMIT, size_t(512 * 1024), MHD_OPTION_THREAD_STACK_SIZE,
|
|
size_t(256 * 1024), MHD_OPTION_NOTIFY_COMPLETED, completed, nullptr,
|
|
MHD_OPTION_NOTIFY_CONNECTION, tune_connection, nullptr, MHD_OPTION_END);
|
|
std::fprintf(stderr, "webui: %s port=%u\n", web_daemon ? "listening" : "unavailable", port);
|
|
return web_daemon != nullptr;
|
|
}
|
|
void ps5_webui_stop()
|
|
{
|
|
if (web_daemon)
|
|
{
|
|
MHD_stop_daemon(web_daemon);
|
|
web_daemon = nullptr;
|
|
std::fprintf(stderr, "webui: stopped\n");
|
|
}
|
|
sessions.clear();
|
|
ps5_scraper::shutdown();
|
|
ps5_update::stop();
|
|
}
|