/** * @file src/confighttp.cpp * @brief Definitions for the Web UI Config HTTPS server. * * @todo Authentication, better handling of routes common to nvhttp, cleanup */ #define BOOST_BIND_GLOBAL_PLACEHOLDERS // standard includes #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // lib includes #include #include #include #include #include #include #include #ifdef _WIN32 #include "platform/windows/misc.h" #include #include #endif // local includes #include "config.h" #include "confighttp.h" #include "crypto.h" #include "file_handler.h" #include "globals.h" #include "http_auth.h" #include "httpcommon.h" #include "platform/common.h" #ifdef _WIN32 #include "src/platform/windows/image_convert.h" #endif #include "logging.h" #include "log_export.h" #include "network.h" #include "nvhttp.h" #include "remote_display_topology.h" #include "remote_session.h" #include "platform/common.h" #include "rtsp.h" #include "session_history.h" #include "stream.h" #include "host_stats.h" #include "video.h" #include "webrtc_stream.h" #ifdef _WIN32 #include "platform/windows/virtual_display_cleanup.h" #include "platform/windows/virtual_display.h" #elif defined(__linux__) #include "platform/linux/capture_status.h" #include "platform/linux/private_display.h" #include "src/platform/linux/display_backend.h" #endif #include #if defined(_WIN32) #include "platform/windows/misc.h" #include "src/platform/windows/ipc/misc_utils.h" #include "src/platform/windows/playnite_integration.h" #include "src/platform/windows/playnite_sync.h" #include #endif #ifdef uuid_t #undef uuid_t #endif #if defined(_WIN32) #include "platform/windows/misc.h" #include #include #include #endif #include "display_helper_integration.h" #include "process.h" #include "utility.h" #include "uuid.h" #ifdef _WIN32 #include "platform/windows/utils.h" #endif using namespace std::literals; namespace pt = boost::property_tree; namespace confighttp { #ifdef _WIN32 /** * @brief Reports whether ViGEmBus is needed for the configured gamepad backend. * @return `true` unless a selected VHF backend is ready to provide controllers. */ static bool is_vigem_required() { const bool vhf_allowed = config::input.gamepad == "auto" || config::input.gamepad.starts_with("vhf"); return !vhf_allowed || !platf::is_virtual_gamepad_driver_available(); } #endif // Global MIME type lookup used for static file responses const std::map mime_types = { {"css", "text/css"}, {"gif", "image/gif"}, {"htm", "text/html"}, {"html", "text/html"}, {"ico", "image/x-icon"}, {"jpeg", "image/jpeg"}, {"jpg", "image/jpeg"}, {"js", "application/javascript"}, {"json", "application/json"}, {"map", "application/json"}, {"png", "image/png"}, {"webp", "image/webp"}, {"svg", "image/svg+xml"}, {"ttf", "font/ttf"}, {"txt", "text/plain"}, {"wasm", "application/wasm"}, {"webmanifest", "application/manifest+json"}, {"woff2", "font/woff2"}, {"xml", "text/xml"}, }; // Helper: sort apps by their 'name' field, if present static void sort_apps_by_name(nlohmann::json &file_tree) { try { if (!file_tree.contains("apps") || !file_tree["apps"].is_array()) { return; } auto &apps_node = file_tree["apps"]; std::sort(apps_node.begin(), apps_node.end(), [](const nlohmann::json &a, const nlohmann::json &b) { try { return a.at("name").get() < b.at("name").get(); } catch (...) { return false; } }); } catch (...) {} } std::optional find_app_index_by_uuid(const nlohmann::json &apps_node, const std::string &uuid) { if (uuid.empty() || !apps_node.is_array()) { return std::nullopt; } for (size_t i = 0; i < apps_node.size(); ++i) { const auto &app = apps_node[i]; if (app.is_object() && app.contains("uuid") && app["uuid"].is_string() && app["uuid"].get() == uuid) { return i; } } return std::nullopt; } remote_session::control_e configurable_remote_session(std::string_view uuid) { const auto control = remote_session::identify(0, uuid); return control == remote_session::control_e::input || control == remote_session::control_e::monitor ? control : remote_session::control_e::none; } bool ensure_remote_session_apps(nlohmann::json &file_tree) { if (!file_tree.contains("apps") || !file_tree["apps"].is_array()) { file_tree["apps"] = nlohmann::json::array(); } bool changed = false; for (const auto control : {remote_session::control_e::input, remote_session::control_e::monitor}) { const auto synthetic = remote_session::synthetic(control); const auto artwork = remote_session::synthetic_artwork_filename(control); if (!artwork) { continue; } const auto default_image = std::string {"remote-session/"} + std::string {*artwork}; const auto configured_name = control == remote_session::control_e::input ? "Remote Input" : "Remote Monitor"; const auto index = find_app_index_by_uuid(file_tree["apps"], synthetic.uuid); if (!index) { file_tree["apps"].push_back({ {"name", configured_name}, {"uuid", synthetic.uuid}, {"image-path", default_image}, }); changed = true; continue; } auto &app = file_tree["apps"][*index]; if (app.value("name", std::string {}) != configured_name) { app["name"] = configured_name; changed = true; } if (!app.contains("image-path") || !app["image-path"].is_string() || app["image-path"].get().empty()) { app["image-path"] = default_image; changed = true; } } return changed; } std::optional resolve_app_index_token(const nlohmann::json &apps_node, const std::string &token) { if (auto uuid_index = find_app_index_by_uuid(apps_node, token)) { return uuid_index; } if (token.empty() || !std::ranges::all_of(token, [](unsigned char ch) { return std::isdigit(ch) != 0; })) { return std::nullopt; } try { const auto index = static_cast(std::stoull(token)); if (apps_node.is_array() && index < apps_node.size()) { return index; } } catch (...) { } return std::nullopt; } std::recursive_mutex &apps_file_mutex() { static std::recursive_mutex mutex; return mutex; } bool refresh_client_apps_cache(nlohmann::json &file_tree, bool sort_by_name) { std::lock_guard lock {apps_file_mutex()}; try { if (sort_by_name) { sort_apps_by_name(file_tree); } file_handler::write_file(config::stream.file_apps.c_str(), file_tree.dump(4)); proc::refresh(config::stream.file_apps, false); return true; } catch (const std::exception &e) { BOOST_LOG(warning) << "refresh_client_apps_cache: failed: " << e.what(); } catch (...) { BOOST_LOG(warning) << "refresh_client_apps_cache: failed (unknown)"; } return false; } namespace fs = std::filesystem; using enum confighttp::StatusCode; static std::string trim_copy(const std::string &input) { auto begin = input.begin(); auto end = input.end(); while (begin != end && std::isspace(static_cast(*begin))) { ++begin; } while (end != begin && std::isspace(static_cast(*(end - 1)))) { --end; } return std::string {begin, end}; } static bool file_is_regular(const fs::path &path) { if (path.empty()) { return false; } std::error_code ec; return fs::exists(path, ec) && fs::is_regular_file(path, ec); } static bool resolve_cover_path_for_uuid(const std::string &uuid, fs::path &out_path) { if (uuid.empty()) { return false; } try { std::string content = file_handler::read_file(config::stream.file_apps.c_str()); nlohmann::json file_tree = nlohmann::json::parse(content); if (!file_tree.contains("apps") || !file_tree["apps"].is_array()) { return false; } const fs::path cover_dir = fs::path(platf::appdata()) / "covers"; const fs::path config_dir = fs::path(config::stream.file_apps).parent_path(); const fs::path assets_dir = fs::path(SUNSHINE_ASSETS_DIR); for (const auto &entry : file_tree["apps"]) { if (!entry.is_object()) { continue; } if (!entry.contains("uuid") || !entry["uuid"].is_string()) { continue; } if (entry["uuid"].get() != uuid) { continue; } std::string image_path; if (entry.contains("image-path") && entry["image-path"].is_string()) { image_path = entry["image-path"].get(); } std::string playnite_id; if (entry.contains("playnite-id") && entry["playnite-id"].is_string()) { playnite_id = entry["playnite-id"].get(); } std::vector candidates; std::unordered_set seen; auto push_candidate = [&](fs::path candidate) { if (candidate.empty()) { return; } auto normalized = candidate.lexically_normal(); std::string key = normalized.generic_string(); #ifdef _WIN32 std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); #endif if (!seen.insert(key).second) { return; } candidates.emplace_back(std::move(normalized)); }; auto trimmed = trim_copy(image_path); auto normalized_path = trimmed; std::replace(normalized_path.begin(), normalized_path.end(), '\\', '/'); if (!trimmed.empty()) { fs::path direct(trimmed); push_candidate(direct); if (!direct.is_absolute()) { if (!normalized_path.empty() && normalized_path.rfind("./", 0) == 0) { fs::path rel(normalized_path.substr(2)); push_candidate(config_dir / rel); push_candidate(assets_dir / rel); } push_candidate(config_dir / direct); push_candidate(assets_dir / direct); if (normalized_path.rfind("covers/", 0) == 0) { fs::path rel(normalized_path.substr(7)); push_candidate(cover_dir / rel); } if (normalized_path.rfind("./covers/", 0) == 0) { fs::path rel(normalized_path.substr(9)); push_candidate(cover_dir / rel); } } } static const std::array fallback_exts {".png", ".jpg", ".jpeg", ".webp"}; for (const char *ext : fallback_exts) { push_candidate(cover_dir / (uuid + ext)); } if (!playnite_id.empty()) { push_candidate(cover_dir / (std::string("playnite_") + playnite_id + ".png")); } for (const auto &candidate : candidates) { if (file_is_regular(candidate)) { out_path = candidate; return true; } } fs::path fallback = assets_dir / "box.png"; if (file_is_regular(fallback)) { out_path = fallback; return true; } return false; } } catch (const std::exception &e) { BOOST_LOG(warning) << "resolve_cover_path_for_uuid: failed for uuid '" << uuid << "': " << e.what(); } catch (...) { BOOST_LOG(warning) << "resolve_cover_path_for_uuid: failed for uuid '" << uuid << "': unknown error"; } return false; } using https_server_t = SimpleWeb::Server; using args_t = SimpleWeb::CaseInsensitiveMultimap; using resp_https_t = std::shared_ptr::Response>; using req_https_t = std::shared_ptr::Request>; bool is_token_route_eligible(std::string_view path) { return path.rfind("/api/", 0) == 0 && path.rfind("/api/auth/", 0) != 0; } std::vector ordered_methods_for_catalog(const std::set> &methods) { static constexpr std::array preferred_order = { "GET", "POST", "PUT", "PATCH", "DELETE" }; std::vector ordered; ordered.reserve(methods.size()); for (const auto method : preferred_order) { if (methods.contains(std::string(method))) { ordered.emplace_back(method); } } for (const auto &method : methods) { if (std::find(preferred_order.begin(), preferred_order.end(), method) == preferred_order.end()) { ordered.push_back(method); } } return ordered; } namespace { using token_route_methods_t = std::map>, std::less<>>; std::mutex token_route_catalog_mutex; token_route_methods_t token_route_catalog; std::string normalize_route_pattern(std::string pattern) { if (!pattern.empty() && pattern.front() == '^') { pattern.erase(pattern.begin()); } if (!pattern.empty() && pattern.back() == '$') { pattern.pop_back(); } return pattern; } void clear_token_route_catalog() { std::scoped_lock lock(token_route_catalog_mutex); token_route_catalog.clear(); } void record_token_route(std::string path, std::string method) { if (!is_token_route_eligible(path)) { return; } boost::to_upper(method); std::scoped_lock lock(token_route_catalog_mutex); token_route_catalog[std::move(path)].insert(std::move(method)); } token_route_methods_t snapshot_token_route_catalog() { std::scoped_lock lock(token_route_catalog_mutex); return token_route_catalog; } bool has_active_stream_sessions() { return rtsp_stream::session_count() > 0 || webrtc_stream::has_active_or_pending_sessions(); } bool is_rtx_hdr_live_key(std::string_view key) { return key == "rtx_hdr" || key == "rtx_hdr_sdr_brightness" || key == "rtx_hdr_contrast" || key == "rtx_hdr_saturation" || key == "rtx_hdr_middle_gray" || key == "rtx_hdr_peak_brightness"; } #ifdef _WIN32 std::string encode_config_override_value(const nlohmann::json &value) { if (value.is_string()) { return value.get(); } return value.dump(); } #endif void normalize_adapter_config_pair(nlohmann::json &config_object) { if (!config_object.is_object()) { return; } const auto adapter_name = config_object.find("adapter_name"); if (adapter_name == config_object.end() || !adapter_name->is_string() || adapter_name->get_ref().empty()) { config_object.erase("adapter_pnp_id"); return; } const auto adapter_pnp_id = config_object.find("adapter_pnp_id"); if (adapter_pnp_id == config_object.end() || !adapter_pnp_id->is_string() || adapter_pnp_id->get_ref().empty()) { config_object.erase("adapter_pnp_id"); } } void normalize_adapter_config_patch(nlohmann::json &patch_object) { if (!patch_object.is_object()) { return; } const auto adapter_name = patch_object.find("adapter_name"); if (adapter_name == patch_object.end()) { // A PnP identity cannot independently replace half of the pair. patch_object.erase("adapter_pnp_id"); return; } const bool name_is_nonempty = adapter_name->is_string() && !adapter_name->get_ref().empty(); const auto adapter_pnp_id = patch_object.find("adapter_pnp_id"); const bool pnp_is_nonempty = adapter_pnp_id != patch_object.end() && adapter_pnp_id->is_string() && !adapter_pnp_id->get_ref().empty(); if (!name_is_nonempty || !pnp_is_nonempty) { // A name-only patch explicitly selects legacy matching and must clear // any persistent identity inherited from the existing file. patch_object["adapter_pnp_id"] = nullptr; } } bool can_hot_apply_during_session(const std::set &keys) { if (keys.empty()) { return false; } for (const auto &key : keys) { if (key.rfind("playnite_", 0) == 0) { continue; } if (key.rfind("steam_", 0) == 0) { continue; } if (key.rfind("realtime_stats_", 0) == 0) { continue; } if (is_rtx_hdr_live_key(key)) { continue; } if (key == "session_history_enabled") { return false; } if (key == "session_history_ttl_days" || key == "session_history_db_size_limit_mb") { continue; } return false; } return true; } } // namespace static std::string get_web_ui_host_for_local_open() { const auto address_family = net::af_from_enum_string(config::sunshine.address_family); // Derive the advertised host from the same accessor the listeners bind through. // Resolving the configured value independently let an unparseable bind_address // be advertised verbatim while the acceptors had already degraded to the family // wildcard, so the logged/tray URL pointed at an address nothing listens on. const auto bind_address = net::get_bind_address(address_family); boost::system::error_code ec; const auto address = boost::asio::ip::make_address(bind_address, ec); if (ec) { // get_bind_address only returns a validated address or the family wildcard. return address_family == net::IPV4 ? "127.0.0.1"s : "localhost"s; } if (address.is_unspecified()) { return address.is_v4() ? "127.0.0.1"s : "localhost"s; } return net::addr_to_url_escaped_string(address); } std::string get_web_ui_url(std::string_view path) { auto port_https = net::map_port(PORT_HTTPS); auto url = std::format("https://{}:{}", get_web_ui_host_for_local_open(), port_https); url += path; return url; } // Forward declaration for error helper implemented later void bad_request(resp_https_t response, req_https_t request, const std::string &error_message); void getAppCover(resp_https_t response, req_https_t request); #if defined(_WIN32) || defined(__linux__) // Platform-neutral frame limiter status (RTSS/NVCP on Windows, MangoHUD on Linux). void getFrameLimiterStatus(resp_https_t response, req_https_t request); #endif // Steam provider endpoints are available on every supported host. The // handlers remain provider-local in confighttp_steam.cpp. void getSteamStatus(resp_https_t response, req_https_t request); void getSteamGames(resp_https_t response, req_https_t request); void postSteamForceSync(resp_https_t response, req_https_t request); void postSteamLaunch(resp_https_t response, req_https_t request); #ifdef __linux__ void getLutrisStatus(resp_https_t response, req_https_t request); void getLutrisGames(resp_https_t response, req_https_t request); void postLutrisForceSync(resp_https_t response, req_https_t request); void postLutrisLaunch(resp_https_t response, req_https_t request); #endif #ifdef _WIN32 // Forward declarations for Playnite handlers implemented in confighttp_playnite.cpp void getPlayniteStatus(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void installPlaynite(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void uninstallPlaynite(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void getPlayniteGames(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void getPlayniteCategories(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void postPlayniteForceSync(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void postPlayniteCover(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void postPlayniteLaunch(std::shared_ptr::Response> response, std::shared_ptr::Request> request); // Helper to keep confighttp.cpp free of Playnite details void enhance_app_with_playnite_cover(nlohmann::json &input_tree); void enhance_app_with_playnite_icon(nlohmann::json &input_tree); // New: download Playnite-related logs as a ZIP // RTSS status endpoint (Windows-only) void getRtssStatus(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void getLosslessScalingStatus(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void downloadPlayniteLogs(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void getCrashDumpStatus(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void postCrashDumpDismiss(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void getCrashBundleManifest(std::shared_ptr::Response> response, std::shared_ptr::Request> request); void downloadCrashBundle(std::shared_ptr::Response> response, std::shared_ptr::Request> request); // Display helper: export current OS state as golden restore snapshot void postExportGoldenDisplay(resp_https_t response, req_https_t request); // Helper log readers (Windows-only) bool is_helper_log_source(const std::string &source); bool read_helper_log(const std::string &source, std::string &out); #endif enum class op_e { ADD, ///< Add client REMOVE ///< Remove client }; // SESSION COOKIE std::string sessionCookie; static std::chrono::time_point cookie_creation_time; /** * @brief Log the request details. * @param request The HTTP request object. */ void print_req(const req_https_t &request) { BOOST_LOG(debug) << "HTTP "sv << request->method << ' ' << request->path; if (!request->header.empty()) { BOOST_LOG(verbose) << "Headers:"sv; for (auto &[name, val] : request->header) { BOOST_LOG(verbose) << name << " -- " << (name == "Authorization" ? "CREDENTIALS REDACTED" : val); } } auto query = request->parse_query_string(); if (!query.empty()) { BOOST_LOG(verbose) << "Query Params:"sv; for (auto &[name, val] : query) { BOOST_LOG(verbose) << name << " -- " << val; } } } /** * @brief Get the CORS origin for localhost (no wildcard). * @return The CORS origin string. */ static std::string get_cors_origin() { std::uint16_t https_port = net::map_port(PORT_HTTPS); return std::format("https://localhost:{}", https_port); } /** * @brief Helper to add CORS headers for API responses. * @param headers The headers to add CORS to. */ void add_cors_headers(SimpleWeb::CaseInsensitiveMultimap &headers) { headers.emplace("Access-Control-Allow-Origin", get_cors_origin()); headers.emplace("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); headers.emplace("Access-Control-Allow-Headers", "Content-Type, Authorization"); } /** * @brief Send a response. * @param response The HTTP response object. * @param output_tree The JSON tree to send. */ void send_response(resp_https_t response, const nlohmann::json &output_tree, std::string_view cache_control) { SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json; charset=utf-8"); if (!cache_control.empty()) { headers.emplace("Cache-Control", cache_control); } add_cors_headers(headers); response->write(success_ok, output_tree.dump(), headers); } void send_response(resp_https_t response, const nlohmann::json &output_tree) { send_response(response, output_tree, {}); } nlohmann::json load_webrtc_ice_servers() { auto env = std::getenv("SUNSHINE_WEBRTC_ICE_SERVERS"); if (!env || !*env) { return nlohmann::json::array(); } try { auto parsed = nlohmann::json::parse(env); if (parsed.is_array()) { return parsed; } } catch (const std::exception &e) { BOOST_LOG(warning) << "WebRTC: invalid SUNSHINE_WEBRTC_ICE_SERVERS: "sv << e.what(); } return nlohmann::json::array(); } nlohmann::json webrtc_session_to_json(const webrtc_stream::SessionState &state) { nlohmann::json output; output["id"] = state.id; output["audio"] = state.audio; output["video"] = state.video; output["encoded"] = state.encoded; output["audio_packets"] = state.audio_packets; output["video_packets"] = state.video_packets; output["audio_dropped"] = state.audio_dropped; output["video_dropped"] = state.video_dropped; output["audio_queue_frames"] = state.audio_queue_frames; output["video_queue_frames"] = state.video_queue_frames; output["video_inflight_frames"] = state.video_inflight_frames; output["has_remote_offer"] = state.has_remote_offer; output["has_local_answer"] = state.has_local_answer; output["ice_candidates"] = state.ice_candidates; output["width"] = state.width ? nlohmann::json(*state.width) : nlohmann::json(nullptr); output["height"] = state.height ? nlohmann::json(*state.height) : nlohmann::json(nullptr); output["fps"] = state.fps ? nlohmann::json(*state.fps) : nlohmann::json(nullptr); output["bitrate_kbps"] = state.bitrate_kbps ? nlohmann::json(*state.bitrate_kbps) : nlohmann::json(nullptr); // WebRTC has no FEC/audio adjustment, so the requested bitrate is the same as the encoder bitrate. output["requested_bitrate_kbps"] = state.bitrate_kbps ? nlohmann::json(*state.bitrate_kbps) : nlohmann::json(nullptr); output["encoder_bitrate_kbps"] = state.bitrate_kbps ? nlohmann::json(*state.bitrate_kbps) : nlohmann::json(nullptr); output["codec"] = state.codec ? nlohmann::json(stream::canonical_codec_name(*state.codec)) : nlohmann::json(nullptr); output["hdr"] = state.hdr ? nlohmann::json(*state.hdr) : nlohmann::json(nullptr); output["yuv444"] = state.yuv444 ? nlohmann::json(*state.yuv444) : nlohmann::json(false); output["stream_gpu_model"] = state.stream_gpu_model ? nlohmann::json(*state.stream_gpu_model) : nlohmann::json(nullptr); output["audio_channels"] = state.audio_channels ? nlohmann::json(*state.audio_channels) : nlohmann::json(nullptr); output["audio_codec"] = state.audio_codec ? nlohmann::json(*state.audio_codec) : nlohmann::json(nullptr); output["profile"] = state.profile ? nlohmann::json(*state.profile) : nlohmann::json(nullptr); output["video_pacing_mode"] = state.video_pacing_mode ? nlohmann::json(*state.video_pacing_mode) : nlohmann::json(nullptr); output["video_pacing_slack_ms"] = state.video_pacing_slack_ms ? nlohmann::json(*state.video_pacing_slack_ms) : nlohmann::json(nullptr); output["video_max_frame_age_ms"] = state.video_max_frame_age_ms ? nlohmann::json(*state.video_max_frame_age_ms) : nlohmann::json(nullptr); output["last_audio_bytes"] = state.last_audio_bytes; output["last_video_bytes"] = state.last_video_bytes; output["video_bytes_total"] = state.video_bytes_total; output["audio_bytes_total"] = state.audio_bytes_total; output["bytes_sent"] = state.video_bytes_total + state.audio_bytes_total; output["last_video_idr"] = state.last_video_idr; output["last_video_frame_index"] = state.last_video_frame_index; auto now = std::chrono::steady_clock::now(); auto age_or_null = [&now](const std::optional &tp) -> nlohmann::json { if (!tp) { return nullptr; } return std::chrono::duration_cast(now - *tp).count(); }; output["last_audio_age_ms"] = age_or_null(state.last_audio_time); output["last_video_age_ms"] = age_or_null(state.last_video_time); return output; } double round_to(double value, double factor) { return std::round(value * factor) / factor; } nlohmann::json rtsp_session_to_json(const stream::session_info_t &info) { nlohmann::json output; output["uuid"] = info.uuid; output["device_name"] = info.device_name; output["width"] = info.width; output["height"] = info.height; output["fps"] = info.fps; output["encoder_bitrate_kbps"] = info.encoder_bitrate_kbps; output["requested_bitrate_kbps"] = info.requested_bitrate_kbps; output["video_format"] = info.video_format; output["codec"] = stream::canonical_codec_name(stream::video_format_name(info.video_format)); output["hdr"] = info.hdr; output["yuv444"] = info.yuv444; output["audio_channels"] = info.audio_channels; output["stream_gpu_model"] = info.stream_gpu_model; output["state"] = info.state; output["frames_sent"] = info.frames_sent; output["packets_sent"] = info.packets_sent; output["bytes_sent"] = info.bytes_sent; output["idr_requests"] = info.idr_requests; output["invalidate_ref_count"] = info.invalidate_ref_count; output["client_reported_losses"] = info.client_reported_losses; output["encode_latency_ms"] = round_to(info.encode_latency_ms, 10.0); output["last_frame_index"] = info.last_frame_index; output["uptime_seconds"] = round_to(info.uptime_seconds, 10.0); return output; } nlohmann::json host_stats_to_json(const platf::host_stats_t &stats) { nlohmann::json output; output["cpu_percent"] = stats.cpu_percent; output["cpu_temp_c"] = stats.cpu_temp_c; output["ram_used_bytes"] = stats.ram_used_bytes; output["ram_total_bytes"] = stats.ram_total_bytes; output["ram_percent"] = stats.ram_total_bytes > 0 ? (static_cast(stats.ram_used_bytes) * 100.0 / static_cast(stats.ram_total_bytes)) : 0.0; output["gpu_percent"] = stats.gpu_percent; output["gpu_encoder_percent"] = stats.gpu_encoder_percent; output["gpu_temp_c"] = stats.gpu_temp_c; const auto vram_used_bytes = stats.vram_total_bytes > 0 && stats.vram_used_bytes > stats.vram_total_bytes ? stats.vram_total_bytes : stats.vram_used_bytes; output["vram_used_bytes"] = vram_used_bytes; output["vram_total_bytes"] = stats.vram_total_bytes; output["vram_percent"] = stats.vram_total_bytes > 0 ? (static_cast(vram_used_bytes) * 100.0 / static_cast(stats.vram_total_bytes)) : 0.0; output["net_rx_bps"] = stats.net_rx_bps; output["net_tx_bps"] = stats.net_tx_bps; return output; } nlohmann::json host_info_to_json(const platf::host_info_t &info) { nlohmann::json output; output["cpu_model"] = info.cpu_model; output["gpu_model"] = info.gpu_model; output["cpu_logical_cores"] = info.cpu_logical_cores; output["ram_total_bytes"] = info.ram_total_bytes; output["vram_total_bytes"] = info.vram_total_bytes; output["net_interface"] = info.net_interface; output["net_link_speed_mbps"] = info.net_link_speed_mbps; return output; } nlohmann::json session_summary_to_json(const session_history::session_summary_t &summary) { nlohmann::json output; output["uuid"] = summary.uuid; output["protocol"] = summary.protocol; output["client_name"] = summary.client_name; output["device_name"] = summary.device_name; output["app_name"] = summary.app_name; output["width"] = summary.width; output["height"] = summary.height; output["target_fps"] = summary.target_fps; output["encoder_bitrate_kbps"] = summary.encoder_bitrate_kbps; output["requested_bitrate_kbps"] = summary.requested_bitrate_kbps; output["codec"] = summary.codec; output["hdr"] = summary.hdr; output["yuv444"] = summary.yuv444; output["audio_channels"] = summary.audio_channels; output["start_time_unix"] = summary.start_time_unix; output["end_time_unix"] = summary.end_time_unix; output["duration_seconds"] = round_to(summary.duration_seconds, 10.0); output["verdict"] = summary.verdict; output["server_version"] = summary.server_version; output["host_cpu_model"] = summary.host_cpu_model; output["host_gpu_model"] = summary.host_gpu_model; output["stream_gpu_model"] = summary.stream_gpu_model; return output; } nlohmann::json session_sample_to_json(const session_history::session_sample_t &sample) { nlohmann::json output; output["session_uuid"] = sample.session_uuid; output["timestamp_unix"] = sample.timestamp_unix; output["bytes_sent_total"] = sample.bytes_sent_total; output["packets_sent_video"] = sample.packets_sent_video; output["frames_sent"] = sample.frames_sent; output["last_frame_index"] = sample.last_frame_index; output["video_dropped"] = sample.video_dropped; output["audio_dropped"] = sample.audio_dropped; output["client_reported_losses"] = sample.client_reported_losses; output["idr_requests"] = sample.idr_requests; output["ref_invalidations"] = sample.ref_invalidations; output["encode_latency_ms"] = round_to(sample.encode_latency_ms, 10.0); output["actual_fps"] = round_to(sample.actual_fps, 10.0); output["actual_bitrate_kbps"] = round_to(sample.actual_bitrate_kbps, 10.0); output["frame_interval_jitter_ms"] = round_to(sample.frame_interval_jitter_ms, 100.0); output["host_cpu_percent"] = sample.host_cpu_percent < 0 ? -1 : round_to(sample.host_cpu_percent, 10.0); output["host_gpu_percent"] = sample.host_gpu_percent < 0 ? -1 : round_to(sample.host_gpu_percent, 10.0); output["host_gpu_encoder_percent"] = sample.host_gpu_encoder_percent < 0 ? -1 : round_to(sample.host_gpu_encoder_percent, 10.0); output["host_ram_percent"] = sample.host_ram_percent < 0 ? -1 : round_to(sample.host_ram_percent, 10.0); output["host_vram_percent"] = sample.host_vram_percent < 0 ? -1 : round_to(sample.host_vram_percent, 10.0); output["host_cpu_temp_c"] = sample.host_cpu_temp_c < 0 ? -1 : round_to(sample.host_cpu_temp_c, 10.0); output["host_gpu_temp_c"] = sample.host_gpu_temp_c < 0 ? -1 : round_to(sample.host_gpu_temp_c, 10.0); output["host_net_rx_bps"] = sample.host_net_rx_bps < 0 ? -1 : sample.host_net_rx_bps; output["host_net_tx_bps"] = sample.host_net_tx_bps < 0 ? -1 : sample.host_net_tx_bps; return output; } nlohmann::json session_event_to_json(const session_history::session_event_t &event) { nlohmann::json output; output["session_uuid"] = event.session_uuid; output["timestamp_unix"] = event.timestamp_unix; output["event_type"] = event.event_type; output["payload"] = event.payload; return output; } nlohmann::json active_session_to_json(const session_history::active_session_t &session) { nlohmann::json output; output["uuid"] = session.uuid; output["protocol"] = session.protocol; output["client_name"] = session.client_name; output["device_name"] = session.device_name; output["app_name"] = session.app_name; output["width"] = session.width; output["height"] = session.height; output["target_fps"] = session.target_fps; output["encoder_bitrate_kbps"] = session.encoder_bitrate_kbps; output["requested_bitrate_kbps"] = session.requested_bitrate_kbps; output["codec"] = session.codec; output["hdr"] = session.hdr; output["yuv444"] = session.yuv444; output["stream_gpu_model"] = session.stream_gpu_model; output["uptime_seconds"] = round_to(session.uptime_seconds, 10.0); output["actual_fps"] = round_to(session.actual_fps, 10.0); output["actual_bitrate_kbps"] = round_to(session.actual_bitrate_kbps, 10.0); output["encode_latency_ms"] = round_to(session.encode_latency_ms, 10.0); output["frame_interval_jitter_ms"] = round_to(session.frame_interval_jitter_ms, 100.0); output["frames_sent"] = session.frames_sent; output["bytes_sent"] = session.bytes_sent; output["client_reported_losses"] = session.client_reported_losses; output["idr_requests"] = session.idr_requests; return output; } nlohmann::json session_detail_to_json(const session_history::session_detail_t &detail) { nlohmann::json output = session_summary_to_json(detail.summary); output["total_samples"] = detail.total_samples; output["total_events"] = detail.total_events; output["samples_truncated"] = detail.samples_truncated; output["events_truncated"] = detail.events_truncated; output["samples"] = nlohmann::json::array(); for (const auto &sample : detail.samples) { output["samples"].push_back(session_sample_to_json(sample)); } output["events"] = nlohmann::json::array(); for (const auto &event : detail.events) { output["events"].push_back(session_event_to_json(event)); } return output; } nlohmann::json history_status_to_json(const session_history::history_status_t &status) { nlohmann::json output; output["available"] = status.available; output["degraded"] = status.degraded; output["dropped_samples"] = status.dropped_samples; output["failed_writes"] = status.failed_writes; output["pending_control_commands"] = status.pending_control_commands; output["pending_priority_commands"] = status.pending_priority_commands; output["pending_regular_commands"] = status.pending_regular_commands; output["pending_samples"] = status.pending_samples; return output; } /** * @brief Write an APIResponse to an HTTP response object. * @param response The HTTP response object. * @param api_response The APIResponse containing the structured response data. */ void write_api_response(resp_https_t response, const APIResponse &api_response) { SimpleWeb::CaseInsensitiveMultimap headers = api_response.headers; headers.emplace("Content-Type", "application/json"); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); add_cors_headers(headers); response->write(api_response.status_code, api_response.body, headers); } /** * @brief Send a 401 Unauthorized response. * @param response The HTTP response object. * @param request The HTTP request object. */ void send_unauthorized(resp_https_t response, req_https_t request) { auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); BOOST_LOG(info) << "Configuration API: ["sv << address << "] -- not authorized"sv; constexpr auto code = client_error_unauthorized; nlohmann::json tree; tree["status_code"] = code; tree["status"] = false; tree["error"] = "Unauthorized"; const SimpleWeb::CaseInsensitiveMultimap headers { {"Content-Type", "application/json"}, {"X-Frame-Options", "DENY"}, {"Content-Security-Policy", "frame-ancestors 'none';"}, {"Access-Control-Allow-Origin", get_cors_origin()} }; response->write(code, tree.dump(), headers); } /** * @brief Send a redirect response. * @param response The HTTP response object. * @param request The HTTP request object. * @param path The path to redirect to. */ void send_redirect(resp_https_t response, req_https_t request, const char *path) { auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); BOOST_LOG(info) << "Web UI: ["sv << address << "] -- redirecting"sv; const SimpleWeb::CaseInsensitiveMultimap headers { {"Location", path}, {"X-Frame-Options", "DENY"}, {"Content-Security-Policy", "frame-ancestors 'none';"} }; response->write(redirection_temporary_redirect, headers); } /** * @brief Enforce origin access policy based on configured network scope. * @return True if the remote address is permitted, false otherwise (response set). */ bool checkIPOrigin(resp_https_t response, req_https_t request) { const auto remote_address = net::addr_to_normalized_string(request->remote_endpoint().address()); const auto ip_type = net::from_address(remote_address); if (ip_type > http::origin_web_ui_allowed) { BOOST_LOG(info) << "Web UI: ["sv << remote_address << "] -- denied by origin policy"sv; nlohmann::json tree; tree["status_code"] = static_cast(SimpleWeb::StatusCode::client_error_forbidden); tree["status"] = false; tree["error"] = "Forbidden"; SimpleWeb::CaseInsensitiveMultimap headers { {"Content-Type", "application/json"}, {"X-Frame-Options", "DENY"}, {"Content-Security-Policy", "frame-ancestors 'none';"} }; add_cors_headers(headers); response->write(SimpleWeb::StatusCode::client_error_forbidden, tree.dump(), headers); return false; } return true; } /** * @brief Check authentication and authorization for an HTTP request. * @param request The HTTP request object. * @return AuthResult with outcome and response details if not authorized. */ AuthResult check_auth(const req_https_t &request) { auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); std::string auth_header; // Try Authorization header if (auto auth_it = request->header.find("authorization"); auth_it != request->header.end()) { auth_header = auth_it->second; } else { std::string token = extract_session_token_from_cookie(request->header); if (!token.empty()) { auth_header = "Session " + token; } } return check_auth(address, auth_header, request->path, request->method); } /** * @brief Authenticate the user or API token for a specific path/method. * @param response The HTTP response object. * @param request The HTTP request object. * @return True if authenticated and authorized, false otherwise. */ bool authenticate(resp_https_t response, req_https_t request) { if (auto result = check_auth(request); !result.ok) { if (result.code == StatusCode::redirection_temporary_redirect) { response->write(result.code, result.headers); } else if (!result.body.empty()) { response->write(result.code, result.body, result.headers); } else { response->write(result.code); } return false; } return true; } /** * @brief Get the list of available display devices. * @api_examples{/api/display-devices| GET| [{"device_id":"{...}","display_name":"\\\\.\\DISPLAY1","friendly_name":"Monitor"}, ...]} * @note Pass query param detail=full to include extended metadata (refresh lists, inactive displays). */ void getDisplayDevices(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } try { display_device::DeviceEnumerationDetail detail = display_device::DeviceEnumerationDetail::Minimal; const auto query = request->parse_query_string(); if (const auto it = query.find("detail"); it != query.end()) { const auto value = boost::algorithm::to_lower_copy(it->second); if (value == "full") { detail = display_device::DeviceEnumerationDetail::Full; } } else if (const auto full_it = query.find("full"); full_it != query.end()) { const auto value = boost::algorithm::to_lower_copy(full_it->second); if (value == "1" || value == "true" || value == "yes") { detail = display_device::DeviceEnumerationDetail::Full; } } const auto json_str = display_helper_integration::enumerate_devices_json(detail); nlohmann::json tree = nlohmann::json::parse(json_str); send_response(response, tree); } catch (const std::exception &e) { nlohmann::json tree; tree["status"] = false; tree["error"] = std::string {"Failed to enumerate display devices: "} + e.what(); send_response(response, tree); } } #ifdef _WIN32 /** * @brief Validate refresh capabilities for a display via EDID for frame generation health checks. * @api_examples{/api/framegen/edid-refresh?device_id=\\.\DISPLAY1| GET| {"status":true,"targets":[{"hz":120,"supported":true,"method":"range"}]}} */ void getFramegenEdidRefresh(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } try { const auto query = request->parse_query_string(); auto read_first = [&](std::initializer_list keys) -> std::string { for (const auto &key : keys) { const auto it = query.find(key); if (it != query.end()) { auto value = boost::algorithm::trim_copy(it->second); if (!value.empty()) { return value; } } } return {}; }; std::string device_hint = read_first({"device_id", "device", "id", "display"}); if (device_hint.empty()) { bad_request(response, request, "device_id query parameter is required"); return; } std::vector targets {120, 180, 240, 288}; if (const auto it = query.find("targets"); it != query.end()) { std::vector parsed; std::vector parts; boost::split(parts, it->second, boost::is_any_of(",")); for (auto part : parts) { boost::algorithm::trim(part); if (part.empty()) { continue; } try { int hz = std::stoi(part); if (hz > 0) { parsed.push_back(hz); } } catch (...) { // ignore invalid entries } } if (!parsed.empty()) { targets = std::move(parsed); } } auto result = display_helper_integration::framegen_edid_refresh_support(device_hint, targets); nlohmann::json out; if (!result) { out["status"] = false; out["error"] = "Display device not found for EDID refresh validation."; send_response(response, out); return; } out["status"] = true; out["device_id"] = result->device_id; out["device_label"] = result->device_label; out["edid_present"] = result->edid_present; if (result->max_vertical_hz) { out["max_vertical_hz"] = *result->max_vertical_hz; } if (result->max_timing_hz) { out["max_timing_hz"] = *result->max_timing_hz; } nlohmann::json targets_json = nlohmann::json::array(); for (const auto &entry : result->targets) { nlohmann::json target_json; target_json["hz"] = entry.hz; target_json["supported"] = entry.supported.has_value() ? nlohmann::json(*entry.supported) : nlohmann::json(nullptr); target_json["method"] = entry.method; targets_json.push_back(std::move(target_json)); } out["targets"] = std::move(targets_json); send_response(response, out); } catch (const std::exception &e) { bad_request(response, request, e.what()); } catch (...) { bad_request(response, request, "Failed to validate display refresh via EDID."); } } /** * @brief Health check for ViGEm (Virtual Gamepad) installation on Windows. * @api_examples{/api/health/vigem| GET| {"installed":true,"version":""}} */ void getVigemHealth(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } try { std::string version; bool installed = platf::is_vigem_installed(&version); nlohmann::json out; out["installed"] = installed; // ViGEmBus is only a requirement when nothing else can provide a virtual controller. out["required"] = is_vigem_required(); if (!version.empty()) { out["version"] = version; } send_response(response, out); } catch (...) { bad_request(response, request, "Failed to evaluate ViGEm health"); } } /** * @brief Health check for the Sunshine Vulkan HDR implicit layer (virtual-display HDR support). * @details `installed` reflects the actual HKLM registration; `enabled` reflects the configured * desired state. The web UI warns when the layer is desired but not installed. * @api_examples{/api/health/vulkan-hdr-layer| GET| {"installed":true,"enabled":true}} */ void getVulkanHdrLayerHealth(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } try { nlohmann::json out; out["installed"] = platf::is_vulkan_hdr_layer_registered(); out["enabled"] = config::video.dd.vulkan_hdr_layer; send_response(response, out); } catch (...) { bad_request(response, request, "Failed to evaluate Vulkan HDR layer health"); } } /** * @brief (Re)register the Sunshine Vulkan HDR implicit layer (repair action for the warning banner). * @details Forces registration regardless of current state; used when the installer's best-effort * registration was skipped/failed. Requires SYSTEM/admin rights (Sunshine's service runs * as SYSTEM). Does not change the configured enable/disable preference. * @api_examples{/api/health/vulkan-hdr-layer/register| POST| {"status":true,"installed":true,"enabled":true}} */ void postVulkanHdrLayerRegister(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } try { const bool ok = platf::set_vulkan_hdr_layer_enabled(true); nlohmann::json out; out["status"] = ok; out["installed"] = platf::is_vulkan_hdr_layer_registered(); out["enabled"] = config::video.dd.vulkan_hdr_layer; send_response(response, out); } catch (...) { bad_request(response, request, "Failed to register Vulkan HDR layer"); } } #endif /** * @brief Send a 404 Not Found response. * @param response The HTTP response object. * @param request The HTTP request object. * @param error_message The error message to include in the response. */ void not_found(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message = "Not Found") { constexpr auto code = client_error_not_found; nlohmann::json tree; tree["status_code"] = code; tree["error"] = error_message; SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json"); headers.emplace("Access-Control-Allow-Origin", get_cors_origin()); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); response->write(code, tree.dump(), headers); } /** * @brief Send a 400 Bad Request response. * @param response The HTTP response object. * @param request The HTTP request object. * @param error_message The error message. */ void bad_request(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message = "Bad Request") { SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json; charset=utf-8"); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); add_cors_headers(headers); nlohmann::json error = {{"error", error_message}}; response->write(client_error_bad_request, error.dump(), headers); } struct csrf_token_t { std::string token; std::chrono::steady_clock::time_point expiration; }; std::map> csrf_tokens; std::mutex csrf_tokens_mutex; constexpr auto CSRF_TOKEN_SIZE = 32; constexpr auto CSRF_TOKEN_LIFETIME = std::chrono::hours(1); std::string get_client_id(const req_https_t &request) { if (const auto auth = request->header.find("authorization"); auth != request->header.end()) { return auth->second; } return net::addr_to_normalized_string(request->remote_endpoint().address()); } std::string generate_csrf_token(const std::string &client_id) { const auto now = std::chrono::steady_clock::now(); std::scoped_lock lock(csrf_tokens_mutex); std::erase_if(csrf_tokens, [&now](const auto &entry) { return entry.second.expiration < now; }); if (const auto existing = csrf_tokens.find(client_id); existing != csrf_tokens.end()) { return existing->second.token; } std::string token = crypto::rand_alphabet(CSRF_TOKEN_SIZE); csrf_tokens[client_id] = csrf_token_t {token, now + CSRF_TOKEN_LIFETIME}; return token; } bool validate_stored_csrf_token(const resp_https_t &response, const req_https_t &request, std::string_view client_id, std::string_view provided_token) { std::scoped_lock lock(csrf_tokens_mutex); auto token_it = csrf_tokens.find(client_id); if (token_it == csrf_tokens.end()) { bad_request(response, request, "Invalid CSRF token"); return false; } const auto now = std::chrono::steady_clock::now(); if (token_it->second.expiration < now) { csrf_tokens.erase(token_it); bad_request(response, request, "CSRF token expired"); return false; } if (token_it->second.token != provided_token) { bad_request(response, request, "Invalid CSRF token"); return false; } return true; } bool validate_csrf_token(const resp_https_t &response, const req_https_t &request, const std::string &client_id) { auto is_allowed_origin = [](std::string_view url) { return std::ranges::any_of(config::sunshine.csrf_allowed_origins, [&url](const std::string &allowed_origin) { if (url.rfind(allowed_origin, 0) != 0) { return false; } const size_t len = allowed_origin.length(); return url.length() == len || url[len] == ':' || url[len] == '/'; }); }; const auto origin_it = request->header.find("Origin"); if (origin_it != request->header.end() && is_allowed_origin(origin_it->second)) { return true; } const auto referer_it = request->header.find("Referer"); if (referer_it != request->header.end() && is_allowed_origin(referer_it->second)) { return true; } if (origin_it == request->header.end() && referer_it == request->header.end()) { return true; } if (const auto header_it = request->header.find("X-CSRF-Token"); header_it != request->header.end()) { return validate_stored_csrf_token(response, request, client_id, header_it->second); } auto query_params = request->parse_query_string(); if (const auto query_it = query_params.find("csrf_token"); query_it != query_params.end()) { return validate_stored_csrf_token(response, request, client_id, query_it->second); } bad_request(response, request, "Missing CSRF token"); return false; } void getCSRFToken(resp_https_t response, req_https_t request) { // The browser needs a token before login. Issuing one does not grant any // authority: it is bound to the request's client identity and every API // handler still performs its own authentication and authorization checks. nlohmann::json output_tree; output_tree["csrf_token"] = generate_csrf_token(get_client_id(request)); send_response(response, output_tree); } void service_unavailable(resp_https_t response, const std::string &error_message) { SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json; charset=utf-8"); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); add_cors_headers(headers); nlohmann::json error = {{"error", error_message}}; response->write(SimpleWeb::StatusCode::server_error_service_unavailable, error.dump(), headers); } void conflict(resp_https_t response, const std::string &error_message) { SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json; charset=utf-8"); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); add_cors_headers(headers); nlohmann::json error = {{"error", error_message}}; response->write(SimpleWeb::StatusCode::client_error_conflict, error.dump(), headers); } void gateway_timeout(resp_https_t response, const std::string &error_message) { SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json; charset=utf-8"); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); add_cors_headers(headers); nlohmann::json error = {{"error", error_message}}; response->write(SimpleWeb::StatusCode::server_error_gateway_timeout, error.dump(), headers); } /** * @brief Validate the request content type and send bad request when mismatch. * @param response The HTTP response object. * @param request The HTTP request object. * @param contentType The required content type. */ bool validateContentType(resp_https_t response, req_https_t request, const std::string_view &contentType) { auto requestContentType = request->header.find("content-type"); if (requestContentType == request->header.end()) { bad_request(response, request, "Content type not provided"); return false; } // Extract the media type part before any parameters (e.g., charset) std::string actualContentType = requestContentType->second; size_t semicolonPos = actualContentType.find(';'); if (semicolonPos != std::string::npos) { actualContentType = actualContentType.substr(0, semicolonPos); } // Trim whitespace and convert to lowercase for case-insensitive comparison boost::algorithm::trim(actualContentType); boost::algorithm::to_lower(actualContentType); std::string expectedContentType(contentType); boost::algorithm::to_lower(expectedContentType); if (actualContentType != expectedContentType) { bad_request(response, request, "Content type mismatch"); return false; } return true; } bool check_content_type(resp_https_t response, req_https_t request, const std::string_view &contentType) { return validateContentType(response, request, contentType); } /** * @brief Validates the application index and sends error response if invalid. * @param response The HTTP response object. * @param request The HTTP request object. * @param index The application index/id. */ bool check_app_index(resp_https_t response, req_https_t request, int index) { std::string file = file_handler::read_file(config::stream.file_apps.c_str()); nlohmann::json file_tree = nlohmann::json::parse(file); if (const auto &apps = file_tree["apps"]; index < 0 || index >= static_cast(apps.size())) { std::string error; if (const int max_index = static_cast(apps.size()) - 1; max_index < 0) { error = "No applications found"; } else { error = std::format("'index' {} out of range, max index is {}", index, max_index); } bad_request(std::move(response), std::move(request), error); return false; } return true; } /** * @brief Send an HTTP redirect. */ // Consolidated redirect helper: use the const char* variant below. namespace { bool is_safe_web_path(std::string_view relative_path) { if (relative_path.empty() || relative_path.front() == '/' || relative_path.find('\\') != std::string_view::npos || relative_path.find('%') != std::string_view::npos || relative_path.find(':') != std::string_view::npos || relative_path.find('\0') != std::string_view::npos) { return false; } const fs::path path {relative_path}; if (path.is_absolute() || path.has_root_name() || path.has_root_directory()) { return false; } return std::ranges::none_of(path, [](const fs::path &part) { return part == ".."; }); } SimpleWeb::CaseInsensitiveMultimap web_headers(std::string_view content_type, bool cache_immutable) { SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", std::string {content_type}); headers.emplace("Cache-Control", cache_immutable ? "public, max-age=31536000, immutable" : "no-cache"); headers.emplace("Content-Security-Policy", "default-src 'self'; base-uri 'self'; connect-src 'self' https://api.github.com https://raw.githubusercontent.com wss:; font-src 'self'; " "form-action 'self'; frame-ancestors 'none'; img-src 'self' https://images.igdb.com data: blob:; media-src 'self' blob:; " "object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:"); headers.emplace("Referrer-Policy", "no-referrer"); headers.emplace("X-Content-Type-Options", "nosniff"); headers.emplace("X-Frame-Options", "DENY"); return headers; } void serve_web_file(resp_https_t response, req_https_t request, std::string_view relative_path) { if (!is_safe_web_path(relative_path)) { not_found(response, request); return; } const fs::path file_path = fs::path {WEB_DIR} / fs::path {relative_path}; std::error_code error; if (!fs::is_regular_file(file_path, error) || error) { not_found(response, request); return; } auto extension = file_path.extension().string(); if (!extension.empty() && extension.front() == '.') { extension.erase(0, 1); } boost::algorithm::to_lower(extension); const auto mime_type = mime_types.find(extension); if (mime_type == mime_types.end()) { not_found(response, request); return; } std::ifstream input {file_path, std::ios::binary}; if (!input) { not_found(response, request); return; } const bool cache_immutable = extension == "css" || extension == "js" || extension == "woff2"; response->write(success_ok, input, web_headers(mime_type->second, cache_immutable)); } } // namespace /** * @brief Serve a built browser asset from the isolated web root. */ void getWebAsset(resp_https_t response, req_https_t request) { print_req(request); if (request->path.size() <= 1) { not_found(response, request); return; } const std::string relative_path = request->path.substr(1); serve_web_file(std::move(response), std::move(request), relative_path); } /** * @brief Serve the Vue application shell for browser navigation routes. */ void getWebUi(resp_https_t response, req_https_t request) { print_req(request); const std::string &path = request->path; const std::string_view path_view {path}; static constexpr std::array reserved_prefixes {"/api"sv, "/assets"sv, "/covers"sv, "/images"sv}; if (std::ranges::any_of(reserved_prefixes, [&path](std::string_view prefix) { return std::string_view {path}.starts_with(prefix); })) { not_found(response, request); return; } const bool is_v2_static_path = path_view == "/v2/assets" || path_view.starts_with("/v2/assets/") || path_view == "/v2/images" || path_view.starts_with("/v2/images/"); if (is_v2_static_path) { not_found(response, request); return; } // Missing files should remain 404s. Extension-free paths are client-side // navigation routes and receive the single application shell. if (fs::path {path}.has_extension()) { not_found(response, request); return; } serve_web_file(std::move(response), std::move(request), "v2/index.html"); } /** * @brief Get the favicon image. * @param response The HTTP response object. * @param request The HTTP request object. */ void getFaviconImage(resp_https_t response, req_https_t request) { print_req(request); std::ifstream in(WEB_DIR "images/apollo.ico", std::ios::binary); SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "image/x-icon"); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); response->write(success_ok, in, headers); } /** * @brief Get the Apollo logo image. * @param response The HTTP response object. * @param request The HTTP request object. * * @todo combine function with getFaviconImage and possibly getNodeModules * @todo use mime_types map */ void getApolloLogoImage(resp_https_t response, req_https_t request) { print_req(request); std::ifstream in(WEB_DIR "images/logo-apollo-45.png", std::ios::binary); SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "image/png"); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); response->write(success_ok, in, headers); } /** * @brief Check if a path is a child of another path. * @param base The base path. * @param query The path to check. * @return True if the path is a child of the base path, false otherwise. */ bool isChildPath(fs::path const &base, fs::path const &query) { auto relPath = fs::relative(base, query); return *(relPath.begin()) != fs::path(".."); } /** * @brief Get an asset from the node_modules directory. * @param response The HTTP response object. * @param request The HTTP request object. */ void getNodeModules(resp_https_t response, req_https_t request) { print_req(request); fs::path webDirPath(WEB_DIR); fs::path nodeModulesPath(webDirPath / "assets"); // .relative_path is needed to shed any leading slash that might exist in the request path auto filePath = fs::weakly_canonical(webDirPath / fs::path(request->path).relative_path()); // Don't do anything if file does not exist or is outside the assets directory if (!isChildPath(filePath, nodeModulesPath)) { BOOST_LOG(warning) << "Someone requested a path " << filePath << " that is outside the assets folder"; bad_request(response, request); return; } if (!fs::exists(filePath)) { not_found(response, request); return; } auto relPath = fs::relative(filePath, webDirPath); // get the mime type from the file extension mime_types map // remove the leading period from the extension auto mimeType = mime_types.find(relPath.extension().string().substr(1)); if (mimeType == mime_types.end()) { bad_request(response, request); return; } SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", mimeType->second); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); std::ifstream in(filePath.string(), std::ios::binary); response->write(success_ok, in, headers); } /** * @brief Get the list of available applications. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/apps| GET| null} */ void getApps(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); try { std::lock_guard apps_lock {apps_file_mutex()}; std::string content = file_handler::read_file(config::stream.file_apps.c_str()); nlohmann::json file_tree = nlohmann::json::parse(content); file_tree["current_app"] = proc::proc.get_running_app_uuid(); file_tree["host_uuid"] = http::unique_id; file_tree["host_name"] = config::nvhttp.sunshine_name; #ifdef _WIN32 // No auto-insert here; controlled by config 'playnite_fullscreen_entry_enabled'. #endif // Legacy versions of Sunshine used strings for boolean and integers, let's convert them // List of keys to convert to boolean std::vector boolean_keys = { "exclude-global-prep-cmd", "exclude-global-state-cmd", "elevated", "auto-detach", "wait-all", "terminate-on-pause", "virtual-display", "allow-client-commands", "use-app-identity", "per-client-app-identity", "prefer-10bit-sdr", "gen1-framegen-fix", "gen2-framegen-fix", "dlss-framegen-capture-fix", // backward compatibility "lossless-scaling-enabled", "lossless-scaling-framegen", "lossless-scaling-legacy-auto-detect" }; // List of keys to convert to integers std::vector integer_keys = { "exit-timeout", "lossless-scaling-target-fps", "lossless-scaling-rtss-limit", "scale-factor", "lossless-scaling-launch-delay" }; bool mutated = ensure_remote_session_apps(file_tree); auto normalize_lossless_profile_overrides = [](nlohmann::json &node) -> bool { if (!node.is_object()) { return false; } bool changed = false; auto convert_int = [&](const char *key) { if (!node.contains(key)) { return; } auto &value = node[key]; if (value.is_string()) { try { value = std::stoi(value.get()); changed = true; } catch (...) { } } }; auto convert_bool = [&](const char *key) { if (!node.contains(key)) { return; } auto &value = node[key]; if (value.is_string()) { auto text = value.get(); if (text == "true" || text == "false") { value = (text == "true"); changed = true; } else if (text == "1" || text == "0") { value = (text == "1"); changed = true; } } }; convert_bool("performance-mode"); convert_int("flow-scale"); convert_int("resolution-scale"); convert_int("sharpening"); convert_bool("anime4k-vrs"); if (node.contains("scaling-type") && node["scaling-type"].is_string()) { auto text = node["scaling-type"].get(); boost::algorithm::to_lower(text); node["scaling-type"] = text; changed = true; } if (node.contains("anime4k-size") && node["anime4k-size"].is_string()) { auto text = node["anime4k-size"].get(); boost::algorithm::to_upper(text); node["anime4k-size"] = text; changed = true; } return changed; }; // Walk fileTree and convert true/false strings to boolean or integer values for (auto &app : file_tree["apps"]) { for (const auto &key : boolean_keys) { if (app.contains(key) && app[key].is_string()) { app[key] = app[key] == "true"; mutated = true; } } for (const auto &key : integer_keys) { if (app.contains(key) && app[key].is_string()) { app[key] = std::stoi(app[key].get()); mutated = true; } } if (app.contains("lossless-scaling-recommended")) { mutated = normalize_lossless_profile_overrides(app["lossless-scaling-recommended"]) || mutated; } if (app.contains("lossless-scaling-custom")) { mutated = normalize_lossless_profile_overrides(app["lossless-scaling-custom"]) || mutated; } if (app.contains("prep-cmd")) { for (auto &prep : app["prep-cmd"]) { if (prep.contains("elevated") && prep["elevated"].is_string()) { prep["elevated"] = prep["elevated"] == "true"; mutated = true; } } } if (app.contains("state-cmd")) { for (auto &state : app["state-cmd"]) { if (state.contains("elevated") && state["elevated"].is_string()) { state["elevated"] = state["elevated"] == "true"; mutated = true; } } } // Ensure each app has a UUID (auto-insert if missing/empty) if (!app.contains("uuid") || app["uuid"].is_null() || (app["uuid"].is_string() && app["uuid"].get().empty())) { app["uuid"] = uuid_util::uuid_t::generate().string(); mutated = true; } } // If any normalization occurred, persist back to disk if (mutated) { try { file_handler::write_file(config::stream.file_apps.c_str(), file_tree.dump(4)); proc::refresh(config::stream.file_apps, false); } catch (std::exception &e) { BOOST_LOG(warning) << "GetApps persist normalization failed: "sv << e.what(); } } // Add computed app ids for UI clients only after normalized records are // persisted. These fields are response metadata and must never enter apps.json. if (file_tree.contains("apps") && file_tree["apps"].is_array()) { try { const auto apps_snapshot = proc::proc.get_apps(); const auto count = std::min(file_tree["apps"].size(), apps_snapshot.size()); for (size_t idx = 0; idx < count; ++idx) { auto &app = file_tree["apps"][idx]; app["id"] = apps_snapshot[idx].id; app["index"] = static_cast(idx); } } catch (...) { } } // Attach cache-busting stamps for artwork so the UI re-fetches when an icon/cover is // regenerated (the icon/cover URLs are otherwise stable and get cached by the browser). // Added after persistence so these transient fields never get written to apps.json. if (file_tree.contains("apps") && file_tree["apps"].is_array()) { auto art_stamp = [](const std::string &path) -> long long { std::error_code ec; auto t = fs::last_write_time(fs::path(path), ec); if (ec) { return 0; } return static_cast(t.time_since_epoch().count()); }; for (auto &app : file_tree["apps"]) { try { if (app.contains("playnite-icon-path") && app["playnite-icon-path"].is_string()) { const auto v = art_stamp(app["playnite-icon-path"].get()); if (v) { app["playnite-icon-version"] = v; } } if (app.contains("image-path") && app["image-path"].is_string()) { const auto v = art_stamp(app["image-path"].get()); if (v) { app["image-version"] = v; } } const auto control = configurable_remote_session(app.value("uuid", "")); if (control != remote_session::control_e::none) { app["remote-session"] = control == remote_session::control_e::input ? "input" : "monitor"; } } catch (...) { } } } send_response(response, file_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "GetApps: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Save an application. To save a new application the index must be `-1`. To update an existing application, you must provide the current index of the application. * @param response The HTTP response object. * @param request The HTTP request object. * The body for the post request should be JSON serialized in the following format: * @code{.json} * { * "name": "Application Name", * "output": "Log Output Path", * "cmd": "Command to run the application", * "exclude-global-prep-cmd": false, * "elevated": false, * "auto-detach": true, * "wait-all": true, * "exit-timeout": 5, * "prep-cmd": [ * { * "do": "Command to prepare", * "undo": "Command to undo preparation", * "elevated": false * } * ], * "detached": [ * "Detached command" * ], * "image-path": "Full path to the application image. Must be a png file.", * "uuid": "aaaa-bbbb" * } * @endcode * * @api_examples{/api/apps| POST| {"name":"Hello, World!","uuid": "aaaa-bbbb"}} */ void saveApp(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); std::stringstream ss; ss << request->content.rdbuf(); BOOST_LOG(info) << config::stream.file_apps; try { std::lock_guard apps_lock {apps_file_mutex()}; // TODO: Input Validation // Read the input JSON from the request body. nlohmann::json input_tree = nlohmann::json::parse(ss.str()); int index = input_tree.value("index", -1); // Read the existing apps file. std::string content = file_handler::read_file(config::stream.file_apps.c_str()); nlohmann::json file_tree = nlohmann::json::parse(content); // Migrate/merge the new app into the file tree. proc::migrate_apps(&file_tree, &input_tree); if (input_tree.contains("config-overrides") && input_tree["config-overrides"].is_object()) { auto &overrides = input_tree["config-overrides"]; if (overrides.contains("nvenc_force_split_encode") && !overrides.contains("nvenc_split_encode")) { overrides["nvenc_split_encode"] = overrides["nvenc_force_split_encode"]; } overrides.erase("nvenc_force_split_encode"); normalize_adapter_config_pair(overrides); } // If image-path omitted but we have a Playnite id, let Playnite helper resolve a cover (Windows) #ifdef _WIN32 enhance_app_with_playnite_cover(input_tree); enhance_app_with_playnite_icon(input_tree); try { if (input_tree.contains("playnite-id") && input_tree["playnite-id"].is_string()) { const auto playnite_id = input_tree["playnite-id"].get(); if (!playnite_id.empty()) { input_tree["uuid"] = platf::playnite::sync::policy::canonical_playnite_app_uuid(playnite_id); } } } catch (...) {} #endif #ifndef _WIN32 if ((input_tree.contains("gen1-framegen-fix") && input_tree["gen1-framegen-fix"].is_boolean() && input_tree["gen1-framegen-fix"].get()) || (input_tree.contains("dlss-framegen-capture-fix") && input_tree["dlss-framegen-capture-fix"].is_boolean() && input_tree["dlss-framegen-capture-fix"].get())) { bad_request(response, request, "Frame generation capture fixes are only supported on Windows hosts."); return; } if (input_tree.contains("gen2-framegen-fix") && input_tree["gen2-framegen-fix"].is_boolean() && input_tree["gen2-framegen-fix"].get()) { bad_request(response, request, "Frame generation capture fixes are only supported on Windows hosts."); return; } #else // Migrate old field name to new for backward compatibility if (input_tree.contains("dlss-framegen-capture-fix") && !input_tree.contains("gen1-framegen-fix")) { input_tree["gen1-framegen-fix"] = input_tree["dlss-framegen-capture-fix"]; } // Remove old field to avoid duplication input_tree.erase("dlss-framegen-capture-fix"); #endif const auto remote_control = configurable_remote_session(input_tree.value("uuid", "")); input_tree.erase("remote-session"); if (remote_control != remote_session::control_e::none) { const auto synthetic = remote_session::synthetic(remote_control); input_tree["uuid"] = synthetic.uuid; input_tree["name"] = synthetic.title; } auto &apps_node = file_tree["apps"]; if (!apps_node.is_array()) { apps_node = nlohmann::json::array(); } std::string input_uuid; try { if (input_tree.contains("uuid") && input_tree["uuid"].is_string()) { input_uuid = input_tree["uuid"].get(); } } catch (...) {} if (auto uuid_index = find_app_index_by_uuid(apps_node, input_uuid)) { index = static_cast(*uuid_index); } input_tree.erase("index"); if (index == -1) { if (input_uuid.empty()) { input_uuid = uuid_util::uuid_t::generate().string(); input_tree["uuid"] = input_uuid; } apps_node.push_back(input_tree); } else { nlohmann::json newApps = nlohmann::json::array(); for (size_t i = 0; i < apps_node.size(); ++i) { if (i == index) { try { if ((!input_tree.contains("uuid") || input_tree["uuid"].is_null() || (input_tree["uuid"].is_string() && input_tree["uuid"].get().empty())) && apps_node[i].contains("uuid") && apps_node[i]["uuid"].is_string()) { input_tree["uuid"] = apps_node[i]["uuid"].get(); } } catch (...) {} newApps.push_back(input_tree); } else { newApps.push_back(apps_node[i]); } } file_tree["apps"] = newApps; } // Update apps file and refresh client cache confighttp::refresh_client_apps_cache(file_tree); #ifdef _WIN32 const auto edited_uuid = input_tree.value("uuid", ""s); if (!edited_uuid.empty()) { (void) proc::proc.update_active_app_live_rtx_hdr_overrides(edited_uuid); } #endif // Prepare and send the output response. nlohmann::json outputTree; outputTree["status"] = true; send_response(response, outputTree); } catch (std::exception &e) { BOOST_LOG(warning) << "SaveApp: "sv << e.what(); bad_request(response, request, e.what()); } } #ifdef _WIN32 void updateAppRtxHdrLive(resp_https_t response, req_https_t request) { if (!check_content_type(response, request, "application/json")) { return; } if (!authenticate(response, request)) { return; } print_req(request); std::stringstream ss; ss << request->content.rdbuf(); try { nlohmann::json input_tree = nlohmann::json::parse(ss); if (!input_tree.is_object()) { bad_request(response, request, "Request body must be a JSON object"); return; } const std::string uuid = input_tree.value("uuid", ""); if (uuid.empty()) { bad_request(response, request, "Missing application UUID"); return; } std::unordered_map rtx_hdr_overrides; const auto overrides_it = input_tree.find("config-overrides"); if (overrides_it != input_tree.end()) { if (!overrides_it->is_object()) { bad_request(response, request, "config-overrides must be an object"); return; } for (const auto &item : overrides_it->items()) { const std::string key = item.key(); if (!is_rtx_hdr_live_key(key) || item.value().is_null()) { continue; } rtx_hdr_overrides[key] = encode_config_override_value(item.value()); } } nlohmann::json output_tree; output_tree["status"] = true; output_tree["applied"] = proc::proc.update_active_app_live_rtx_hdr_overrides(uuid, rtx_hdr_overrides); send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "UpdateAppRtxHdrLive: "sv << e.what(); bad_request(response, request, e.what()); } } #endif /** * @brief Serve a specific application's cover image by UUID. * Looks for files named @c uuid with a supported image extension in the covers directory. * @api_examples{/api/apps/@c uuid/cover| GET| null} */ void getAppCover(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); if (request->path_match.size() < 2) { bad_request(response, request, "Application uuid required"); return; } std::string uuid = request->path_match[1]; if (uuid.empty()) { bad_request(response, request, "Application uuid required"); return; } fs::path cover_path; if (!resolve_cover_path_for_uuid(uuid, cover_path)) { not_found(response, request); return; } std::ifstream in(cover_path, std::ios::binary); if (!in) { not_found(response, request); return; } std::string ext = cover_path.extension().string(); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (!ext.empty() && ext.front() == '.') { ext.erase(ext.begin()); } std::string mime = "image/png"; if (!ext.empty()) { auto it = mime_types.find(ext); if (it != mime_types.end()) { mime = it->second; } } SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", mime); headers.emplace("Cache-Control", "private, max-age=300"); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); response->write(success_ok, in, headers); } /** * @brief Serve a Playnite application's icon image by UUID. */ void getAppIcon(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } std::string uuid; if (request->path_match.size() > 1) { uuid = request->path_match[1]; } if (uuid.empty()) { bad_request(response, request, "Missing application uuid"); return; } try { std::string content = file_handler::read_file(config::stream.file_apps.c_str()); nlohmann::json file_tree = nlohmann::json::parse(content); if (!file_tree.contains("apps") || !file_tree["apps"].is_array()) { not_found(response, request); return; } fs::path icon_path; for (const auto &app : file_tree["apps"]) { if (!app.contains("uuid") || !app["uuid"].is_string()) { continue; } if (app["uuid"].get() != uuid) { continue; } if (app.contains("playnite-icon-path") && app["playnite-icon-path"].is_string()) { std::string raw_path = app["playnite-icon-path"].get(); boost::algorithm::trim(raw_path); if (!raw_path.empty()) { icon_path = raw_path; } } break; } if (icon_path.empty() || !fs::exists(icon_path)) { not_found(response, request); return; } std::ifstream in(icon_path, std::ios::binary); if (!in) { bad_request(response, request, "Unable to read application icon"); return; } SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "image/png"); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); response->write(success_ok, in, headers); } catch (std::exception &e) { BOOST_LOG(warning) << "GetAppIcon: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Upload or set a specific application's cover image by UUID. * Accepts either a JSON body with {"url": "..."} (restricted to images.igdb.com) or {"data": base64}. * Saves to appdata/covers/@c uuid.@c ext where ext is derived from URL or defaults to .png for data. * @api_examples{/api/apps/@c uuid/cover| POST| {"url":"https://images.igdb.com/.../abc.png"}} */ /** * @brief Close the currently running application. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/apps/close| POST| null} */ void closeApp(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); proc::proc.terminate(); nlohmann::json output_tree; output_tree["status"] = true; send_response(response, output_tree); } /** * @brief Reorder applications. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/apps/reorder| POST| {"order": ["aaaa-bbbb", "cccc-dddd"]}} */ void reorderApps(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); try { std::stringstream ss; ss << request->content.rdbuf(); nlohmann::json input_tree = nlohmann::json::parse(ss.str()); nlohmann::json output_tree; // Read the existing apps file. std::string content = file_handler::read_file(config::stream.file_apps.c_str()); nlohmann::json fileTree = nlohmann::json::parse(content); // Get the desired order of UUIDs from the request. if (!input_tree.contains("order") || !input_tree["order"].is_array()) { throw std::runtime_error("Missing or invalid 'order' array in request body"); } const auto &order_uuids_json = input_tree["order"]; // Get the original apps array from the fileTree. // Default to an empty array if "apps" key is missing or if it's present but not an array (after logging an error). nlohmann::json original_apps_list = nlohmann::json::array(); if (fileTree.contains("apps")) { if (fileTree["apps"].is_array()) { original_apps_list = fileTree["apps"]; } else { // "apps" key exists but is not an array. This is a malformed state. BOOST_LOG(error) << "ReorderApps: 'apps' key in apps configuration file ('" << config::stream.file_apps << "') is present but not an array."; throw std::runtime_error("'apps' in file is not an array, cannot reorder."); } } else { // "apps" key is missing. Treat as an empty list. Reordering an empty list is valid. BOOST_LOG(debug) << "ReorderApps: 'apps' key missing in apps configuration file ('" << config::stream.file_apps << "'). Treating as an empty list for reordering."; // original_apps_list is already an empty array, so no specific action needed here. } nlohmann::json reordered_apps_list = nlohmann::json::array(); std::vector item_moved(original_apps_list.size(), false); // Phase 1: Place apps according to the 'order' array from the request. // Iterate through the desired order of UUIDs. for (const auto &uuid_json_value : order_uuids_json) { if (!uuid_json_value.is_string()) { BOOST_LOG(warning) << "ReorderApps: Encountered a non-string UUID in the 'order' array. Skipping this entry."; continue; } std::string target_uuid = uuid_json_value.get(); bool found_match_for_ordered_uuid = false; // Find the first unmoved app in the original list that matches the current target_uuid. for (size_t i = 0; i < original_apps_list.size(); ++i) { if (item_moved[i]) { continue; // This specific app object has already been placed. } const auto &app_item = original_apps_list[i]; // Ensure the app item is an object and has a UUID to match against. if (app_item.is_object() && app_item.contains("uuid") && app_item["uuid"].is_string()) { if (app_item["uuid"].get() == target_uuid) { reordered_apps_list.push_back(app_item); // Add the found app object to the new list. item_moved[i] = true; // Mark this specific object as moved. found_match_for_ordered_uuid = true; break; // Found an app for this UUID, move to the next UUID in the 'order' array. } } } if (!found_match_for_ordered_uuid) { // This means a UUID specified in the 'order' array was not found in the original_apps_list // among the currently available (unmoved) app objects. // Per instruction "If the uuid is missing from the original json file, omit it." BOOST_LOG(debug) << "ReorderApps: UUID '" << target_uuid << "' from 'order' array not found in available apps list or its matching app was already processed. Omitting."; } } // Phase 2: Append any remaining apps from the original list that were not explicitly ordered. // These are app objects that were not marked 'item_moved' in Phase 1. for (size_t i = 0; i < original_apps_list.size(); ++i) { if (!item_moved[i]) { reordered_apps_list.push_back(original_apps_list[i]); } } // Update the fileTree with the new, reordered list of apps. fileTree["apps"] = reordered_apps_list; // Write the modified fileTree back to the apps configuration file. file_handler::write_file(config::stream.file_apps.c_str(), fileTree.dump(4)); // Notify relevant parts of the system that the apps configuration has changed. proc::refresh(config::stream.file_apps, false); output_tree["status"] = true; send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "ReorderApps: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Delete an application. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/apps/delete | POST| { uuid: 'aaaa-bbbb' }} */ void deleteApp(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); const bool is_delete_method = request->method == "DELETE"; std::optional token_from_path; std::optional index_from_path; if (request->path_match.size() > 1) { token_from_path = request->path_match[1]; try { index_from_path = static_cast(std::stoul(*token_from_path)); } catch (...) { } } std::stringstream ss; ss << request->content.rdbuf(); std::string raw_body = ss.str(); std::optional uuid; std::optional index_from_body; if (!raw_body.empty()) { if (!validateContentType(response, request, "application/json")) { return; } try { nlohmann::json input_tree = nlohmann::json::parse(raw_body); if (input_tree.contains("uuid") && input_tree["uuid"].is_string()) { uuid = input_tree["uuid"].get(); } if (input_tree.contains("index") && input_tree["index"].is_number_integer()) { auto idx = input_tree["index"].get(); if (idx >= 0) { index_from_body = static_cast(idx); } } } catch (const std::exception &e) { bad_request(response, request, e.what()); return; } } else if (!is_delete_method) { bad_request(response, request, "Missing request body"); return; } std::optional target_index = index_from_body ? index_from_body : index_from_path; try { std::lock_guard apps_lock {apps_file_mutex()}; std::string content = file_handler::read_file(config::stream.file_apps.c_str()); nlohmann::json file_tree = nlohmann::json::parse(content); if (!file_tree.contains("apps") || !file_tree["apps"].is_array()) { bad_request(response, request, "Apps configuration missing or invalid"); return; } auto &apps_node = file_tree["apps"]; if (!uuid && !index_from_body && token_from_path) { if (auto resolved_index = resolve_app_index_token(apps_node, *token_from_path)) { target_index = resolved_index; const auto &app_entry = apps_node[*resolved_index]; if (app_entry.is_object() && app_entry.contains("uuid") && app_entry["uuid"].is_string()) { uuid = app_entry["uuid"].get(); } } else { bad_request(response, request, std::format("Application '{}' not found", *token_from_path)); return; } } std::optional protected_index; if (uuid && !uuid->empty()) { protected_index = find_app_index_by_uuid(apps_node, *uuid); } else if (target_index && *target_index < apps_node.size()) { protected_index = *target_index; } if (protected_index && configurable_remote_session(apps_node[*protected_index].value("uuid", "")) != remote_session::control_e::none) { bad_request(response, request, "Remote session applications cannot be deleted"); return; } nlohmann::json::array_t new_apps; new_apps.reserve(apps_node.size()); #ifdef _WIN32 // Detect if the app being removed is the Playnite fullscreen launcher auto is_playnite_fullscreen = [](const nlohmann::json &app) -> bool { try { if (app.contains("playnite-fullscreen") && app["playnite-fullscreen"].is_boolean() && app["playnite-fullscreen"].get()) { return true; } if (app.contains("cmd") && app["cmd"].is_string()) { auto s = app["cmd"].get(); if (s.find("playnite-launcher") != std::string::npos && s.find("--fullscreen") != std::string::npos) { return true; } } if (app.contains("name") && app["name"].is_string() && app["name"].get() == "Playnite (Fullscreen)") { return true; } } catch (...) {} return false; }; #endif bool removed = false; bool disabled_fullscreen_flag = false; for (size_t i = 0; i < apps_node.size(); ++i) { const auto &app_entry = apps_node[i]; auto app_uuid = app_entry.contains("uuid") && app_entry["uuid"].is_string() ? app_entry["uuid"].get() : std::string {}; bool match = false; if (uuid && !uuid->empty()) { match = app_uuid == *uuid; } else if (!uuid && target_index && *target_index == i) { match = true; if (!app_uuid.empty()) { uuid = app_uuid; } } if (!match) { new_apps.push_back(app_entry); continue; } removed = true; #ifdef _WIN32 try { if (is_playnite_fullscreen(app_entry)) { auto current_cfg = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str())); current_cfg["playnite_fullscreen_entry_enabled"] = "false"; std::stringstream config_stream; for (const auto &kv : current_cfg) { config_stream << kv.first << " = " << kv.second << std::endl; } file_handler::write_file(config::sunshine.config_file.c_str(), config_stream.str()); config::apply_config_now(); disabled_fullscreen_flag = true; } } catch (...) { } #endif } if (!removed) { bad_request(response, request, "App to delete not found"); return; } file_tree["apps"] = new_apps; file_handler::write_file(config::stream.file_apps.c_str(), file_tree.dump(4)); proc::refresh(config::stream.file_apps, false); nlohmann::json output_tree; output_tree["status"] = true; if (disabled_fullscreen_flag) { output_tree["playniteFullscreenDisabled"] = true; } send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "DeleteApp: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Get the list of paired clients. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/clients/list| GET| null} */ void getClients(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); nlohmann::json named_certs = nvhttp::get_all_clients(); nlohmann::json output_tree; output_tree["named_certs"] = named_certs; #ifdef _WIN32 output_tree["platform"] = "windows"; #endif output_tree["status"] = true; output_tree["platform"] = SUNSHINE_PLATFORM; // The list changes immediately after pair/unpair. Avoid serving an old empty // list from an HTTP cache after the client state has changed. send_response(response, output_tree, "no-store"); } void refresh_remote_display_physical_baseline() { try { const auto devices = nlohmann::json::parse(display_helper_integration::enumerate_devices_json(display_device::DeviceEnumerationDetail::Full)); if (!devices.is_array()) return; std::vector nodes; for (const auto &device : devices) { const auto id = device.value("device_id", ""); const auto label = device.value("friendly_name", device.value("display_name", id)); if (id.empty()) continue; #ifdef __linux__ if (platf::linux_private_display::is_private_output(id)) continue; #else if (boost::algorithm::icontains(label, "virtual display")) continue; #endif remote_display_topology::node_t node; node.id = id; node.label = label; node.physical = true; const auto info = device.value("info", nlohmann::json::object()); node.active = info.value("active", true); node.primary = info.value("primary", false); nodes.push_back(std::move(node)); } remote_display_topology::instance().set_physical_baseline(std::move(nodes)); } catch (const std::exception &e) { BOOST_LOG(warning) << "Remote display layout could not refresh physical monitor baseline: " << e.what(); } } void getClientDisplayLayout(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) return; print_req(request); refresh_remote_display_physical_baseline(); const auto clients = nvhttp::get_all_clients(); std::vector client_nodes; for (const auto &client : clients) client_nodes.push_back(client); auto output = remote_display_topology::instance().snapshot(client_nodes); output["layout"] = nvhttp::get_remote_display_layout(); send_response(response, output, "no-store"); } void putClientDisplayLayout(resp_https_t response, req_https_t request) { if (!check_content_type(response, request, "application/json") || !authenticate(response, request)) return; print_req(request); refresh_remote_display_physical_baseline(); try { std::stringstream body; body << request->content.rdbuf(); const auto layout = nlohmann::json::parse(body); std::string error; if (!nvhttp::set_remote_display_layout(layout, error)) { bad_request(response, request, error); return; } const auto clients = nvhttp::get_all_clients(); std::vector client_nodes; for (const auto &client : clients) client_nodes.push_back(client); auto output = remote_display_topology::instance().snapshot(client_nodes); output["layout"] = nvhttp::get_remote_display_layout(); output["applies_on_next_activation"] = true; send_response(response, output, "no-store"); } catch (const std::exception &e) { bad_request(response, request, e.what()); } } #ifdef _WIN32 static std::optional file_creation_time_ms(const std::filesystem::path &path) { WIN32_FILE_ATTRIBUTE_DATA data {}; if (!GetFileAttributesExW(path.c_str(), GetFileExInfoStandard, &data)) { return std::nullopt; } ULARGE_INTEGER t {}; t.LowPart = data.ftCreationTime.dwLowDateTime; t.HighPart = data.ftCreationTime.dwHighDateTime; // FILETIME is in 100ns units since 1601-01-01. constexpr uint64_t kEpochDiff100ns = 116444736000000000ULL; // 1970-01-01 - 1601-01-01 if (t.QuadPart < kEpochDiff100ns) { return std::nullopt; } return (t.QuadPart - kEpochDiff100ns) / 10000ULL; } static std::filesystem::path windows_color_profile_dir() { wchar_t system_root[MAX_PATH] = {}; if (GetSystemWindowsDirectoryW(system_root, _countof(system_root)) == 0) { return std::filesystem::path(L"C:\\Windows\\System32\\spool\\drivers\\color"); } std::filesystem::path root(system_root); return root / L"System32" / L"spool" / L"drivers" / L"color"; } #endif /** * @brief Get a list of available HDR color profiles (Windows only). * * @api_examples{/api/clients/hdr-profiles| GET| null} */ void getHdrProfiles(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); nlohmann::json output_tree; output_tree["status"] = true; nlohmann::json profiles = nlohmann::json::array(); #ifdef _WIN32 try { const auto dir = windows_color_profile_dir(); struct entry_t { std::string filename; uint64_t added_ms; }; std::vector entries; for (const auto &entry : std::filesystem::directory_iterator(dir)) { std::error_code ec; if (!entry.is_regular_file(ec)) { continue; } auto ext = entry.path().extension().wstring(); std::transform(ext.begin(), ext.end(), ext.begin(), [](wchar_t ch) { return static_cast(std::towlower(ch)); }); if (ext != L".icm" && ext != L".icc") { continue; } const auto filename_utf8 = platf::to_utf8(entry.path().filename().wstring()); const auto added_ms = file_creation_time_ms(entry.path()).value_or(0); entries.push_back({filename_utf8, added_ms}); } std::sort(entries.begin(), entries.end(), [](const entry_t &a, const entry_t &b) { if (a.added_ms != b.added_ms) { return a.added_ms > b.added_ms; } return a.filename < b.filename; }); for (const auto &e : entries) { nlohmann::json node; node["filename"] = e.filename; node["added_ms"] = e.added_ms; profiles.push_back(std::move(node)); } } catch (const std::exception &e) { output_tree["status"] = false; output_tree["error"] = e.what(); } catch (...) { output_tree["status"] = false; output_tree["error"] = "unknown error"; } #endif output_tree["profiles"] = std::move(profiles); send_response(response, output_tree); } #ifdef _WIN32 // removed unused forward declaration for default_playnite_ext_dir() #endif /** * @brief Update stored settings for a paired client. */ /** * @brief Disconnect a client session without unpairing it. */ void disconnectClient(resp_https_t response, req_https_t request) { if (!check_content_type(response, request, "application/json")) { return; } if (!authenticate(response, request)) { return; } print_req(request); std::stringstream ss; ss << request->content.rdbuf(); try { const nlohmann::json input_tree = nlohmann::json::parse(ss); nlohmann::json output_tree; const std::string uuid = input_tree.value("uuid", ""); output_tree["status"] = nvhttp::disconnect_client(uuid); send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "DisconnectClient: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Unpair a client. * @param response The HTTP response object. * @param request The HTTP request object. * * The body for the POST request should be JSON serialized in the following format: * @code{.json} * { * "uuid": "", * "name": "", * "display_mode": "1920x1080x59.94", * "do": [ { "cmd": "", "elevated": false }, ... ], * "undo": [ { "cmd": "", "elevated": false }, ... ], * "perm": * } * @endcode */ void updateClient(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); std::stringstream ss; ss << request->content.rdbuf(); try { nlohmann::json input_tree = nlohmann::json::parse(ss.str()); nlohmann::json output_tree; std::string uuid = input_tree.value("uuid", ""); std::optional hdr_profile; if (input_tree.contains("hdr_profile")) { if (input_tree["hdr_profile"].is_null()) { hdr_profile = std::string {}; } else { hdr_profile = input_tree.value("hdr_profile", ""); } } const bool has_extended_fields = input_tree.contains("name") || input_tree.contains("display_mode") || input_tree.contains("output_name_override") || input_tree.contains("always_use_virtual_display") || input_tree.contains("virtual_display_mode") || input_tree.contains("virtual_display_layout") || input_tree.contains("config_overrides") || input_tree.contains("prefer_10bit_sdr") || input_tree.contains("enable_legacy_ordering") || input_tree.contains("allow_client_commands") || input_tree.contains("perm") || input_tree.contains("do") || input_tree.contains("undo"); if (!has_extended_fields && hdr_profile.has_value()) { output_tree["status"] = nvhttp::set_client_hdr_profile(uuid, hdr_profile.value()); send_response(response, output_tree); return; } std::string name = input_tree.value("name", ""); std::string display_mode = input_tree.value("display_mode", ""); std::string output_name_override = input_tree.value("output_name_override", ""); bool enable_legacy_ordering = input_tree.value("enable_legacy_ordering", true); bool allow_client_commands = input_tree.value("allow_client_commands", true); bool always_use_virtual_display = input_tree.value("always_use_virtual_display", false); bool prefer_10bit_sdr = false; if (input_tree.contains("prefer_10bit_sdr") && !input_tree["prefer_10bit_sdr"].is_null()) { prefer_10bit_sdr = util::get_non_string_json_value(input_tree, "prefer_10bit_sdr", false); } std::optional> config_overrides; if (input_tree.contains("config_overrides")) { if (input_tree["config_overrides"].is_null()) { config_overrides = std::unordered_map {}; } else if (input_tree["config_overrides"].is_object()) { std::unordered_map overrides; for (const auto &item : input_tree["config_overrides"].items()) { std::string key = item.key(); if (key == "nvenc_force_split_encode") { key = "nvenc_split_encode"; } const auto &val = item.value(); if (key.empty() || val.is_null()) { continue; } std::string encoded; if (val.is_string()) { encoded = val.get(); } else { encoded = val.dump(); } overrides[key] = std::move(encoded); } config_overrides = std::move(overrides); } } std::string virtual_display_mode = input_tree.value("virtual_display_mode", ""); std::string virtual_display_layout = input_tree.value("virtual_display_layout", ""); auto do_cmds = nvhttp::extract_command_entries(input_tree, "do"); auto undo_cmds = nvhttp::extract_command_entries(input_tree, "undo"); auto perm = static_cast(input_tree.value("perm", static_cast(crypto::PERM::_no)) & static_cast(crypto::PERM::_all)); bool updated = nvhttp::update_device_info( uuid, name, display_mode, output_name_override, do_cmds, undo_cmds, perm, enable_legacy_ordering, allow_client_commands, always_use_virtual_display, virtual_display_mode, virtual_display_layout, prefer_10bit_sdr ); if (config_overrides.has_value() || hdr_profile.has_value()) { updated = nvhttp::update_device_info( uuid, name, display_mode, output_name_override, always_use_virtual_display, virtual_display_mode, virtual_display_layout, std::move(config_overrides), prefer_10bit_sdr, hdr_profile ) && updated; } output_tree["status"] = updated; send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "Update Client: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Unpair a client. * @param response The HTTP response object. * @param request The HTTP request object. * * The body for the POST request should be JSON serialized in the following format: * @code{.json} * { * "uuid": "" * } * @endcode * * @api_examples{/api/clients/unpair| POST| {"uuid":"1234"}} */ void unpair(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); std::stringstream ss; ss << request->content.rdbuf(); try { nlohmann::json input_tree = nlohmann::json::parse(ss.str()); nlohmann::json output_tree; std::string uuid = input_tree.value("uuid", ""); output_tree["status"] = nvhttp::unpair_client(uuid); send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "Unpair: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Unpair all clients. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/clients/unpair-all| POST| null} */ void unpairAll(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); const bool persisted = nvhttp::erase_all_clients(); proc::proc.terminate(); nlohmann::json output_tree; output_tree["status"] = persisted; send_response(response, output_tree); } /** * @brief Get the configuration settings. * @param response The HTTP response object. * @param request The HTTP request object. */ void getConfig(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); nlohmann::json output_tree; output_tree["status"] = true; output_tree["platform"] = SUNSHINE_PLATFORM; output_tree["version"] = PROJECT_VERSION; #ifdef _WIN32 output_tree["vdisplayStatus"] = static_cast(proc::vDisplayDriverStatus.load(std::memory_order_acquire)); #endif auto vars = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str())); for (auto &[name, value] : vars) { output_tree[name] = value; } send_response(response, output_tree); } /** * @brief Get immutables metadata about the server. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/meta| GET| null} */ void getMetadata(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); nlohmann::json output_tree; output_tree["status"] = true; output_tree["platform"] = SUNSHINE_PLATFORM; output_tree["version"] = PROJECT_VERSION; output_tree["commit"] = PROJECT_VERSION_COMMIT; #ifdef PROJECT_VERSION_PRERELEASE output_tree["prerelease"] = PROJECT_VERSION_PRERELEASE; #else output_tree["prerelease"] = ""; #endif #ifdef PROJECT_VERSION_BRANCH output_tree["branch"] = PROJECT_VERSION_BRANCH; #else output_tree["branch"] = "unknown"; #endif // Build/release date provided by CMake (ISO 8601 when available) output_tree["release_date"] = PROJECT_RELEASE_DATE; output_tree["providers"]["steam"] = true; #if defined(__linux__) output_tree["providers"]["lutris"] = true; output_tree["providers"]["mangohud"] = true; const char *session_role = std::getenv("ARTLIGHT_SESSION_ROLE"); const std::string role = session_role ? session_role : "unknown"; output_tree["linux"] = {{"session_role", role == "desktop" || role == "greeter" ? role : "unknown"}}; const bool managed_active = platf::linux_capture_status::managed_event_capture_active(); output_tree["capture_status"] = { {"configured_backend", config::video.capture}, {"observed_backend", managed_active ? "kms" : "unknown"}, {"managed_event_driven", managed_active}, {"virtual_display_configured", config::video.virtual_display_mode != config::video_t::virtual_display_mode_e::disabled}, }; const auto display_capabilities = platf::linux_display::backend().capabilities(); const bool virtual_capable = display_capabilities.independent_outputs; const bool virtual_ready = display_capabilities.independent_outputs_ready; output_tree["virtual_display"] = { {"capable", virtual_capable}, {"ready", virtual_ready}, {"reason", virtual_ready ? "" : virtual_capable ? "session_or_output_unavailable" : "driver_or_outputs_unavailable"}, {"backend", display_capabilities.backend_name}, {"modes", {"per_client", "shared"}}, {"layouts", {"exclusive", "extended", "extended_primary", "extended_isolated", "extended_primary_isolated"}}, {"display_enumeration", true}, {"dynamic_modes", true}, {"hdr", "per_output"}, {"scale", true}, {"reset_persistence", true}, }; #endif // UI status reads must never start a capture or probe an encoder. bool probe_complete = false; const auto encoder_caps = video::advertised_encoder_capabilities(false, &probe_complete); output_tree["encoder_status"] = { {"state", probe_complete ? "ready" : video::has_attempted_encoder_probe() ? "failed" : "unknown"}, {"h264", probe_complete}, {"hevc", probe_complete && encoder_caps.hevc_mode >= 2}, {"av1", probe_complete && encoder_caps.av1_mode >= 2}, }; #if defined(_WIN32) output_tree["providers"]["playnite_toggle"] = true; const auto driver_snapshot = proc::vDisplayDriverStatusSnapshot(); const auto driver_status = driver_snapshot.status; const auto active_driver = driver_snapshot.selection; const auto driver_status_name = [](const VDISPLAY::DRIVER_STATUS status) { switch (status) { case VDISPLAY::DRIVER_STATUS::OK: return "ready"; case VDISPLAY::DRIVER_STATUS::FAILED: return "failed"; case VDISPLAY::DRIVER_STATUS::VERSION_INCOMPATIBLE: return "version_incompatible"; case VDISPLAY::DRIVER_STATUS::WATCHDOG_FAILED: return "watchdog_failed"; case VDISPLAY::DRIVER_STATUS::UNKNOWN: default: return "unknown"; } }; const auto driver_selection_name = [](const VDISPLAY::DRIVER_SELECTION selection) -> const char * { switch (selection) { case VDISPLAY::DRIVER_SELECTION::VIBESHINE: return "vibeshine"; case VDISPLAY::DRIVER_SELECTION::SUDOVDA: return "sudovda"; case VDISPLAY::DRIVER_SELECTION::UNKNOWN: default: return nullptr; } }; const auto configured_driver = config::video.dd.use_sunshine_virtual_display_driver ? "vibeshine" : "sudovda"; nlohmann::json driver_metadata = { {"configured", configured_driver}, {"status", driver_status_name(driver_status)}, {"status_code", static_cast(driver_status)}, }; if (const auto active_name = driver_selection_name(active_driver)) { driver_metadata["active"] = active_name; } else { driver_metadata["active"] = nullptr; } output_tree["virtual_display_driver"] = std::move(driver_metadata); try { const auto gpus = platf::enumerate_gpus(); if (!gpus.empty()) { nlohmann::json gpu_array = nlohmann::json::array(); bool has_nvidia = false; bool has_amd = false; bool has_intel = false; for (const auto &gpu : gpus) { nlohmann::json gpu_entry; gpu_entry["description"] = gpu.description; gpu_entry["pnp_id"] = gpu.pnp_id; gpu_entry["vendor_id"] = gpu.vendor_id; gpu_entry["device_id"] = gpu.device_id; gpu_entry["dedicated_video_memory"] = gpu.dedicated_video_memory; gpu_array.push_back(std::move(gpu_entry)); switch (gpu.vendor_id) { case 0x10DE: // NVIDIA has_nvidia = true; break; case 0x1002: // AMD/ATI case 0x1022: // AMD alternative PCI vendor ID (APUs) has_amd = true; break; case 0x8086: // Intel has_intel = true; break; default: break; } } output_tree["gpus"] = std::move(gpu_array); output_tree["has_nvidia_gpu"] = has_nvidia; output_tree["has_amd_gpu"] = has_amd; output_tree["has_intel_gpu"] = has_intel; } const auto version = platf::query_windows_version(); if (!version.display_version.empty()) { output_tree["windows_display_version"] = version.display_version; } if (!version.release_id.empty()) { output_tree["windows_release_id"] = version.release_id; } if (!version.product_name.empty()) { output_tree["windows_product_name"] = version.product_name; } if (!version.current_build.empty()) { output_tree["windows_current_build"] = version.current_build; } if (version.build_number.has_value()) { output_tree["windows_build_number"] = version.build_number.value(); } if (version.major_version.has_value()) { output_tree["windows_major_version"] = version.major_version.value(); } if (version.minor_version.has_value()) { output_tree["windows_minor_version"] = version.minor_version.value(); } } catch (...) { // Non-fatal; keep metadata response minimal if enumeration fails. } #endif send_response(response, output_tree); } /** * @brief Get the locale setting. This endpoint does not require authentication. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/configLocale| GET| null} */ void getLocale(resp_https_t response, req_https_t request) { print_req(request); nlohmann::json output_tree; output_tree["status"] = true; output_tree["locale"] = config::sunshine.locale; send_response(response, output_tree); } /** * @brief Save the configuration settings. * @param response The HTTP response object. * @param request The HTTP request object. * The body for the post request should be JSON serialized in the following format: * @code{.json} * { * "key": "value" * } * @endcode * * @attention{It is recommended to ONLY save the config settings that differ from the default behavior.} * * @api_examples{/api/config| POST| {"key":"value"}} */ #ifdef _WIN32 /** * @brief Apply a changed `vulkan_hdr_layer` preference to the system Vulkan implicit-layer * registration immediately, so the Web UI toggle takes effect without a restart. * @details Best-effort: registering/unregistering the HKLM implicit layer requires SYSTEM/admin * rights (Sunshine's service runs as SYSTEM). No-op when the key is not in the body. */ void reconcile_vulkan_hdr_layer_from_body(const nlohmann::json &body) { if (!body.is_object()) { return; } const auto it = body.find("vulkan_hdr_layer"); if (it == body.end()) { return; } const nlohmann::json &v = *it; bool enabled = true; if (v.is_boolean()) { enabled = v.get(); } else if (v.is_number()) { enabled = v.get() != 0.0; } else if (v.is_string()) { std::string s = v.get(); std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return (char) std::tolower(c); }); enabled = !(s.empty() || s == "false" || s == "disabled" || s == "0" || s == "off" || s == "no"); } platf::set_vulkan_hdr_layer_enabled(enabled); } #endif void saveConfig(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); std::stringstream ss; ss << request->content.rdbuf(); try { // TODO: Input Validation std::stringstream config_stream; nlohmann::json output_tree; nlohmann::json input_tree = nlohmann::json::parse(ss); normalize_adapter_config_pair(input_tree); std::set changed_keys; for (const auto &[k, v] : input_tree.items()) { changed_keys.insert(k); if (v.is_null() || (v.is_string() && v.get().empty())) { continue; } // v.dump() will dump valid json, which we do not want for strings in the config right now // we should migrate the config file to straight json and get rid of all this nonsense config_stream << k << " = " << (v.is_string() ? v.get() : v.dump()) << std::endl; } file_handler::write_file(config::sunshine.config_file.c_str(), config_stream.str()); #ifdef _WIN32 reconcile_vulkan_hdr_layer_from_body(input_tree); #endif // Detect restart-required keys static const std::set restart_required_keys = { "port", "address_family", "upnp", "pkey", "cert" }; bool restart_required = false; for (const auto &k : changed_keys) { if (restart_required_keys.count(k)) { restart_required = true; break; } } bool applied_now = false; bool deferred = false; if (!restart_required) { if (can_hot_apply_during_session(changed_keys) || !has_active_stream_sessions()) { // Apply immediately config::apply_config_now(); applied_now = true; } else { config::mark_deferred_reload(); deferred = true; } } output_tree["status"] = true; output_tree["appliedNow"] = applied_now; output_tree["deferred"] = deferred; output_tree["restartRequired"] = restart_required; send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "SaveConfig: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Partial update of configuration (PATCH /api/config). * Merges provided JSON object into the existing key=value style config file. * Removes keys when value is null or an empty string. Detects whether a * restart is required and attempts to apply immediately when safe. */ void patchConfig(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json")) { return; } if (!authenticate(response, request)) { return; } print_req(request); std::stringstream ss; ss << request->content.rdbuf(); try { nlohmann::json output_tree; nlohmann::json patch_tree = nlohmann::json::parse(ss); if (!patch_tree.is_object()) { bad_request(response, request, "PATCH body must be a JSON object"); return; } normalize_adapter_config_patch(patch_tree); // Load existing config into a map std::unordered_map current = config::parse_config( file_handler::read_file(config::sunshine.config_file.c_str()) ); // Track which keys are being modified to detect restart requirements std::set changed_keys; for (auto it = patch_tree.begin(); it != patch_tree.end(); ++it) { const std::string key = it.key(); const nlohmann::json &val = it.value(); changed_keys.insert(key); // Remove key when explicitly null or empty string if (val.is_null() || (val.is_string() && val.get().empty())) { auto curIt = current.find(key); if (curIt != current.end()) { current.erase(curIt); } continue; } // Persist value: strings are raw, non-strings are dumped as JSON if (val.is_string()) { current[key] = val.get(); } else { current[key] = val.dump(); } } // Write back full merged config file std::stringstream config_stream; for (const auto &kv : current) { config_stream << kv.first << " = " << kv.second << std::endl; } file_handler::write_file(config::sunshine.config_file.c_str(), config_stream.str()); #ifdef _WIN32 reconcile_vulkan_hdr_layer_from_body(patch_tree); #endif // Detect restart-required keys static const std::set restart_required_keys = { "port", "address_family", "upnp", "pkey", "cert" }; bool restart_required = false; for (const auto &k : changed_keys) { if (restart_required_keys.count(k)) { restart_required = true; break; } } bool applied_now = false; bool deferred = false; if (!restart_required) { if (can_hot_apply_during_session(changed_keys) || !has_active_stream_sessions()) { // Apply immediately config::apply_config_now(); applied_now = true; } else { config::mark_deferred_reload(); deferred = true; } } output_tree["status"] = true; output_tree["appliedNow"] = applied_now; output_tree["deferred"] = deferred; output_tree["restartRequired"] = restart_required; send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "PatchConfig: "sv << e.what(); bad_request(response, request, e.what()); return; } } // Lightweight session status for UI messaging void getSessionStatus(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); nlohmann::json output_tree; const int active = rtsp_stream::session_count() + static_cast(webrtc_stream::active_session_count()); const bool app_running = proc::proc.running() > 0; output_tree["activeSessions"] = active; output_tree["appRunning"] = app_running; output_tree["appName"] = app_running ? proc::proc.get_last_run_app_name() : ""; output_tree["paused"] = app_running && active == 0; output_tree["lastEncoderProbeFailed"] = video::last_encoder_probe_failed(); output_tree["status"] = true; send_response(response, output_tree); } // Live host system performance counters (CPU/GPU/RAM/VRAM/temps). void getHostStats(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); send_response(response, host_stats_to_json(host_stats::latest_for_consumer())); } // Static host info — model strings + total RAM/VRAM, sampled once. void getHostInfo(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); send_response(response, host_info_to_json(host_stats::info())); } void listRTSPSessions(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } nlohmann::json output; output["sessions"] = nlohmann::json::array(); for (const auto &info : stream::get_all_session_info()) { output["sessions"].push_back(rtsp_session_to_json(info)); } send_response(response, output); } void listWebRTCSessions(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } nlohmann::json output; output["sessions"] = nlohmann::json::array(); for (const auto &session : webrtc_stream::list_sessions()) { output["sessions"].push_back(webrtc_session_to_json(session)); } send_response(response, output); } // ── Session History endpoints ──────────────────────────────────── void listSessionHistory(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } int limit = 25; int offset = 0; auto query = request->parse_query_string(); auto it_limit = query.find("limit"); if (it_limit != query.end()) { try { limit = std::stoi(it_limit->second); } catch (...) {} } auto it_offset = query.find("offset"); if (it_offset != query.end()) { try { offset = std::stoi(it_offset->second); } catch (...) {} } limit = std::clamp(limit, 1, 100); offset = std::max(offset, 0); nlohmann::json output; output["sessions"] = nlohmann::json::array(); for (const auto &s : session_history::list_sessions(limit, offset)) { output["sessions"].push_back(session_summary_to_json(s)); } output["history_status"] = history_status_to_json(session_history::get_history_status()); send_response(response, output); } void getSessionHistoryDetail(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } auto uuid = request->path_match[1].str(); const auto query = request->parse_query_string(); const bool include_all = [&query]() { auto it = query.find("full"); if (it == query.end()) { return false; } return it->second == "1" || it->second == "true" || it->second == "yes"; }(); auto detail = session_history::get_session_detail(uuid, include_all); if (!detail) { not_found(response, request); return; } auto output = session_detail_to_json(*detail); output["history_status"] = history_status_to_json(session_history::get_history_status()); send_response(response, output); } void deleteSessionHistory(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } auto uuid = request->path_match[1].str(); auto result = session_history::delete_session(uuid); switch (result) { case session_history::delete_result_e::deleted: break; case session_history::delete_result_e::not_found: not_found(response, request); return; case session_history::delete_result_e::active_session: conflict(response, "Cannot delete an active session"); return; case session_history::delete_result_e::unavailable: service_unavailable(response, "Session history subsystem unavailable"); return; case session_history::delete_result_e::timeout: gateway_timeout(response, "Timed out waiting for session history delete"); return; case session_history::delete_result_e::failed: service_unavailable(response, "Session history delete failed"); return; } nlohmann::json output; output["status"] = "ok"; output["uuid"] = uuid; send_response(response, output); } void getActiveSessionHistory(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } nlohmann::json output; output["sessions"] = nlohmann::json::array(); for (const auto &as : session_history::get_active_sessions()) { output["sessions"].push_back(active_session_to_json(as)); } send_response(response, output); } void getWebRTCCapabilities(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } nlohmann::json output; #ifndef SUNSHINE_ENABLE_WEBRTC output["enabled"] = false; output["availability"] = { {"state", "disabled"}, {"reason", "WebRTC support is disabled in this build"}, }; send_response(response, output); return; #else const auto capabilities = nvhttp::get_web_stream_capabilities(); constexpr int kMaxWebRtcDimension = 16384; constexpr int kMaxWebRtcFps = 1000; constexpr int kAbsoluteMaxWebRtcBitrateKbps = 500000; const int max_bitrate_kbps = config::video.max_bitrate > 0 ? std::min(config::video.max_bitrate, kAbsoluteMaxWebRtcBitrateKbps) : kAbsoluteMaxWebRtcBitrateKbps; bool hdr_policy_allows = true; std::string_view hdr_policy = "automatic"; #ifdef _WIN32 using hdr_request_override_e = config::video_t::dd_t::hdr_request_override_e; switch (config::video.dd.hdr_request_override) { case hdr_request_override_e::force_on: hdr_policy = "force_on"; break; case hdr_request_override_e::force_off: hdr_policy = "force_off"; hdr_policy_allows = false; break; case hdr_request_override_e::automatic: break; } #endif output["enabled"] = true; output["availability"] = { {"state", capabilities.probe_complete ? "ready" : "unverified"}, {"reason", capabilities.probe_complete ? "" : "The selected capture adapter has not reported a usable encoder."}, }; output["codecs"] = { {"h264", {{"supported", capabilities.h264}, {"hdr", false}}}, {"hevc", {{"supported", capabilities.hevc}, {"hdr", capabilities.hevc_hdr}}}, {"av1", {{"supported", capabilities.av1}, {"hdr", capabilities.av1_hdr}}}, }; output["hdr_policy_allows"] = hdr_policy_allows; output["hdr_policy"] = std::string {hdr_policy}; output["limits"] = { {"min_dimension", 64}, {"max_dimension", kMaxWebRtcDimension}, {"min_fps", 1}, {"max_fps", kMaxWebRtcFps}, {"min_bitrate_kbps", 0}, {"max_bitrate_kbps", max_bitrate_kbps}, }; send_response(response, output); #endif } void createWebRTCSession(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } #ifndef SUNSHINE_ENABLE_WEBRTC service_unavailable(response, "WebRTC support is disabled in this build"); return; #endif BOOST_LOG(debug) << "WebRTC: create session request received"; webrtc_stream::SessionOptions options; std::stringstream ss; ss << request->content.rdbuf(); auto body = ss.str(); if (!body.empty()) { if (!check_content_type(response, request, "application/json")) { return; } try { nlohmann::json input = nlohmann::json::parse(body); if (input.contains("audio")) { options.audio = input.at("audio").get(); } if (input.contains("host_audio")) { options.host_audio = input.at("host_audio").get(); } if (input.contains("video")) { options.video = input.at("video").get(); } if (input.contains("encoded")) { options.encoded = input.at("encoded").get(); } if (input.contains("width")) { const int width = input.at("width").get(); if (width > 0) { options.width = width; } } if (input.contains("height")) { const int height = input.at("height").get(); if (height > 0) { options.height = height; } } if (input.contains("fps")) { const int fps = input.at("fps").get(); if (fps > 0) { options.fps = fps; } } if (input.contains("bitrate_kbps")) { options.bitrate_kbps = input.at("bitrate_kbps").get(); } if (input.contains("codec")) { options.codec = input.at("codec").get(); } if (input.contains("hdr")) { options.hdr = input.at("hdr").get(); } if (input.contains("audio_channels")) { options.audio_channels = input.at("audio_channels").get(); } if (input.contains("audio_codec")) { options.audio_codec = input.at("audio_codec").get(); } if (input.contains("profile")) { options.profile = input.at("profile").get(); } if (input.contains("app_id")) { options.app_id = input.at("app_id").get(); } if (input.contains("client_uuid")) { if (!input.at("client_uuid").is_string()) { bad_request(response, request, "client_uuid must be a string"); return; } auto client_uuid = input.at("client_uuid").get(); if (client_uuid.empty() || !nvhttp::has_client_uuid(client_uuid)) { bad_request(response, request, "Unknown paired client UUID"); return; } options.client_uuid = std::move(client_uuid); } if (input.contains("resume")) { options.resume = input.at("resume").get(); } if (input.contains("video_pacing_mode")) { options.video_pacing_mode = input.at("video_pacing_mode").get(); } if (input.contains("video_pacing_slack_ms")) { options.video_pacing_slack_ms = input.at("video_pacing_slack_ms").get(); } if (input.contains("video_max_frame_age_ms")) { options.video_max_frame_age_ms = input.at("video_max_frame_age_ms").get(); } if (options.codec) { auto lower = *options.codec; boost::algorithm::to_lower(lower); if (lower != "h264" && lower != "hevc" && lower != "av1") { bad_request(response, request, "Unsupported codec"); return; } options.codec = std::move(lower); } if (options.audio_codec) { auto lower = *options.audio_codec; boost::algorithm::to_lower(lower); if (lower != "opus" && lower != "aac") { bad_request(response, request, "Unsupported audio codec"); return; } options.audio_codec = std::move(lower); } if (options.audio_channels) { int channels = *options.audio_channels; if (channels != 2 && channels != 6 && channels != 8) { bad_request(response, request, "Unsupported audio channel count"); return; } } if (options.video_pacing_mode) { auto lower = *options.video_pacing_mode; boost::algorithm::to_lower(lower); if (lower == "smooth") { lower = "smoothness"; } if (lower != "latency" && lower != "balanced" && lower != "smoothness") { bad_request(response, request, "Unsupported video pacing mode"); return; } options.video_pacing_mode = std::move(lower); } if (options.video_pacing_slack_ms) { const int slack_ms = *options.video_pacing_slack_ms; if (slack_ms < 0 || slack_ms > 10) { bad_request(response, request, "video_pacing_slack_ms must be between 0 and 10"); return; } } if (options.video_max_frame_age_ms) { const int max_age_ms = *options.video_max_frame_age_ms; if (max_age_ms < 5 || max_age_ms > 250) { bad_request(response, request, "video_max_frame_age_ms must be between 5 and 250"); return; } } if (options.hdr.value_or(false)) { #ifdef _WIN32 if (config::video.dd.hdr_request_override == config::video_t::dd_t::hdr_request_override_e::force_off) { bad_request(response, request, "HDR is disabled by the host display policy"); return; } #endif if (!options.encoded) { bad_request(response, request, "HDR requires encoded video for WebRTC sessions"); return; } if (!options.codec || (*options.codec != "hevc" && *options.codec != "av1")) { bad_request(response, request, "HDR requires HEVC or AV1 video encoding"); return; } } #ifdef _WIN32 if (config::video.dd.hdr_request_override == config::video_t::dd_t::hdr_request_override_e::force_on && (!options.codec || (*options.codec != "hevc" && *options.codec != "av1"))) { bad_request(response, request, "The host HDR display policy requires HEVC or AV1 video encoding"); return; } #endif constexpr int kMinWebRtcDimension = 64; constexpr int kMaxWebRtcDimension = 16384; constexpr int kMaxWebRtcFps = 1000; constexpr int kAbsoluteMaxWebRtcBitrateKbps = 500000; const int max_bitrate_kbps = config::video.max_bitrate > 0 ? std::min(config::video.max_bitrate, kAbsoluteMaxWebRtcBitrateKbps) : kAbsoluteMaxWebRtcBitrateKbps; const auto valid_dimension = [=](const std::optional &dimension) { return !dimension || (*dimension >= kMinWebRtcDimension && *dimension <= kMaxWebRtcDimension && *dimension % 2 == 0); }; if (!valid_dimension(options.width) || !valid_dimension(options.height)) { bad_request(response, request, "WebRTC width and height must be even values between 64 and 16384"); return; } if (options.fps && (*options.fps < 1 || *options.fps > kMaxWebRtcFps)) { bad_request(response, request, "WebRTC fps must be between 1 and 1000"); return; } if (options.bitrate_kbps && (*options.bitrate_kbps < 0 || *options.bitrate_kbps > max_bitrate_kbps)) { bad_request(response, request, "WebRTC bitrate_kbps exceeds this host's allowed range"); return; } } catch (const std::exception &e) { bad_request(response, request, e.what()); return; } } BOOST_LOG(debug) << "WebRTC: creating session"; std::optional capture_start_error; #ifdef _WIN32 { // Publish the cleanup tail before capture startup mutates any display or // runtime configuration. The lifecycle gate below then closes the gap // between the failed start releasing its gate and direct VDD cleanup. stream::session::cleanup_reservation_t cleanup_reservation; capture_start_error = webrtc_stream::ensure_capture_started(options); if (capture_start_error) { std::unique_lock lifecycle_lock(nvhttp::stream_lifecycle_mutex()); if (!stream::session::has_shared_runtime_owner()) { (void) platf::virtual_display_cleanup::run( "webrtc_session_start_failed", config::video.dd.config_revert_on_disconnect ); } } } #else capture_start_error = webrtc_stream::ensure_capture_started(options); #endif if (capture_start_error) { bad_request(response, request, capture_start_error->c_str()); return; } auto session = webrtc_stream::create_session(options); if (!session) { webrtc_stream::shutdown_all_sessions(); service_unavailable(response, "Shutdown in progress"); return; } BOOST_LOG(debug) << "WebRTC: session created id=" << session->id; nlohmann::json output; output["status"] = true; output["session"] = webrtc_session_to_json(*session); output["cert_fingerprint"] = webrtc_stream::get_server_cert_fingerprint(); output["cert_pem"] = webrtc_stream::get_server_cert_pem(); output["ice_servers"] = load_webrtc_ice_servers(); send_response(response, output); } void getWebRTCSession(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } std::string session_id; if (request->path_match.size() > 1) { session_id = request->path_match[1]; } auto session = webrtc_stream::get_session(session_id); if (!session) { bad_request(response, request, "Session not found"); return; } nlohmann::json output; output["session"] = webrtc_session_to_json(*session); send_response(response, output); } void deleteWebRTCSession(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } std::string session_id; if (request->path_match.size() > 1) { session_id = request->path_match[1]; } nlohmann::json output; if (webrtc_stream::close_session(session_id)) { output["status"] = true; } else { output["error"] = "Session not found"; } send_response(response, output); } void postWebRTCOffer(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } if (!check_content_type(response, request, "application/json")) { return; } std::string session_id; if (request->path_match.size() > 1) { session_id = request->path_match[1]; } std::stringstream ss; ss << request->content.rdbuf(); try { nlohmann::json input = nlohmann::json::parse(ss.str()); auto sdp = input.at("sdp").get(); auto type = input.value("type", "offer"); nlohmann::json output; if (!webrtc_stream::set_remote_offer(session_id, sdp, type)) { if (!webrtc_stream::get_session(session_id)) { output["error"] = "Session not found"; } else { const auto negotiation_error = webrtc_stream::get_negotiation_error(session_id); output["error"] = negotiation_error.empty() ? "Failed to process offer" : negotiation_error; } send_response(response, output); return; } std::string answer_sdp; std::string answer_type; if (webrtc_stream::wait_for_local_answer(session_id, answer_sdp, answer_type, std::chrono::seconds {3})) { output["status"] = true; output["answer_ready"] = true; output["sdp"] = answer_sdp; output["type"] = answer_type; } else { const auto negotiation_error = webrtc_stream::get_negotiation_error(session_id); if (!negotiation_error.empty()) { output["error"] = negotiation_error; send_response(response, output); return; } output["status"] = true; output["answer_ready"] = false; output["sdp"] = nullptr; output["type"] = nullptr; } send_response(response, output); } catch (const std::exception &e) { bad_request(response, request, e.what()); } } void getWebRTCAnswer(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } std::string session_id; if (request->path_match.size() > 1) { session_id = request->path_match[1]; } std::string answer_sdp; std::string answer_type; nlohmann::json output; if (webrtc_stream::get_local_answer(session_id, answer_sdp, answer_type)) { output["status"] = true; output["answer_ready"] = true; output["sdp"] = answer_sdp; output["type"] = answer_type; } else { const auto negotiation_error = webrtc_stream::get_negotiation_error(session_id); output["status"] = false; output["error"] = negotiation_error.empty() ? "Answer not ready" : negotiation_error; } send_response(response, output); } void postWebRTCIce(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } if (!check_content_type(response, request, "application/json")) { return; } std::string session_id; if (request->path_match.size() > 1) { session_id = request->path_match[1]; } std::stringstream ss; ss << request->content.rdbuf(); try { nlohmann::json input = nlohmann::json::parse(ss.str()); nlohmann::json output; constexpr std::size_t kMaxCandidatesPerRequest = 256; std::vector candidates; if (input.is_array()) { candidates.reserve(std::min(input.size(), kMaxCandidatesPerRequest)); for (const auto &entry : input) { if (candidates.size() >= kMaxCandidatesPerRequest) { break; } candidates.push_back(entry); } } else if (input.contains("candidates") && input["candidates"].is_array()) { const auto &arr = input["candidates"]; candidates.reserve(std::min(arr.size(), kMaxCandidatesPerRequest)); for (const auto &entry : arr) { if (candidates.size() >= kMaxCandidatesPerRequest) { break; } candidates.push_back(entry); } } else { candidates.push_back(input); } bool ok = true; for (const auto &entry : candidates) { if (!entry.is_object()) { continue; } auto mid = entry.value("sdpMid", ""); auto mline_index = entry.value("sdpMLineIndex", -1); auto candidate = entry.value("candidate", ""); if (candidate.empty()) { continue; } if (!webrtc_stream::add_ice_candidate(session_id, std::move(mid), mline_index, std::move(candidate))) { ok = false; break; } } if (ok) { output["status"] = true; } else { output["error"] = "Session not found"; } send_response(response, output); } catch (const std::exception &e) { bad_request(response, request, e.what()); } } void getWebRTCIce(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } std::string session_id; if (request->path_match.size() > 1) { session_id = request->path_match[1]; } std::size_t since = 0; auto query = request->parse_query_string(); auto since_it = query.find("since"); if (since_it != query.end()) { try { since = static_cast(std::stoull(since_it->second)); } catch (...) { bad_request(response, request, "Invalid since parameter"); return; } } auto candidates = webrtc_stream::get_local_candidates(session_id, since); nlohmann::json output; output["status"] = true; output["candidates"] = nlohmann::json::array(); std::size_t last_index = since; for (const auto &candidate : candidates) { nlohmann::json item; item["sdpMid"] = candidate.mid; item["sdpMLineIndex"] = candidate.mline_index; item["candidate"] = candidate.candidate; item["index"] = candidate.index; output["candidates"].push_back(std::move(item)); last_index = std::max(last_index, candidate.index); } output["next_since"] = last_index; send_response(response, output); } void getWebRTCIceStream(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } std::string session_id; if (request->path_match.size() > 1) { session_id = request->path_match[1]; } if (!webrtc_stream::get_session(session_id)) { bad_request(response, request, "Session not found"); return; } std::size_t since = 0; auto query = request->parse_query_string(); auto since_it = query.find("since"); if (since_it != query.end()) { try { since = static_cast(std::stoull(since_it->second)); } catch (...) { bad_request(response, request, "Invalid since parameter"); return; } } std::thread([response, session_id, since]() mutable { response->close_connection_after_response = true; response->write({ {"Content-Type", "text/event-stream"}, {"Cache-Control", "no-cache, no-transform"}, {"Connection", "keep-alive"}, {"X-Accel-Buffering", "no"}, {"Access-Control-Allow-Origin", get_cors_origin()}, }); std::promise header_error; response->send([&header_error](const SimpleWeb::error_code &ec) { header_error.set_value(static_cast(ec)); }); if (header_error.get_future().get()) { return; } // Make the initial response large enough for buffering proxies to release // the stream without adding padding to every browser-visible event. constexpr std::size_t sse_proxy_prelude_size = 2048; *response << ':' << std::string(sse_proxy_prelude_size - 3, ' ') << "\n\n"; std::promise prelude_error; response->send([&prelude_error](const SimpleWeb::error_code &ec) { prelude_error.set_value(static_cast(ec)); }); if (prelude_error.get_future().get()) { return; } auto last_index = since; auto last_keepalive = std::chrono::steady_clock::now(); while (true) { auto candidates = webrtc_stream::get_local_candidates(session_id, last_index); for (const auto &candidate : candidates) { nlohmann::json payload; payload["sdpMid"] = candidate.mid; payload["sdpMLineIndex"] = candidate.mline_index; payload["candidate"] = candidate.candidate; *response << "event: candidate\n"; *response << "id: " << candidate.index << "\n"; *response << "data: " << payload.dump() << "\n\n"; std::promise error; response->send([&error](const SimpleWeb::error_code &ec) { error.set_value(static_cast(ec)); }); if (error.get_future().get()) { return; } last_index = std::max(last_index, candidate.index); } auto now = std::chrono::steady_clock::now(); if (now - last_keepalive > std::chrono::seconds(2)) { *response << "event: keepalive\n"; *response << "data: {}\n\n"; std::promise error; response->send([&error](const SimpleWeb::error_code &ec) { error.set_value(static_cast(ec)); }); if (error.get_future().get()) { return; } last_keepalive = now; } std::this_thread::sleep_for(std::chrono::milliseconds(200)); } }).detach(); } void getWebRTCCert(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } nlohmann::json output; output["cert_fingerprint"] = webrtc_stream::get_server_cert_fingerprint(); output["cert_pem"] = webrtc_stream::get_server_cert_pem(); send_response(response, output); } /** * @brief Get an application's image. * @param response The HTTP response object. * @param request The HTTP request object. * * @note{The index in the url path is the application index.} * * @api_examples{/api/covers/9999 | GET| null} */ void getCover(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); try { const int index = std::stoi(request->path_match[1]); if (!check_app_index(response, request, index)) { return; } std::string file = file_handler::read_file(config::stream.file_apps.c_str()); nlohmann::json file_tree = nlohmann::json::parse(file); auto &apps = file_tree["apps"]; auto &app = apps[index]; // Get the image path from the app configuration std::string app_image_path; if (app.contains("image-path") && !app["image-path"].is_null()) { app_image_path = app["image-path"]; } // Use validate_app_image_path to resolve and validate the path std::string validated_path = proc::validate_app_image_path(app_image_path); if (validated_path == DEFAULT_APP_IMAGE_PATH) { BOOST_LOG(debug) << "Application at index " << index << " does not have a valid cover image"; not_found(response, request, "Cover image not found"); return; } const auto image = proc::read_validated_app_image(validated_path); if (!image) { BOOST_LOG(warning) << "Unable to read cover image file: " << validated_path; bad_request(response, request, "Unable to read cover image file"); return; } SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "image/png"); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); response->write(SimpleWeb::StatusCode::success_ok, *image, headers); } catch (std::exception &e) { BOOST_LOG(warning) << "GetCover: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Upload a cover image. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/covers/upload| POST| {"key":"igdb_1234","url":"https://images.igdb.com/igdb/image/upload/t_cover_big_2x/abc123.png"}} */ void uploadCover(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } std::stringstream ss; ss << request->content.rdbuf(); try { nlohmann::json input_tree = nlohmann::json::parse(ss.str()); nlohmann::json output_tree; std::string key = input_tree.value("key", ""); if (key.empty()) { bad_request(response, request, "Cover key is required"); return; } std::string url = input_tree.value("url", ""); const std::string coverdir = platf::appdata().string() + "/covers/"; file_handler::make_directory(coverdir); // Final destination PNG path const std::string dest_png = coverdir + http::url_escape(key) + ".png"; // Helper to check PNG magic header auto file_is_png = [](const std::string &p) -> bool { std::ifstream f(p, std::ios::binary); if (!f) { return false; } unsigned char sig[8] {}; f.read(reinterpret_cast(sig), 8); static const unsigned char pngsig[8] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; return f.gcount() == 8 && std::equal(std::begin(sig), std::end(sig), std::begin(pngsig)); }; // Build a temp source path (extension based on URL if available) auto ext_from_url = [](std::string u) -> std::string { auto qpos = u.find_first_of("?#"); if (qpos != std::string::npos) { u = u.substr(0, qpos); } auto slash = u.find_last_of('/'); if (slash != std::string::npos) { u = u.substr(slash + 1); } auto dot = u.find_last_of('.'); if (dot == std::string::npos) { return std::string {".img"}; } std::string e = u.substr(dot); // sanitize extension if (e.size() > 8) { return std::string {".img"}; } for (char &c : e) { c = static_cast(std::tolower(static_cast(c))); } return e; }; std::string src_tmp; if (!url.empty()) { if (http::url_get_host(url) != "images.igdb.com") { bad_request(response, request, "Only images.igdb.com is allowed"); return; } const std::string ext = ext_from_url(url); src_tmp = coverdir + http::url_escape(key) + "_src" + ext; if (!http::download_file(url, src_tmp)) { bad_request(response, request, "Failed to download cover"); return; } } bool converted = false; #ifdef _WIN32 { // Convert using WIC helper; falls back to copying if already PNG std::wstring src_w(src_tmp.begin(), src_tmp.end()); std::wstring dst_w(dest_png.begin(), dest_png.end()); converted = platf::img::convert_to_png_96dpi(src_w, dst_w); if (!converted && file_is_png(src_tmp)) { std::error_code ec {}; std::filesystem::copy_file(src_tmp, dest_png, std::filesystem::copy_options::overwrite_existing, ec); converted = !ec.operator bool(); } } #else // Non-Windows: we can’t transcode here; accept only already-PNG data if (file_is_png(src_tmp)) { std::error_code ec {}; std::filesystem::rename(src_tmp, dest_png, ec); if (ec) { // If rename fails (cross-device), try copy std::filesystem::copy_file(src_tmp, dest_png, std::filesystem::copy_options::overwrite_existing, ec); if (!ec) { std::filesystem::remove(src_tmp); converted = true; } } else { converted = true; } } else { // Leave a clear error on non-Windows when not PNG bad_request(response, request, "Cover must be PNG on this platform"); return; } #endif // Cleanup temp source file when possible if (!src_tmp.empty()) { std::error_code del_ec {}; std::filesystem::remove(src_tmp, del_ec); } if (!converted) { bad_request(response, request, "Failed to convert cover to PNG"); return; } output_tree["status"] = true; output_tree["path"] = dest_png; send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "UploadCover: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Purge all auto-synced Playnite applications (playnite-managed == "auto"). * @api_examples{/api/apps/purge_autosync| POST| null} */ void purgeAutoSyncedApps(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); try { std::lock_guard apps_lock {apps_file_mutex()}; nlohmann::json output_tree; nlohmann::json new_apps = nlohmann::json::array(); std::string file = file_handler::read_file(config::stream.file_apps.c_str()); nlohmann::json file_tree = nlohmann::json::parse(file); auto &apps_node = file_tree["apps"]; int removed = 0; for (auto &app : apps_node) { std::string managed = app.contains("playnite-managed") && app["playnite-managed"].is_string() ? app["playnite-managed"].get() : std::string(); if (managed == "auto") { ++removed; continue; } new_apps.push_back(app); } file_tree["apps"] = new_apps; confighttp::refresh_client_apps_cache(file_tree); output_tree["status"] = true; output_tree["removed"] = removed; send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "purgeAutoSyncedApps: "sv << e.what(); bad_request(response, request, e.what()); } } #ifndef _WIN32 void downloadLogs(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } try { logging::log_flush(); std::vector> candidates; for (const auto &path : logging::recent_session_logs(30)) { std::error_code ec; const auto mtime = std::filesystem::last_write_time(path, ec); if (!ec) { candidates.emplace_back(path, mtime); } } // Snapshot timestamps before sorting: the active log can change during collection. // Match the Windows support bundle limits, selecting newest files first. std::sort(candidates.begin(), candidates.end(), [](const auto &a, const auto &b) { return a.second != b.second ? a.second > b.second : a.first < b.first; }); constexpr std::size_t max_files = 32; constexpr std::size_t max_bytes = 64 * 1024 * 1024; std::size_t bytes = 0; log_export::export_log_sanitizer_t sanitizer; std::vector entries; for (const auto &[path, mtime] : candidates) { if (entries.size() >= max_files || bytes >= max_bytes) { break; } std::error_code ec; if (!std::filesystem::is_regular_file(std::filesystem::symlink_status(path, ec))) { continue; } const auto size = std::filesystem::file_size(path, ec); if (ec || size > max_bytes - bytes) { continue; } std::ifstream file(path, std::ios::binary); if (!file) { continue; } // Bound reads even if the active file grows during collection. std::string data(static_cast(size), '\0'); file.read(data.data(), static_cast(data.size())); data.resize(static_cast(file.gcount())); bytes += data.size(); entries.push_back(log_export::make_export_log_entry(sanitizer, path.filename().string(), std::move(data), mtime)); } if (entries.empty()) { bad_request(response, request, "No retained log files are available"); return; } SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/zip"); headers.emplace("Content-Disposition", "attachment; filename=\"artlight_logs.zip\""); headers.emplace("Cache-Control", "no-store"); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); response->write(success_ok, log_export::build_zip_from_entries(entries), headers); } catch (const std::exception &e) { bad_request(response, request, e.what()); } } #endif /** * @brief Get the logs from the log file. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/logs| GET| null} */ void getLogs(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); auto read_sunshine_log = [](std::string &out) { auto log_path = logging::current_log_file(); if (!log_path.empty()) { const std::string log_path_str = log_path.string(); out = file_handler::read_file(log_path_str.c_str()); } }; std::string content; std::string source = "sunshine"; const auto query = request->parse_query_string(); if (const auto it = query.find("source"); it != query.end() && !it->second.empty()) { source = it->second; boost::algorithm::to_lower(source); } bool handled = false; if (source == "sunshine") { read_sunshine_log(content); handled = true; } #ifdef _WIN32 else if (is_helper_log_source(source)) { handled = true; read_helper_log(source, content); } #endif if (!handled) { read_sunshine_log(content); } // The logs page polls this endpoint. Returning the complete file on every poll // can overwhelm the browser once a long-running host has accumulated a large log. // Keep the legacy full response unless the caller explicitly requests a tail. if (const auto it = query.find("tail"); it != query.end()) { try { constexpr std::size_t kMaxTailLines = 10000; const auto requested = std::stoull(it->second); const auto tail_lines = std::min(requested, kMaxTailLines); if (tail_lines > 0 && !content.empty()) { std::size_t cursor = content.size(); if (content.back() == '\n') { --cursor; } std::size_t tail_start = 0; for (std::size_t line = 0; line < tail_lines && cursor > 0; ++line) { const auto separator = content.rfind('\n', cursor - 1); if (separator == std::string::npos) { tail_start = 0; break; } tail_start = separator + 1; cursor = separator; } if (tail_start > 0) { content.erase(0, tail_start); } } } catch (const std::exception &) { // Invalid tail values preserve the legacy full-log response. } } SimpleWeb::CaseInsensitiveMultimap headers; std::string contentType = "text/plain"; #ifdef _WIN32 contentType += "; charset="; contentType += currentCodePageToCharset(); #endif headers.emplace("Content-Type", contentType); headers.emplace("X-Frame-Options", "DENY"); headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); response->write(success_ok, content, headers); } #ifdef _WIN32 #endif /** * @brief Update existing credentials. * @param response The HTTP response object. * @param request The HTTP request object. * * The body for the POST request should be JSON serialized in the following format: * @code{.json} * { * "currentUsername": "Current Username", * "currentPassword": "Current Password", * "newUsername": "New Username", * "newPassword": "New Password", * "confirmNewPassword": "Confirm New Password" * } * @endcode * * @api_examples{/api/password| POST| {"currentUsername":"admin","currentPassword":"admin","newUsername":"admin","newPassword":"admin","confirmNewPassword":"admin"}} */ void savePassword(resp_https_t response, req_https_t request) { if ((!config::sunshine.username.empty() && !authenticate(response, request)) || !validateContentType(response, request, "application/json")) { return; } print_req(request); std::vector errors; std::stringstream ss; ss << request->content.rdbuf(); try { nlohmann::json input_tree = nlohmann::json::parse(ss.str()); nlohmann::json output_tree; std::string username = input_tree.value("currentUsername", ""); std::string newUsername = input_tree.value("newUsername", ""); std::string password = input_tree.value("currentPassword", ""); std::string newPassword = input_tree.value("newPassword", ""); std::string confirmPassword = input_tree.value("confirmNewPassword", ""); if (newUsername.empty()) { newUsername = username; } if (newUsername.empty()) { errors.push_back("Invalid Username"); } else { auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); if (config::sunshine.username.empty() || (boost::iequals(username, config::sunshine.username) && hash == config::sunshine.password)) { if (newPassword.empty() || newPassword != confirmPassword) { errors.push_back("Password Mismatch"); } else { if (http::save_user_creds(config::sunshine.credentials_file, newUsername, newPassword)) { service_unavailable(response, "Unable to write credentials file"); return; } if (http::reload_user_creds(config::sunshine.credentials_file)) { service_unavailable(response, "Unable to reload credentials file"); return; } sessionCookie.clear(); // force re-login output_tree["status"] = true; } } else { errors.push_back("Invalid Current Credentials"); } } if (!errors.empty()) { std::string error = std::accumulate(errors.begin(), errors.end(), std::string(), [](const std::string &a, const std::string &b) { return a.empty() ? b : a + ", " + b; }); bad_request(response, request, error); return; } send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "SavePassword: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Get a one-time password (OTP). * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/otp| GET| null} */ void getOTP(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); nlohmann::json output_tree; try { std::stringstream ss; ss << request->content.rdbuf(); nlohmann::json input_tree = nlohmann::json::parse(ss.str()); std::string passphrase = input_tree.value("passphrase", ""); if (passphrase.empty()) { throw std::runtime_error("Passphrase not provided!"); } if (passphrase.size() < 4) { throw std::runtime_error("Passphrase too short!"); } std::string deviceName = input_tree.value("deviceName", ""); output_tree["otp"] = nvhttp::request_otp(passphrase, deviceName); output_tree["ip"] = platf::get_local_ip_for_gateway(); output_tree["name"] = config::nvhttp.sunshine_name; output_tree["status"] = true; output_tree["message"] = "OTP created, effective within 3 minutes."; send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "OTP creation failed: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Send a PIN code to the host. * @param response The HTTP response object. * @param request The HTTP request object. * * The body for the POST request should be JSON serialized in the following format: * @code{.json} * { * "pin": "", * "name": "Friendly Client Name" * } * @endcode * * @api_examples{/api/pin| POST| {"pin":"1234","name":"My PC"}} */ void savePin(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); try { std::stringstream ss; ss << request->content.rdbuf(); nlohmann::json input_tree = nlohmann::json::parse(ss.str()); nlohmann::json output_tree; std::string pin = input_tree.value("pin", ""); std::string name = input_tree.value("name", ""); output_tree["status"] = nvhttp::pin(pin, name); if (!output_tree["status"].get()) { BOOST_LOG(warning) << "SavePin: no pending Moonlight pairing request accepted the submitted PIN"; } send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "SavePin: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Reset the display device persistence. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/reset-display-device-persistence| POST| null} */ void resetDisplayDevicePersistence(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); nlohmann::json output_tree; output_tree["status"] = display_helper_integration::reset_persistence(); send_response(response, output_tree); } #ifdef _WIN32 /** * @brief Execute the same terminal virtual-display cleanup as the restore hotkey. * @api_examples{/api/display/terminate_virtual| POST| {"status":true}} */ void postTerminateVirtualDisplay(resp_https_t response, req_https_t request) { if (!check_content_type(response, request, "application/json")) { return; } if (!authenticate(response, request)) { return; } print_req(request); nlohmann::json out; const auto result = platf::virtual_display_cleanup::terminate_all("maintenance_api"); out["status"] = result.virtual_displays_removed; out["driver_watchdog_stopped"] = true; out["recovery_disengaged"] = true; out["virtual_displays_removed"] = result.virtual_displays_removed; out["restore_dispatched"] = result.helper_revert_dispatched; out["database_restore_applied"] = result.database_restore_applied; out["watchdogs_stopped"] = true; if (!result.virtual_displays_removed) { out["error"] = "One or more managed virtual displays could not be removed."; } send_response(response, out, "no-store"); } /** * @brief Export the current Windows display settings as a golden restore snapshot. * @api_examples{/api/display/export_golden| POST| {"status":true}} */ void postExportGoldenDisplay(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json")) { return; } if (!authenticate(response, request)) { return; } print_req(request); nlohmann::json out; try { const bool ok = display_helper_integration::export_golden_restore(); out["status"] = ok; } catch (...) { out["status"] = false; } send_response(response, out); } #endif #ifdef _WIN32 // --- Golden snapshot helpers (Windows-only) --- static bool file_exists_nofail(const std::filesystem::path &p) { try { std::error_code ec; return std::filesystem::exists(p, ec); } catch (...) { return false; } } // Return candidate paths where the helper writes the golden snapshot. // We probe both the active user's Roaming/Local AppData and the current // process's CSIDL paths, mirroring the log bundle collection logic. static std::vector golden_snapshot_candidates() { std::vector out; auto add_if = [&](const std::filesystem::path &base) { if (!base.empty()) { out.emplace_back(base / L"Sunshine" / L"display_golden_restore.json"); } }; try { // Prefer the active user's known folders (impersonated) when available try { platf::dxgi::safe_token user_token; user_token.reset(platf::dxgi::retrieve_users_token(false)); auto add_known = [&](REFKNOWNFOLDERID id) { PWSTR baseW = nullptr; if (SUCCEEDED(SHGetKnownFolderPath(id, 0, user_token.get(), &baseW)) && baseW) { add_if(std::filesystem::path(baseW)); CoTaskMemFree(baseW); } }; add_known(FOLDERID_RoamingAppData); add_known(FOLDERID_LocalAppData); } catch (...) { // ignore } // Also probe the current process's CSIDL APPDATA and LOCAL_APPDATA auto add_csidl = [&](int csidl) { wchar_t baseW[MAX_PATH] = {}; if (SUCCEEDED(SHGetFolderPathW(nullptr, csidl, nullptr, SHGFP_TYPE_CURRENT, baseW))) { add_if(std::filesystem::path(baseW)); } }; add_csidl(CSIDL_APPDATA); add_csidl(CSIDL_LOCAL_APPDATA); add_csidl(CSIDL_COMMON_APPDATA); } catch (...) { // best-effort } return out; } static std::vector golden_snapshot_status_candidates() { std::vector out; for (const auto &snapshot : golden_snapshot_candidates()) { const auto parent = snapshot.parent_path(); if (!parent.empty()) { out.emplace_back(parent / L"display_golden_restore_status.json"); } } return out; } constexpr int kGoldenSnapshotLatestVersion = 2; struct golden_current_mode_t { unsigned int width {}; unsigned int height {}; double refresh_hz {}; }; struct golden_current_summary_t { bool valid {false}; bool active_virtual_display {false}; std::set devices; std::unordered_map modes; std::unordered_map hdr; std::unordered_map> origins; std::string primary; }; static std::string normalized_display_id(std::string id) { id.erase(id.begin(), std::find_if(id.begin(), id.end(), [](unsigned char ch) { return !std::isspace(ch); })); id.erase(std::find_if(id.rbegin(), id.rend(), [](unsigned char ch) { return !std::isspace(ch); }).base(), id.end()); std::transform(id.begin(), id.end(), id.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); return id; } static bool contains_ci(const std::string &haystack, const std::string &needle) { if (needle.empty()) { return true; } if (haystack.size() < needle.size()) { return false; } for (size_t i = 0; i + needle.size() <= haystack.size(); ++i) { bool match = true; for (size_t j = 0; j < needle.size(); ++j) { if (std::tolower(static_cast(haystack[i + j])) != std::tolower(static_cast(needle[j]))) { match = false; break; } } if (match) { return true; } } return false; } static bool equals_ci(const std::string &lhs, const std::string &rhs) { return lhs.size() == rhs.size() && contains_ci(lhs, rhs); } static bool is_virtual_display_device(const display_device::EnumeratedDevice &device) { if (contains_ci(device.m_device_id, "SUDOVDA") || contains_ci(device.m_device_id, "SUDOMAKER") || contains_ci(device.m_display_name, "SUDOVDA") || contains_ci(device.m_display_name, "SUDOMAKER") || contains_ci(device.m_friendly_name, "SUDOVDA") || contains_ci(device.m_friendly_name, "SUDOMAKER")) { return true; } if (equals_ci(device.m_friendly_name, "SudoMaker Virtual Display Adapter")) { return true; } return device.m_edid && equals_ci(device.m_edid->m_manufacturer_id, "SMK"); } static bool is_active_display_device(const display_device::EnumeratedDevice &device) { return device.m_info.has_value() || !device.m_display_name.empty(); } static std::optional floating_to_double(const display_device::FloatingPoint &value) { if (std::holds_alternative(value)) { return std::get(value); } const auto &rat = std::get(value); if (rat.m_denominator == 0) { return std::nullopt; } return static_cast(rat.m_numerator) / static_cast(rat.m_denominator); } static bool nearly_equal_refresh(double lhs, double rhs) { if (!std::isfinite(lhs) || !std::isfinite(rhs)) { return false; } const double diff = std::abs(lhs - rhs); const double scale = std::max({1.0, std::abs(lhs), std::abs(rhs)}); return diff <= scale * 1e-4; } static std::optional read_json_file_nofail(const std::filesystem::path &path) { try { std::ifstream file(path, std::ios::binary); if (!file.is_open()) { return std::nullopt; } auto parsed = nlohmann::json::parse(file, nullptr, false); if (parsed.is_discarded() || !parsed.is_object()) { return std::nullopt; } return parsed; } catch (...) { return std::nullopt; } } struct golden_restore_status_t { bool snapshot_out_of_date {false}; std::string reason; std::string last_failure_reason; size_t unresolved_restore_attempts {0}; size_t failure_threshold {0}; size_t failure_window_hours {0}; std::optional first_failure_unix_ms; std::optional latest_failure_unix_ms; std::optional updated_at_unix_ms; }; static std::optional read_golden_restore_status() { for (const auto &p : golden_snapshot_status_candidates()) { if (!file_exists_nofail(p)) { continue; } auto root = read_json_file_nofail(p); if (!root) { continue; } golden_restore_status_t status; status.snapshot_out_of_date = root->value("snapshot_out_of_date", false); status.reason = root->value("reason", std::string {}); status.last_failure_reason = root->value("last_failure_reason", std::string {}); status.unresolved_restore_attempts = static_cast( root->value("unresolved_restore_attempts", root->value("consecutive_restore_failures", 0ull)) ); status.failure_threshold = static_cast(root->value("failure_threshold", 0ull)); status.failure_window_hours = static_cast(root->value("failure_window_hours", 0ull)); auto first_it = root->find("first_failure_unix_ms"); if (first_it != root->end() && first_it->is_number_integer()) { status.first_failure_unix_ms = first_it->get(); } auto latest_it = root->find("latest_failure_unix_ms"); if (latest_it != root->end() && latest_it->is_number_integer()) { status.latest_failure_unix_ms = latest_it->get(); } auto updated_it = root->find("updated_at_unix_ms"); if (updated_it != root->end() && updated_it->is_number_integer()) { status.updated_at_unix_ms = updated_it->get(); } return status; } return std::nullopt; } static std::optional parse_snapshot_version(const nlohmann::json &root) { auto it = root.find("snapshot_version"); if (it == root.end() || !it->is_number_integer()) { return std::nullopt; } int version = it->get(); if (version < 1) { return std::nullopt; } return version; } static bool snapshot_has_layout_data(const nlohmann::json &root) { auto it = root.find("layouts"); if (it == root.end() || !it->is_object()) { return false; } for (auto entry = it->begin(); entry != it->end(); ++entry) { if (!entry.key().empty()) { if (entry->is_number_integer()) { return true; } if (entry->is_object()) { auto rotation = entry->find("rotation"); if (rotation != entry->end() && (rotation->is_number_integer() || rotation->is_string())) { return true; } } } } return false; } static std::set snapshot_topology_devices(const nlohmann::json &root) { std::set ids; auto topology = root.find("topology"); if (topology != root.end() && topology->is_array()) { for (const auto &group : *topology) { if (!group.is_array()) { continue; } for (const auto &device : group) { if (device.is_string()) { auto id = normalized_display_id(device.get()); if (!id.empty()) { ids.insert(std::move(id)); } } } } } if (ids.empty()) { auto modes = root.find("modes"); if (modes != root.end() && modes->is_object()) { for (auto it = modes->begin(); it != modes->end(); ++it) { auto id = normalized_display_id(it.key()); if (!id.empty()) { ids.insert(std::move(id)); } } } } return ids; } static std::unordered_map snapshot_modes(const nlohmann::json &root) { std::unordered_map modes; auto modes_it = root.find("modes"); if (modes_it == root.end() || !modes_it->is_object()) { return modes; } for (auto it = modes_it->begin(); it != modes_it->end(); ++it) { if (!it->is_object()) { continue; } auto id = normalized_display_id(it.key()); const auto width = it->value("w", 0u); const auto height = it->value("h", 0u); const auto num = it->value("num", 0u); const auto den = it->value("den", 0u); if (id.empty() || width == 0 || height == 0 || den == 0) { continue; } modes.emplace(std::move(id), golden_current_mode_t { .width = width, .height = height, .refresh_hz = static_cast(num) / static_cast(den), }); } return modes; } static std::unordered_map snapshot_hdr_states(const nlohmann::json &root) { std::unordered_map states; auto hdr_it = root.find("hdr"); if (hdr_it == root.end() || !hdr_it->is_object()) { return states; } for (auto it = hdr_it->begin(); it != hdr_it->end(); ++it) { if (!it->is_string()) { continue; } auto id = normalized_display_id(it.key()); auto value = boost::algorithm::to_lower_copy(it->get()); if (id.empty() || (value != "on" && value != "off")) { continue; } states.emplace(std::move(id), value == "on"); } return states; } static std::unordered_map> snapshot_origins(const nlohmann::json &root) { std::unordered_map> origins; auto origins_it = root.find("origins"); if (origins_it == root.end() || !origins_it->is_object()) { return origins; } for (auto it = origins_it->begin(); it != origins_it->end(); ++it) { if (!it->is_object()) { continue; } auto id = normalized_display_id(it.key()); if (id.empty()) { continue; } origins.emplace(std::move(id), std::make_pair(it->value("x", 0), it->value("y", 0))); } return origins; } static golden_current_summary_t current_golden_comparison_summary() { golden_current_summary_t summary; const auto devices = display_helper_integration::enumerate_devices(display_device::DeviceEnumerationDetail::Full); if (!devices) { return summary; } std::set exclusions; for (auto id : config::video.dd.snapshot_exclude_devices) { id = normalized_display_id(std::move(id)); if (!id.empty()) { exclusions.insert(std::move(id)); } } for (const auto &device : *devices) { if (is_virtual_display_device(device)) { if (is_active_display_device(device)) { summary.active_virtual_display = true; } continue; } if (!device.m_info || device.m_display_name.empty()) { continue; } auto id = normalized_display_id(device.m_device_id.empty() ? device.m_display_name : device.m_device_id); if (id.empty() || exclusions.contains(id)) { continue; } summary.devices.insert(id); if (auto refresh = floating_to_double(device.m_info->m_refresh_rate)) { summary.modes[id] = golden_current_mode_t { .width = device.m_info->m_resolution.m_width, .height = device.m_info->m_resolution.m_height, .refresh_hz = *refresh, }; } if (device.m_info->m_hdr_state) { summary.hdr[id] = *device.m_info->m_hdr_state == display_device::HdrState::Enabled; } summary.origins[id] = std::make_pair(device.m_info->m_origin_point.m_x, device.m_info->m_origin_point.m_y); if (device.m_info->m_primary) { summary.primary = id; } } summary.valid = !summary.devices.empty(); return summary; } static std::optional snapshot_current_mismatch_reason(const nlohmann::json &root) { const auto current = current_golden_comparison_summary(); if (current.active_virtual_display) { return std::nullopt; } if (!current.valid) { return std::nullopt; } const auto snapshot_devices = snapshot_topology_devices(root); if (snapshot_devices.empty()) { return "invalid_snapshot"; } if (snapshot_devices != current.devices) { return "display_set_changed"; } const auto modes = snapshot_modes(root); for (const auto &[id, mode] : modes) { auto current_mode = current.modes.find(id); if (current_mode == current.modes.end()) { continue; } if (mode.width != current_mode->second.width || mode.height != current_mode->second.height || !nearly_equal_refresh(mode.refresh_hz, current_mode->second.refresh_hz)) { return "display_mode_changed"; } } const auto hdr_states = snapshot_hdr_states(root); for (const auto &[id, hdr] : hdr_states) { auto current_hdr = current.hdr.find(id); if (current_hdr != current.hdr.end() && hdr != current_hdr->second) { return "hdr_changed"; } } auto primary_it = root.find("primary"); if (primary_it != root.end() && primary_it->is_string()) { const auto primary = normalized_display_id(primary_it->get()); if (!primary.empty() && !current.primary.empty() && primary != current.primary) { return "primary_changed"; } } const auto origins = snapshot_origins(root); for (const auto &[id, origin] : origins) { auto current_origin = current.origins.find(id); if (current_origin != current.origins.end() && origin != current_origin->second) { return "layout_changed"; } } return ""; } void getGoldenStatus(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); nlohmann::json out; bool exists = false; std::optional snapshot_version; bool has_layout = false; bool needs_layout_upgrade = false; bool out_of_date = false; bool comparison_available = false; std::string out_of_date_reason; std::string current_mismatch_reason; std::optional restore_status; try { const auto query = request->parse_query_string(); const auto compare_current_it = query.find("compare_current"); const bool compare_current = compare_current_it != query.end() && (boost::iequals(compare_current_it->second, "1") || boost::iequals(compare_current_it->second, "true") || boost::iequals(compare_current_it->second, "yes")); for (const auto &p : golden_snapshot_candidates()) { if (file_exists_nofail(p)) { exists = true; if (auto root = read_json_file_nofail(p)) { snapshot_version = parse_snapshot_version(*root); has_layout = snapshot_has_layout_data(*root); const bool latest_schema = snapshot_version && *snapshot_version >= kGoldenSnapshotLatestVersion; needs_layout_upgrade = !latest_schema || !has_layout; out_of_date = needs_layout_upgrade; if (needs_layout_upgrade) { out_of_date_reason = "schema_upgrade_required"; } // A current-topology comparison walks QDC_ALL_PATHS. On a system // with stale CCD paths, doing that for every ordinary Settings // status refresh can monopolize the single HTTPS I/O thread. It // is diagnostic-only, so retain it behind an explicit request. if (compare_current && !has_active_stream_sessions()) { if (auto mismatch = snapshot_current_mismatch_reason(*root)) { comparison_available = true; if (!mismatch->empty()) { current_mismatch_reason = *mismatch; } } } } else { needs_layout_upgrade = true; out_of_date = true; out_of_date_reason = "unreadable_snapshot"; } break; } } if (exists) { restore_status = read_golden_restore_status(); if (restore_status) { bool restore_status_out_of_date = restore_status->snapshot_out_of_date; if (!restore_status_out_of_date && restore_status->failure_threshold > 0 && restore_status->failure_window_hours > 0 && restore_status->unresolved_restore_attempts >= restore_status->failure_threshold && restore_status->first_failure_unix_ms) { const auto now_ms = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch() ) .count(); const auto window_ms = static_cast(restore_status->failure_window_hours) * 60LL * 60LL * 1000LL; restore_status_out_of_date = now_ms >= *restore_status->first_failure_unix_ms && now_ms - *restore_status->first_failure_unix_ms >= window_ms; } if (restore_status_out_of_date) { out_of_date = true; if (out_of_date_reason.empty()) { out_of_date_reason = "restore_failed_for_days"; } } } } } catch (...) { } out["exists"] = exists; out["snapshot_version"] = snapshot_version ? nlohmann::json(*snapshot_version) : nlohmann::json(nullptr); out["latest_snapshot_version"] = kGoldenSnapshotLatestVersion; out["has_layout"] = has_layout; out["needs_layout_upgrade"] = needs_layout_upgrade; out["out_of_date"] = out_of_date; out["comparison_available"] = comparison_available; out["out_of_date_reason"] = out_of_date_reason; out["current_mismatch_reason"] = current_mismatch_reason; if (restore_status) { out["restore_failure_count"] = restore_status->unresolved_restore_attempts; out["restore_failure_threshold"] = restore_status->failure_threshold; out["restore_failure_window_hours"] = restore_status->failure_window_hours; out["restore_status_reason"] = restore_status->reason; out["restore_last_failure_reason"] = restore_status->last_failure_reason; out["restore_first_failure_unix_ms"] = restore_status->first_failure_unix_ms ? nlohmann::json(*restore_status->first_failure_unix_ms) : nlohmann::json(nullptr); out["restore_latest_failure_unix_ms"] = restore_status->latest_failure_unix_ms ? nlohmann::json(*restore_status->latest_failure_unix_ms) : nlohmann::json(nullptr); out["restore_status_updated_at_unix_ms"] = restore_status->updated_at_unix_ms ? nlohmann::json(*restore_status->updated_at_unix_ms) : nlohmann::json(nullptr); } else { out["restore_failure_count"] = 0; out["restore_failure_threshold"] = 0; out["restore_failure_window_hours"] = 0; out["restore_status_reason"] = ""; out["restore_last_failure_reason"] = ""; out["restore_first_failure_unix_ms"] = nlohmann::json(nullptr); out["restore_latest_failure_unix_ms"] = nlohmann::json(nullptr); out["restore_status_updated_at_unix_ms"] = nlohmann::json(nullptr); } send_response(response, out); } void deleteGolden(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); nlohmann::json out; bool any_deleted = false; try { for (const auto &p : golden_snapshot_candidates()) { if (file_exists_nofail(p)) { std::error_code ec; std::filesystem::remove(p, ec); if (!ec) { any_deleted = true; } } } for (const auto &p : golden_snapshot_status_candidates()) { if (file_exists_nofail(p)) { std::error_code ec; std::filesystem::remove(p, ec); } } } catch (...) { } out["deleted"] = any_deleted; send_response(response, out); } #endif /** * @brief Restart Apollo. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/restart| POST| null} */ void restart(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); proc::proc.terminate(); // We may not return from this call platf::restart(); } /** * @brief Quit Apollo. * @param response The HTTP response object. * @param request The HTTP request object. * * On Windows, if running in a service, a special shutdown code is returned. */ void quit(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); BOOST_LOG(warning) << "Requested quit from config page!"sv; proc::proc.terminate(); #ifdef _WIN32 if (GetConsoleWindow() == NULL) { lifetime::exit_sunshine(ERROR_SHUTDOWN_IN_PROGRESS, true); } else #endif { lifetime::exit_sunshine(0, true); } // If exit fails, write a response after 5 seconds. std::thread write_resp([response] { std::this_thread::sleep_for(5s); response->write(); }); write_resp.detach(); } /** * @brief Generate a new API token with specified scopes. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/token| POST| {"scopes":[{"path":"/api/apps","methods":["GET"]}]}}} * * Request body example: * { * "scopes": [ * { "path": "/api/apps", "methods": ["GET", "POST"] } * ] * } * * Response example: * { "token": "..." } */ void generateApiToken(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } std::stringstream ss; ss << request->content.rdbuf(); const std::string request_body = ss.str(); auto token_opt = api_token_manager.generate_api_token(request_body, config::sunshine.username); nlohmann::json output_tree; if (!token_opt) { output_tree["error"] = "Invalid token request"; send_response(response, output_tree); return; } output_tree["token"] = *token_opt; send_response(response, output_tree); } /** * @brief List all active API tokens and their scopes. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/tokens| GET| null} * * Response example: * [ * { * "hash": "...", * "username": "admin", * "created_at": 1719000000, * "scopes": [ * { "path": "/api/apps", "methods": ["GET"] } * ] * } * ] */ void listApiTokens(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } nlohmann::json output_tree = nlohmann::json::parse(api_token_manager.list_api_tokens_json()); send_response(response, output_tree); } /** * @brief List all token-eligible API routes and methods. * @param response The HTTP response object. * @param request The HTTP request object. */ void listApiTokenRoutes(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); const auto catalog = snapshot_token_route_catalog(); nlohmann::json output_tree; output_tree["status"] = true; output_tree["routes"] = nlohmann::json::array(); for (const auto &[path, methods] : catalog) { output_tree["routes"].push_back({{"path", path}, {"methods", ordered_methods_for_catalog(methods)}}); } send_response(response, output_tree); } /** * @brief Revoke (delete) an API token by its hash. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/token/abcdef1234567890| DELETE| null} * * Response example: * { "status": true } */ void revokeApiToken(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } std::string hash; if (request->path_match.size() > 1) { hash = request->path_match[1]; } bool result = api_token_manager.revoke_api_token_by_hash(hash); nlohmann::json output_tree; if (result) { output_tree["status"] = true; } else { output_tree["error"] = "Internal server error"; } send_response(response, output_tree); } void listSessions(resp_https_t response, req_https_t request); void revokeSession(resp_https_t response, req_https_t request); /** * @brief Launch an application. * @param response The HTTP response object. * @param request The HTTP request object. */ void launchApp(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); try { std::stringstream ss; ss << request->content.rdbuf(); nlohmann::json input_tree = nlohmann::json::parse(ss.str()); if (!input_tree.contains("uuid") || !input_tree["uuid"].is_string()) { bad_request(response, request, "Missing or invalid uuid in request body"); return; } std::string uuid = input_tree["uuid"].get(); nlohmann::json output_tree; const auto &apps = proc::proc.get_apps(); for (auto &app : apps) { if (app.uuid == uuid) { crypto::named_cert_t named_cert { .name = "", .uuid = http::unique_id, .perm = crypto::PERM::_all, }; BOOST_LOG(info) << "Launching app ["sv << app.name << "] from web UI"sv; (void) proc::proc.running(); std::unique_lock lifecycle_lock(nvhttp::stream_lifecycle_mutex()); auto launch_session = nvhttp::make_launch_session(true, false, request->parse_query_string(), &named_cert); auto err = proc::proc.execute(app, launch_session); if (err) { bad_request(response, request, err == 503 ? "Failed to initialize video capture/encoding. Is a display connected and turned on?" : "Failed to start the specified application"); } else { output_tree["status"] = true; send_response(response, output_tree); } return; } } BOOST_LOG(error) << "Couldn't find app with uuid ["sv << uuid << ']'; bad_request(response, request, "Cannot find requested application"); } catch (std::exception &e) { BOOST_LOG(warning) << "LaunchApp: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Disconnect a client. * @param response The HTTP response object. * @param request The HTTP request object. */ void disconnect(resp_https_t response, req_https_t request) { if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { return; } print_req(request); try { std::stringstream ss; ss << request->content.rdbuf(); nlohmann::json output_tree; nlohmann::json input_tree = nlohmann::json::parse(ss.str()); std::string uuid = input_tree.value("uuid", ""); output_tree["status"] = nvhttp::find_and_stop_session(uuid, true); send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "Disconnect: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @brief Get ViGEmBus driver version and installation status. * @param response The HTTP response object. * @param request The HTTP request object. */ void getViGEmBusStatus(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); nlohmann::json output_tree; #ifdef _WIN32 std::string version_str; bool installed = false; bool version_compatible = false; std::filesystem::path driver_path = std::filesystem::path(std::getenv("SystemRoot") ? std::getenv("SystemRoot") : "C:\\Windows") / "System32" / "drivers" / "ViGEmBus.sys"; if (std::filesystem::exists(driver_path)) { installed = platf::getFileVersionInfo(driver_path, version_str); if (installed) { std::vector version_parts; std::stringstream ss(version_str); std::string part; while (std::getline(ss, part, '.')) { version_parts.push_back(part); } if (version_parts.size() >= 2) { int major = std::stoi(version_parts[0]); int minor = std::stoi(version_parts[1]); version_compatible = (major > 1) || (major == 1 && minor >= 17); } } } output_tree["installed"] = installed; output_tree["version"] = version_str; output_tree["version_compatible"] = version_compatible; output_tree["packaged_version"] = VIGEMBUS_PACKAGED_VERSION; // Drives whether the UI presents a missing ViGEmBus as a problem or as an // unused option: ArtLight's own driver provides controllers without it. output_tree["required"] = is_vigem_required(); #else output_tree["error"] = "ViGEmBus is only available on Windows"; output_tree["installed"] = false; output_tree["version"] = ""; output_tree["version_compatible"] = false; output_tree["packaged_version"] = ""; output_tree["required"] = false; #endif send_response(response, output_tree); } /** * @brief Install ViGEmBus driver with elevated permissions. * @param response The HTTP response object. * @param request The HTTP request object. */ void installViGEmBus(resp_https_t response, req_https_t request) { if (!check_content_type(response, request, "application/json")) { return; } if (!authenticate(response, request)) { return; } print_req(request); nlohmann::json output_tree; #ifdef _WIN32 const std::filesystem::path installer_path = platf::appdata().parent_path() / "scripts" / "vigembus_installer.exe"; if (!std::filesystem::exists(installer_path)) { output_tree["status"] = false; output_tree["error"] = "ViGEmBus installer not found"; send_response(response, output_tree); return; } std::error_code ec; boost::filesystem::path working_dir = boost::filesystem::path(installer_path.string()).parent_path(); platf::bp::environment env = platf::bp::this_process::env(); const std::string install_cmd = std::format("{} /quiet", installer_path.string()); auto child = platf::run_command(true, false, install_cmd, working_dir, env, nullptr, ec, nullptr); if (ec) { output_tree["status"] = false; output_tree["error"] = "Failed to start installer: " + ec.message(); send_response(response, output_tree); return; } child.wait(ec); if (ec) { output_tree["status"] = false; output_tree["error"] = "Installer failed: " + ec.message(); } else { int exit_code = child.exit_code(); output_tree["status"] = (exit_code == 0); output_tree["exit_code"] = exit_code; if (exit_code != 0) { output_tree["error"] = std::format("Installer exited with code {}", exit_code); } } #else output_tree["status"] = false; output_tree["error"] = "ViGEmBus installation is only available on Windows"; #endif send_response(response, output_tree); } bool is_browsable_executable(const std::filesystem::directory_entry &entry, const std::filesystem::file_status &status) { if (!std::filesystem::is_regular_file(status)) { return false; } #ifdef _WIN32 auto ext = entry.path().extension().string(); boost::to_lower(ext); return ext == ".exe" || ext == ".bat" || ext == ".cmd" || ext == ".ps1"; #else const auto perms = status.permissions(); return (perms & std::filesystem::perms::owner_exec) != std::filesystem::perms::none || (perms & std::filesystem::perms::group_exec) != std::filesystem::perms::none || (perms & std::filesystem::perms::others_exec) != std::filesystem::perms::none; #endif } nlohmann::json build_browse_entries(const std::filesystem::path &dir_path, const std::string &type_str) { nlohmann::json entries = nlohmann::json::array(); std::error_code iter_ec; for (auto it = std::filesystem::directory_iterator(dir_path, std::filesystem::directory_options::skip_permission_denied, iter_ec); !iter_ec && it != std::filesystem::directory_iterator(); it.increment(iter_ec)) { std::error_code status_ec; const auto status = it->status(status_ec); if (status_ec) { continue; } const bool is_dir = std::filesystem::is_directory(status); const bool is_file = std::filesystem::is_regular_file(status); const bool include = is_dir || type_str == "any" || (type_str == "file" && is_file) || (type_str == "executable" && is_browsable_executable(*it, status)); if (!include) { continue; } entries.push_back({ {"name", it->path().filename().string()}, {"path", it->path().string()}, {"type", is_dir ? "directory" : "file"} }); } std::sort(entries.begin(), entries.end(), [](const nlohmann::json &a, const nlohmann::json &b) { const bool a_dir = a["type"] == "directory"; const bool b_dir = b["type"] == "directory"; if (a_dir != b_dir) { return a_dir; } auto a_name = a["name"].get(); auto b_name = b["name"].get(); boost::to_lower(a_name); boost::to_lower(b_name); return a_name < b_name; }); return entries; } #ifdef _WIN32 nlohmann::json get_windows_drives() { nlohmann::json drives = nlohmann::json::array(); const DWORD mask = GetLogicalDrives(); for (char letter = 'A'; letter <= 'Z'; ++letter) { if ((mask & (1u << (letter - 'A'))) == 0) { continue; } std::string path {letter, ':', '\\'}; drives.push_back({{"name", path}, {"type", "directory"}, {"path", path}}); } return drives; } #endif void browseDirectory(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } try { auto query_params = request->parse_query_string(); const auto type_it = query_params.find("type"); const std::string type_str = type_it == query_params.end() ? "any" : type_it->second; const auto path_it = query_params.find("path"); std::filesystem::path dir_path = path_it == query_params.end() ? std::filesystem::path {} : std::filesystem::path {path_it->second}; #ifdef _WIN32 if (dir_path.empty() || dir_path == "\\" || dir_path == "/") { send_response(response, {{"path", ""}, {"parent", ""}, {"entries", get_windows_drives()}}); return; } #else if (dir_path.empty()) { dir_path = "/"; } #endif if (std::filesystem::is_regular_file(dir_path)) { dir_path = dir_path.parent_path(); } while (!dir_path.empty() && !std::filesystem::exists(dir_path)) { dir_path = dir_path.parent_path(); } if (dir_path.empty() || !std::filesystem::is_directory(dir_path)) { bad_request(response, request, "Directory does not exist"); return; } nlohmann::json output_tree; output_tree["path"] = dir_path.string(); output_tree["parent"] = dir_path.parent_path().empty() ? dir_path.string() : dir_path.parent_path().string(); output_tree["entries"] = build_browse_entries(dir_path, type_str); send_response(response, output_tree); } catch (const std::exception &e) { bad_request(response, request, e.what()); } } void start() { platf::set_thread_name("confighttp"); auto shutdown_event = mail::man->event(mail::shutdown); auto port_https = net::map_port(PORT_HTTPS); auto address_family = net::af_from_enum_string(config::sunshine.address_family); https_server_t server(config::nvhttp.cert, config::nvhttp.pkey); server.default_resource["DELETE"] = [](resp_https_t response, req_https_t request) { bad_request(response, request); }; server.default_resource["PATCH"] = [](resp_https_t response, req_https_t request) { bad_request(response, request); }; server.default_resource["POST"] = [](resp_https_t response, req_https_t request) { bad_request(response, request); }; server.default_resource["PUT"] = [](resp_https_t response, req_https_t request) { bad_request(response, request); }; // Static browser assets are public; every state-changing API below still // passes through the existing authentication and CSRF gates. server.resource["^/(assets|images)/.+$"]["GET"] = getWebAsset; server.resource["^/v2/(assets|images)/.+$"]["GET"] = getWebAsset; server.resource["^/v2/[^/]+\\.webmanifest$"]["GET"] = getWebAsset; server.default_resource["GET"] = getWebUi; thread_pool_util::ThreadPool blocking_route_pool; blocking_route_pool.start(1); clear_token_route_catalog(); auto register_api_route = [&](const char *pattern, const char *method, const auto &handler) { server.resource[pattern][method] = [method, handler](resp_https_t response, req_https_t request) { const std::string_view verb {method}; if (verb == "POST" || verb == "PATCH" || verb == "PUT" || verb == "DELETE") { const auto client_id = get_client_id(request); if (!validate_csrf_token(response, request, client_id)) { return; } } handler(std::move(response), std::move(request)); }; record_token_route(normalize_route_pattern(pattern), method); }; auto register_blocking_api_route = [&](const char *pattern, const char *method, const auto &handler) { register_api_route(pattern, method, [&blocking_route_pool, handler](resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } blocking_route_pool.push([handler, response = std::move(response), request = std::move(request)]() mutable { try { handler(response, request); } catch (const std::exception &e) { BOOST_LOG(error) << "Blocking config API handler failed: " << e.what(); bad_request(response, request, "Internal server error"); } catch (...) { BOOST_LOG(error) << "Blocking config API handler failed with an unknown exception"; bad_request(response, request, "Internal server error"); } }); }); }; register_api_route("^/api/pin$", "POST", savePin); register_api_route("^/api/otp$", "POST", getOTP); register_api_route("^/api/apps$", "GET", getApps); register_api_route("^/api/logs$", "GET", getLogs); register_api_route("^/api/browse$", "GET", browseDirectory); register_api_route("^/api/csrf-token$", "GET", getCSRFToken); register_api_route("^/api/apps$", "POST", saveApp); register_api_route("^/api/apps/([^/]+)/cover$", "GET", getAppCover); register_api_route("^/api/apps/reorder$", "POST", reorderApps); register_api_route("^/api/apps/delete$", "POST", deleteApp); register_api_route("^/api/apps/launch$", "POST", launchApp); register_api_route("^/api/apps/close$", "POST", closeApp); #ifdef _WIN32 register_api_route("^/api/apps/rtx_hdr/live$", "POST", updateAppRtxHdrLive); #endif register_api_route("^/api/logs$", "GET", getLogs); register_api_route("^/api/config$", "GET", getConfig); register_api_route("^/api/config$", "POST", saveConfig); // Partial updates for config settings; merges with existing file and // removes keys when value is null or empty string. register_api_route("^/api/config$", "PATCH", patchConfig); register_api_route("^/api/metadata$", "GET", getMetadata); register_api_route("^/api/configLocale$", "GET", getLocale); register_api_route("^/api/restart$", "POST", restart); register_api_route("^/api/quit$", "POST", quit); register_blocking_api_route("^/api/reset-display-device-persistence$", "POST", resetDisplayDevicePersistence); #if defined(_WIN32) register_blocking_api_route("^/api/display/terminate_virtual$", "POST", postTerminateVirtualDisplay); register_blocking_api_route("^/api/display/export_golden$", "POST", postExportGoldenDisplay); register_blocking_api_route("^/api/display/golden_status$", "GET", getGoldenStatus); register_api_route("^/api/display/golden$", "DELETE", deleteGolden); #endif register_api_route("^/api/password$", "POST", savePassword); register_blocking_api_route("^/api/display-devices$", "GET", getDisplayDevices); #ifdef _WIN32 register_blocking_api_route("^/api/framegen/edid-refresh$", "GET", getFramegenEdidRefresh); register_api_route("^/api/health/vigem$", "GET", getVigemHealth); register_api_route("^/api/health/vulkan-hdr-layer$", "GET", getVulkanHdrLayerHealth); register_api_route("^/api/health/vulkan-hdr-layer/register$", "POST", postVulkanHdrLayerRegister); register_api_route("^/api/health/crashdump$", "GET", getCrashDumpStatus); register_api_route("^/api/health/crashdump/dismiss$", "POST", postCrashDumpDismiss); #endif register_api_route("^/api/apps/([A-Fa-f0-9-]+)/cover$", "GET", getAppCover); register_api_route("^/api/apps/([A-Fa-f0-9-]+)/icon$", "GET", getAppIcon); register_api_route("^/api/apps/([A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12})$", "DELETE", deleteApp); register_api_route("^/api/apps/([0-9]+)$", "DELETE", deleteApp); register_api_route("^/api/clients/unpair-all$", "POST", unpairAll); register_api_route("^/api/clients/list$", "GET", getClients); register_api_route("^/api/clients/display-layout$", "GET", getClientDisplayLayout); register_api_route("^/api/clients/display-layout$", "PUT", putClientDisplayLayout); register_api_route("^/api/clients/hdr-profiles$", "GET", getHdrProfiles); register_api_route("^/api/clients/update$", "POST", updateClient); register_api_route("^/api/clients/unpair$", "POST", unpair); register_api_route("^/api/clients/disconnect$", "POST", disconnectClient); register_api_route("^/api/apps/close$", "POST", closeApp); register_api_route("^/api/session/status$", "GET", getSessionStatus); register_api_route("^/api/host/stats$", "GET", getHostStats); register_api_route("^/api/host/info$", "GET", getHostInfo); register_api_route("^/api/rtsp/sessions$", "GET", listRTSPSessions); register_blocking_api_route("^/api/webrtc/capabilities$", "GET", getWebRTCCapabilities); register_api_route("^/api/webrtc/sessions$", "GET", listWebRTCSessions); register_api_route("^/api/history/sessions$", "GET", listSessionHistory); register_api_route("^/api/history/sessions/active$", "GET", getActiveSessionHistory); register_api_route("^/api/history/sessions/([A-Fa-f0-9-]+)$", "GET", getSessionHistoryDetail); register_api_route("^/api/history/sessions/([A-Fa-f0-9-]+)$", "DELETE", deleteSessionHistory); register_blocking_api_route("^/api/webrtc/sessions$", "POST", createWebRTCSession); register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)$", "GET", getWebRTCSession); register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)$", "DELETE", deleteWebRTCSession); register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/offer$", "POST", postWebRTCOffer); register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/answer$", "GET", getWebRTCAnswer); register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/ice$", "GET", getWebRTCIce); register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/ice$", "POST", postWebRTCIce); register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/ice/stream$", "GET", getWebRTCIceStream); register_api_route("^/api/webrtc/cert$", "GET", getWebRTCCert); // Keep legacy cover upload endpoint present in upstream master register_api_route("^/api/covers/upload$", "POST", uploadCover); register_api_route("^/api/covers/([0-9]+)$", "GET", getCover); register_api_route("^/api/vigembus/status$", "GET", getViGEmBusStatus); register_api_route("^/api/vigembus/install$", "POST", installViGEmBus); register_api_route("^/api/apps/purge_autosync$", "POST", purgeAutoSyncedApps); #if defined(_WIN32) || defined(__linux__) register_api_route("^/api/frame-limiter/status$", "GET", getFrameLimiterStatus); #endif register_api_route("^/api/steam/status$", "GET", getSteamStatus); register_api_route("^/api/steam/games$", "GET", getSteamGames); register_api_route("^/api/steam/force_sync$", "POST", postSteamForceSync); register_api_route("^/api/steam/launch$", "POST", postSteamLaunch); #ifdef __linux__ register_api_route("^/api/lutris/status$", "GET", getLutrisStatus); register_api_route("^/api/lutris/games$", "GET", getLutrisGames); register_api_route("^/api/lutris/force_sync$", "POST", postLutrisForceSync); register_api_route("^/api/lutris/launch$", "POST", postLutrisLaunch); #endif #ifdef _WIN32 register_api_route("^/api/playnite/status$", "GET", getPlayniteStatus); register_api_route("^/api/rtss/status$", "GET", getRtssStatus); register_api_route("^/api/lossless_scaling/status$", "GET", getLosslessScalingStatus); register_api_route("^/api/playnite/install$", "POST", installPlaynite); register_api_route("^/api/playnite/uninstall$", "POST", uninstallPlaynite); register_api_route("^/api/playnite/games$", "GET", getPlayniteGames); register_api_route("^/api/playnite/categories$", "GET", getPlayniteCategories); register_api_route("^/api/playnite/force_sync$", "POST", postPlayniteForceSync); register_blocking_api_route("^/api/playnite/cover$", "POST", postPlayniteCover); register_api_route("^/api/playnite/launch$", "POST", postPlayniteLaunch); // Export logs bundle (Windows only). Collection and sanitizing can take // seconds on large log sets; keep it off the single io thread so the rest // of the WebUI stays responsive during an export. register_blocking_api_route("^/api/logs/export$", "GET", downloadPlayniteLogs); register_blocking_api_route("^/api/logs/export_crash/manifest$", "GET", getCrashBundleManifest); register_blocking_api_route("^/api/logs/export_crash$", "GET", downloadCrashBundle); #else register_blocking_api_route("^/api/logs/export$", "GET", downloadLogs); #endif server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; server.resource["^/images/logo-apollo-45.png$"]["GET"] = getApolloLogoImage; server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getApolloLogoImage; // legacy alias server.resource["^/assets\\/.+$"]["GET"] = getNodeModules; register_api_route("^/api/token$", "POST", generateApiToken); register_api_route("^/api/tokens$", "GET", listApiTokens); register_api_route("^/api/token/routes$", "GET", listApiTokenRoutes); register_api_route("^/api/token/([a-fA-F0-9]+)$", "DELETE", revokeApiToken); // Session validation endpoint used by the web UI to detect HttpOnly session cookies server.resource["^/api-tokens/?$"]["GET"] = getTokenPage; register_api_route("^/api/auth/login$", "POST", loginUser); register_api_route("^/api/auth/refresh$", "POST", refreshSession); register_api_route("^/api/auth/logout$", "POST", logoutUser); register_api_route("^/api/auth/status$", "GET", authStatus); register_api_route("^/api/auth/sessions$", "GET", listSessions); register_api_route("^/api/auth/sessions/([A-Fa-f0-9]+)$", "DELETE", revokeSession); server.config.reuse_address = true; server.config.address = net::get_bind_address(address_family); server.config.port = port_https; auto accept_and_run = [&](auto *server) { try { platf::set_thread_name("confighttp::tcp"); server->start([](unsigned short) { BOOST_LOG(info) << "Configuration UI available at ["sv << get_web_ui_url() << "]"; }); } catch (boost::system::system_error &err) { // It's possible the exception gets thrown after calling server->stop() from a different thread if (shutdown_event->peek()) { return; } BOOST_LOG(fatal) << "Couldn't start Configuration HTTPS server on port ["sv << port_https << "]: "sv << err.what(); shutdown_event->raise(true); return; } }; api_token_manager.load_api_tokens(); session_token_manager.load_session_tokens(); std::thread tcp {accept_and_run, &server}; // Start a background task to clean up expired session tokens every hour std::jthread cleanup_thread([shutdown_event]() { while (!shutdown_event->view(std::chrono::hours(1))) { if (session_token_manager.cleanup_expired_session_tokens()) { session_token_manager.save_session_tokens(); } } }); // Wait for any event shutdown_event->view(); server.stop(); tcp.join(); blocking_route_pool.stop(); blocking_route_pool.join(); // std::jthread (cleanup_thread) auto-joins on destruction, no need for joinable/join } /** * @brief Handles the HTTP request to serve the API token management page. * * This function authenticates the incoming request and, if successful, * reads the "api-tokens.html" file from the web directory and sends its * contents as an HTTP response with the appropriate content type. * * @param response The HTTP response object used to send data back to the client. * @param request The HTTP request object containing client request data. */ void getTokenPage(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); std::string content = file_handler::read_file(WEB_DIR "api-tokens.html"); SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "text/html; charset=utf-8"); response->write(content, headers); } /** * @brief Converts a string representation of a token scope to its corresponding TokenScope enum value. * * This function takes a string view and returns the matching TokenScope enum value. * Supported string values are "Read", "read", "Write", and "write". * If the input string does not match any known scope, an std::invalid_argument exception is thrown. * * @param s The string view representing the token scope. * @return TokenScope The corresponding TokenScope enum value. * @throws std::invalid_argument If the input string does not match any known scope. */ TokenScope scope_from_string(std::string_view scope) { return policy::scope_from_string(scope); } /** * @brief Converts a TokenScope enum value to its string representation. * @param scope The TokenScope enum value to convert. * @return The string representation of the scope. */ std::string scope_to_string(TokenScope scope) { return policy::scope_to_string(scope); } /** * @brief User login endpoint to generate session tokens. * @param response The HTTP response object. * @param request The HTTP request object. * * Expects JSON body: * { * "username": "string", * "password": "string" * } * * Returns: * { * "status": true, * "token": "session_token_string", * "expires_in": 86400 * } * * @api_examples{/api/auth/login| POST| {"username": "admin", "password": "password"}} */ void loginUser(resp_https_t response, req_https_t request) { print_req(request); std::stringstream ss; ss << request->content.rdbuf(); try { nlohmann::json input_tree = nlohmann::json::parse(ss); if (!input_tree.contains("username") || !input_tree.contains("password")) { bad_request(response, request, "Missing username or password"); return; } std::string username = input_tree["username"].get(); std::string password = input_tree["password"].get(); std::string redirect_url = input_tree.value("redirect", "/"); bool remember_me = false; if (auto it = input_tree.find("remember_me"); it != input_tree.end()) { try { remember_me = it->get(); } catch (const nlohmann::json::exception &) { remember_me = false; } } std::string user_agent; if (auto ua = request->header.find("user-agent"); ua != request->header.end()) { user_agent = ua->second; } std::string remote_address = net::addr_to_normalized_string(request->remote_endpoint().address()); APIResponse api_response = session_token_api.login(username, password, redirect_url, remember_me, user_agent, remote_address); write_api_response(response, api_response); } catch (const nlohmann::json::exception &e) { BOOST_LOG(warning) << "Login JSON error:"sv << e.what(); bad_request(response, request, "Invalid JSON format"); } } void refreshSession(resp_https_t response, req_https_t request) { print_req(request); std::string refresh_token; if (auto auth = request->header.find("authorization"); auth != request->header.end() && auth->second.rfind("Refresh ", 0) == 0) { refresh_token = auth->second.substr(8); } if (refresh_token.empty()) { refresh_token = extract_refresh_token_from_cookie(request->header); } // Allow JSON body input for API clients that do not rely on cookies/Authorization header if (refresh_token.empty()) { std::stringstream ss; ss << request->content.rdbuf(); if (!ss.str().empty()) { try { auto body = nlohmann::json::parse(ss); if (auto it = body.find("refresh_token"); it != body.end() && it->is_string()) { refresh_token = it->get(); } } catch (const nlohmann::json::exception &) { } } } std::string user_agent; if (auto ua = request->header.find("user-agent"); ua != request->header.end()) { user_agent = ua->second; } std::string remote_address = net::addr_to_normalized_string(request->remote_endpoint().address()); APIResponse api_response = session_token_api.refresh_session(refresh_token, user_agent, remote_address); write_api_response(response, api_response); } /** * @brief User logout endpoint to revoke session tokens. * @param response The HTTP response object. * @param request The HTTP request object. * * @api_examples{/api/auth/logout| POST| null} */ void logoutUser(resp_https_t response, req_https_t request) { print_req(request); std::string session_token; if (auto auth = request->header.find("authorization"); auth != request->header.end() && auth->second.rfind("Session ", 0) == 0) { session_token = auth->second.substr(8); } if (session_token.empty()) { session_token = extract_session_token_from_cookie(request->header); } std::string refresh_token = extract_refresh_token_from_cookie(request->header); APIResponse api_response = session_token_api.logout(session_token, refresh_token); write_api_response(response, api_response); } void listSessions(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); std::string raw_token; if (auto auth = request->header.find("authorization"); auth != request->header.end() && auth->second.rfind("Session ", 0) == 0) { raw_token = auth->second.substr(8); } if (raw_token.empty()) { raw_token = extract_session_token_from_cookie(request->header); } std::string active_hash; if (!raw_token.empty()) { if (auto hash = session_token_manager.get_hash_for_token(raw_token)) { active_hash = *hash; } } APIResponse api_response = session_token_api.list_sessions(config::sunshine.username, active_hash); write_api_response(response, api_response); } void revokeSession(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) { return; } print_req(request); if (request->path_match.size() < 2) { bad_request(response, request, "Session id required"); return; } std::string session_hash = request->path_match[1].str(); std::string raw_token; if (auto auth = request->header.find("authorization"); auth != request->header.end() && auth->second.rfind("Session ", 0) == 0) { raw_token = auth->second.substr(8); } if (raw_token.empty()) { raw_token = extract_session_token_from_cookie(request->header); } bool is_current = false; if (!raw_token.empty()) { if (auto hash = session_token_manager.get_hash_for_token(raw_token)) { is_current = boost::iequals(*hash, session_hash); } } APIResponse api_response = session_token_api.revoke_session_by_hash(session_hash); if (api_response.status_code == StatusCode::success_ok && is_current) { std::string clear_cookie = std::string(session_cookie_name) + "=; Path=/; HttpOnly; SameSite=Strict; Secure; Priority=High; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0"; std::string clear_refresh_cookie = std::string(refresh_cookie_name) + "=; Path=/; HttpOnly; SameSite=Strict; Secure; Priority=High; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0"; api_response.headers.emplace("Set-Cookie", std::move(clear_cookie)); api_response.headers.emplace("Set-Cookie", std::move(clear_refresh_cookie)); } write_api_response(response, api_response); } /** * @brief Authentication status endpoint. * Returns whether credentials are configured and if authentication is required for protected API calls. * This allows the frontend to avoid showing a login modal when not necessary. * * Response JSON shape: * { * "credentials_configured": true|false, * "login_required": true|false, * "authenticated": true|false * } * * login_required becomes true only when credentials are configured and the supplied * request lacks valid authentication (session token or bearer token) for protected APIs. */ void authStatus(resp_https_t response, req_https_t request) { print_req(request); bool credentials_configured = !config::sunshine.username.empty(); // Determine if current request has valid auth (session or bearer) using existing check_auth bool authenticated = false; if (credentials_configured) { if (auto result = check_auth(request); result.ok) { authenticated = true; // check_auth returns ok for public routes; refine below // We only consider it authenticated if an auth header or cookie was present and validated. std::string auth_header; if (auto auth_it = request->header.find("authorization"); auth_it != request->header.end()) { auth_header = auth_it->second; } else { std::string token = extract_session_token_from_cookie(request->header); if (!token.empty()) { auth_header = "Session " + token; } } if (auth_header.empty()) { authenticated = false; // public access granted but no credentials supplied } else { // Re-run only auth layer for supplied header specifically to ensure validity auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); auto header_check = check_auth(address, auth_header, "/api/config", "GET"); // use protected path for validation authenticated = header_check.ok; } } } bool login_required = credentials_configured && !authenticated; nlohmann::json tree; tree["credentials_configured"] = credentials_configured; tree["login_required"] = login_required; tree["authenticated"] = authenticated; SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json; charset=utf-8"); add_cors_headers(headers); response->write(SimpleWeb::StatusCode::success_ok, tree.dump(), headers); } } // namespace confighttp