/** * @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 // lib includes #include #include #include #include #include #include // local includes #include "config.h" #include "confighttp.h" #include "crypto.h" #include "display_device.h" #include "file_handler.h" #include "globals.h" #include "httpcommon.h" #include "logging.h" #include "network.h" #include "nvhttp.h" #include "platform/common.h" #include "process.h" #include "utility.h" #include "uuid.h" #include "version.h" #ifdef _WIN32 #include "platform/windows/utils.h" #endif using namespace std::literals; namespace confighttp { namespace fs = std::filesystem; 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>; // Keep the base enum for client operations. 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) << "METHOD :: "sv << request->method; BOOST_LOG(debug) << "DESTINATION :: "sv << request->path; for (auto &[name, val] : request->header) { BOOST_LOG(debug) << name << " -- " << (name == "Authorization" ? "CREDENTIALS REDACTED" : val); } BOOST_LOG(debug) << " [--] "sv; for (auto &[name, val] : request->parse_query_string()) { BOOST_LOG(debug) << name << " -- " << val; } BOOST_LOG(debug) << " [--] "sv; } /** * @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) { SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json"); response->write(output_tree.dump(), 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) << "Web UI: ["sv << address << "] -- not authorized"sv; constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_unauthorized; nlohmann::json tree; tree["status_code"] = code; tree["status"] = false; tree["error"] = "Unauthorized"; const SimpleWeb::CaseInsensitiveMultimap headers { {"Content-Type", "application/json"} }; 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} }; response->write(SimpleWeb::StatusCode::redirection_temporary_redirect, headers); } /** * @brief Retrieve the value of a key from a cookie string. * @param cookieString The cookie header string. * @param key The key to search. * @return The value if found, empty string otherwise. */ std::string getCookieValue(const std::string& cookieString, const std::string& key) { std::string keyWithEqual = key + "="; std::size_t startPos = cookieString.find(keyWithEqual); if (startPos == std::string::npos) return ""; startPos += keyWithEqual.length(); std::size_t endPos = cookieString.find(";", startPos); if (endPos == std::string::npos) return cookieString.substr(startPos); return cookieString.substr(startPos, endPos - startPos); } /** * @brief Check if the IP origin is allowed. * @param response The HTTP response object. * @param request The HTTP request object. * @return True if allowed, false otherwise. */ bool checkIPOrigin(resp_https_t response, req_https_t request) { auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); auto ip_type = net::from_address(address); if (ip_type > http::origin_web_ui_allowed) { BOOST_LOG(info) << "Web UI: ["sv << address << "] -- denied"sv; response->write(SimpleWeb::StatusCode::client_error_forbidden); return false; } return true; } /** * @brief Authenticate the request. * @param response The HTTP response object. * @param request The HTTP request object. * @param needsRedirect Whether to redirect on failure. * @return True if authenticated, false otherwise. * * This function uses session cookies (if set) and ensures they have not expired. */ bool authenticate(resp_https_t response, req_https_t request, bool needsRedirect = false) { if (!checkIPOrigin(response, request)) return false; // If credentials not set, redirect to welcome. if (config::sunshine.username.empty()) { send_redirect(response, request, "/welcome"); return false; } // Guard: on failure, redirect if requested. auto fg = util::fail_guard([&]() { if (needsRedirect) { std::string redir_path = "/login?redir=."; redir_path += request->path; send_redirect(response, request, redir_path.c_str()); } else { send_unauthorized(response, request); } }); if (sessionCookie.empty()) return false; // Check for expiry if (std::chrono::steady_clock::now() - cookie_creation_time > SESSION_EXPIRE_DURATION) { sessionCookie.clear(); return false; } auto cookies = request->header.find("cookie"); if (cookies == request->header.end()) return false; auto authCookie = getCookieValue(cookies->second, "auth"); if (authCookie.empty() || util::hex(crypto::hash(authCookie + config::sunshine.salt)).to_string() != sessionCookie) return false; fg.disable(); return true; } /** * @brief Send a 404 Not Found response. * @param response The HTTP response object. * @param request The HTTP request object. */ void not_found(resp_https_t response, [[maybe_unused]] req_https_t request) { constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_not_found; nlohmann::json tree; tree["status_code"] = static_cast(code); tree["error"] = "Not Found"; SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json"); 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") { constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_bad_request; nlohmann::json tree; tree["status_code"] = static_cast(code); tree["status"] = false; tree["error"] = error_message; SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json"); response->write(code, tree.dump(), headers); } /** * @brief Get the index page. * @param response The HTTP response object. * @param request The HTTP request object. */ void getIndexPage(resp_https_t response, req_https_t request) { if (!authenticate(response, request, true)) { return; } print_req(request); std::string content = file_handler::read_file(WEB_DIR "index.html"); SimpleWeb::CaseInsensitiveMultimap headers { {"Content-Type", "text/html; charset=utf-8"} }; response->write(content, headers); } /** * @brief Get the PIN page. * @param response The HTTP response object. * @param request The HTTP request object. */ void getPinPage(resp_https_t response, req_https_t request) { if (!authenticate(response, request, true)) { return; } print_req(request); std::string content = file_handler::read_file(WEB_DIR "pin.html"); SimpleWeb::CaseInsensitiveMultimap headers { {"Content-Type", "text/html; charset=utf-8"} }; response->write(content, headers); } /** * @brief Get the apps page. * @param response The HTTP response object. * @param request The HTTP request object. */ void getAppsPage(resp_https_t response, req_https_t request) { if (!authenticate(response, request, true)) { return; } print_req(request); std::string content = file_handler::read_file(WEB_DIR "apps.html"); SimpleWeb::CaseInsensitiveMultimap headers { {"Content-Type", "text/html; charset=utf-8"}, {"Access-Control-Allow-Origin", "https://images.igdb.com/"} }; response->write(content, headers); } /** * @brief Get the clients page. * @param response The HTTP response object. * @param request The HTTP request object. */ void getClientsPage(resp_https_t response, req_https_t request) { if (!authenticate(response, request, true)) { return; } print_req(request); std::string content = file_handler::read_file(WEB_DIR "clients.html"); SimpleWeb::CaseInsensitiveMultimap headers { {"Content-Type", "text/html; charset=utf-8"} }; response->write(content, headers); } /** * @brief Get the configuration page. * @param response The HTTP response object. * @param request The HTTP request object. */ void getConfigPage(resp_https_t response, req_https_t request) { if (!authenticate(response, request, true)) { return; } print_req(request); std::string content = file_handler::read_file(WEB_DIR "config.html"); SimpleWeb::CaseInsensitiveMultimap headers { {"Content-Type", "text/html; charset=utf-8"} }; response->write(content, headers); } /** * @brief Get the password page. * @param response The HTTP response object. * @param request The HTTP request object. */ void getPasswordPage(resp_https_t response, req_https_t request) { if (!authenticate(response, request, true)) { return; } print_req(request); std::string content = file_handler::read_file(WEB_DIR "password.html"); SimpleWeb::CaseInsensitiveMultimap headers { {"Content-Type", "text/html; charset=utf-8"} }; response->write(content, headers); } /** * @brief Get the login page. * @param response The HTTP response object. * @param request The HTTP request object. * * @todo Combine this function with getWelcomePage if appropriate. */ void getLoginPage(resp_https_t response, req_https_t request) { if (!checkIPOrigin(response, request)) { return; } if (config::sunshine.username.empty()) { send_redirect(response, request, "/welcome"); return; } std::string content = file_handler::read_file(WEB_DIR "login.html"); SimpleWeb::CaseInsensitiveMultimap headers { {"Content-Type", "text/html; charset=utf-8"} }; response->write(content, headers); } /** * @brief Get the welcome page. * @param response The HTTP response object. * @param request The HTTP request object. */ void getWelcomePage(resp_https_t response, req_https_t request) { print_req(request); if (!config::sunshine.username.empty()) { send_redirect(response, request, "/"); return; } std::string content = file_handler::read_file(WEB_DIR "welcome.html"); SimpleWeb::CaseInsensitiveMultimap headers { {"Content-Type", "text/html; charset=utf-8"} }; response->write(content, headers); } /** * @brief Get the troubleshooting page. * @param response The HTTP response object. * @param request The HTTP request object. */ void getTroubleshootingPage(resp_https_t response, req_https_t request) { if (!authenticate(response, request, true)) { return; } print_req(request); std::string content = file_handler::read_file(WEB_DIR "troubleshooting.html"); SimpleWeb::CaseInsensitiveMultimap headers { {"Content-Type", "text/html; charset=utf-8"} }; response->write(content, headers); } /** * @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 { {"Content-Type", "image/x-icon"} }; response->write(SimpleWeb::StatusCode::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 { {"Content-Type", "image/png"} }; response->write(SimpleWeb::StatusCode::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); std::ifstream in(filePath.string(), std::ios::binary); response->write(SimpleWeb::StatusCode::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::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(); 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 UUID must be empty. * To update an existing application, you must provide the current UUID 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 (!authenticate(response, request)) { return; } print_req(request); std::stringstream ss; ss << request->content.rdbuf(); BOOST_LOG(info) << config::stream.file_apps; try { // TODO: Input Validation // Read the input JSON from the request body. nlohmann::json inputTree = nlohmann::json::parse(ss.str()); // 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); // Migrate/merge the new app into the file tree. proc::migrate_apps(&fileTree, &inputTree); // Write the updated file tree back to disk. file_handler::write_file(config::stream.file_apps.c_str(), fileTree.dump(4)); proc::refresh(config::stream.file_apps); // 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()); } } /** * @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 (!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 (!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); 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/9999| DELETE| null} */ void deleteApp(resp_https_t response, req_https_t request) { if (!authenticate(response, request)) return; print_req(request); auto args = request->parse_query_string(); if (args.find("uuid"s) == std::end(args)) { bad_request(response, request, "Missing a required parameter to delete app"); return; } auto uuid = nvhttp::get_arg(args, "uuid"); try { // Read the apps file into a nlohmann::json object. std::string content = file_handler::read_file(config::stream.file_apps.c_str()); nlohmann::json fileTree = nlohmann::json::parse(content); // Remove any app with the matching uuid directly from the "apps" array. if (fileTree.contains("apps") && fileTree["apps"].is_array()) { auto& apps = fileTree["apps"]; apps.erase( std::remove_if(apps.begin(), apps.end(), [&uuid](const nlohmann::json& app) { return app.value("uuid", "") == uuid; }), apps.end() ); } // Write the updated JSON back to the file. file_handler::write_file(config::stream.file_apps.c_str(), fileTree.dump(4)); proc::refresh(config::stream.file_apps); // Prepare and send the response. nlohmann::json outputTree; outputTree["status"] = true; send_response(response, outputTree); } 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; send_response(response, output_tree); } /** * @brief Update client information. * @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 (!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::string name = input_tree.value("name", ""); std::string display_mode = input_tree.value("display_mode", ""); 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)); output_tree["status"] = nvhttp::update_device_info(uuid, name, display_mode, do_cmds, undo_cmds, perm); 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 (!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 (!authenticate(response, request)) { return; } print_req(request); nvhttp::erase_all_clients(); proc::proc.terminate(); nlohmann::json output_tree; output_tree["status"] = true; 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_VER; #ifdef _WIN32 output_tree["vdisplayStatus"] = (int)proc::vDisplayDriverStatus; #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 the locale setting. * @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"}} */ void saveConfig(resp_https_t response, req_https_t request) { if (!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); for (const auto &[k, v] : input_tree.items()) { 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()); output_tree["status"] = true; send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "SaveConfig: "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 (!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); std::string path = coverdir + http::url_escape(key) + ".png"; if (!url.empty()) { if (http::url_get_host(url) != "images.igdb.com") { bad_request(response, request, "Only images.igdb.com is allowed"); return; } if (!http::download_file(url, path)) { bad_request(response, request, "Failed to download cover"); return; } } else { auto data = SimpleWeb::Crypto::Base64::decode(input_tree.value("data", "")); std::ofstream imgfile(path); imgfile.write(data.data(), static_cast(data.size())); } output_tree["status"] = true; output_tree["path"] = path; send_response(response, output_tree); } catch (std::exception &e) { BOOST_LOG(warning) << "UploadCover: "sv << e.what(); bad_request(response, request, e.what()); } } /** * @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); std::string content = file_handler::read_file(config::sunshine.log_file.c_str()); SimpleWeb::CaseInsensitiveMultimap headers; std::string contentType = "text/plain"; #ifdef _WIN32 contentType += "; charset="; contentType += currentCodePageToCharset(); #endif headers.emplace("Content-Type", contentType); response->write(SimpleWeb::StatusCode::success_ok, content, headers); } /** * @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)) 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 { http::save_user_creds(config::sunshine.credentials_file, newUsername, newPassword); http::reload_user_creds(config::sunshine.credentials_file); 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 (!authenticate(response, request)) { return; } print_req(request); nlohmann::json output_tree; try { auto args = request->parse_query_string(); auto it = args.find("passphrase"); if (it == args.end()) throw std::runtime_error("Passphrase not provided!"); if (it->second.size() < 4) throw std::runtime_error("Passphrase too short!"); std::string passphrase = it->second; std::string deviceName; it = args.find("deviceName"); if (it != args.end()) deviceName = it->second; 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 (!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 pin = input_tree.value("pin", ""); std::string name = input_tree.value("name", ""); output_tree["status"] = nvhttp::pin(pin, name); 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 (!authenticate(response, request)) { return; } print_req(request); nlohmann::json output_tree; output_tree["status"] = display_device::reset_persistence(); send_response(response, output_tree); } /** * @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 (!authenticate(response, request)) { return; } print_req(request); // 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; #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 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 (!authenticate(response, request)) { return; } print_req(request); nlohmann::json output_tree; auto args = request->parse_query_string(); if (args.find("uuid") == args.end()) { bad_request(response, request, "Missing a required launch parameter"); return; } std::string uuid = nvhttp::get_arg(args, "uuid"); 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; auto launch_session = nvhttp::make_launch_session(true, false, args, &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"); } /** * @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 (!authenticate(response, request)) { return; } print_req(request); std::stringstream ss; ss << request->content.rdbuf(); nlohmann::json output_tree; try { 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); } catch (std::exception &e) { BOOST_LOG(warning) << "Disconnect: "sv << e.what(); bad_request(response, request, e.what()); } send_response(response, output_tree); } /** * @brief Login the user. * @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} * { * "username": "", * "password": "" * } * @endcode */ void login(resp_https_t response, req_https_t request) { if (!checkIPOrigin(response, request)) { return; } auto fg = util::fail_guard([&]{ response->write(SimpleWeb::StatusCode::client_error_unauthorized); }); std::stringstream ss; ss << request->content.rdbuf(); try { nlohmann::json input_tree = nlohmann::json::parse(ss.str()); std::string username = input_tree.value("username", ""); std::string password = input_tree.value("password", ""); std::string hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); if (!boost::iequals(username, config::sunshine.username) || hash != config::sunshine.password) return; std::string sessionCookieRaw = crypto::rand_alphabet(64); sessionCookie = util::hex(crypto::hash(sessionCookieRaw + config::sunshine.salt)).to_string(); cookie_creation_time = std::chrono::steady_clock::now(); const SimpleWeb::CaseInsensitiveMultimap headers { { "Set-Cookie", "auth=" + sessionCookieRaw + "; Secure; Max-Age=2592000; Path=/" } }; response->write(headers); fg.disable(); } catch (std::exception &e) { BOOST_LOG(warning) << "Web UI Login failed: ["sv << net::addr_to_normalized_string(request->remote_endpoint().address()) << "]: "sv << e.what(); response->write(SimpleWeb::StatusCode::server_error_internal_server_error); fg.disable(); return; } } /** * @brief Start the HTTPS server. */ void start() { 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); }; server.default_resource["GET"] = not_found; server.resource["^/$"]["GET"] = getIndexPage; server.resource["^/pin/?$"]["GET"] = getPinPage; server.resource["^/apps/?$"]["GET"] = getAppsPage; server.resource["^/config/?$"]["GET"] = getConfigPage; server.resource["^/password/?$"]["GET"] = getPasswordPage; server.resource["^/welcome/?$"]["GET"] = getWelcomePage; server.resource["^/login/?$"]["GET"] = getLoginPage; server.resource["^/troubleshooting/?$"]["GET"] = getTroubleshootingPage; server.resource["^/api/login"]["POST"] = login; server.resource["^/api/pin$"]["POST"] = savePin; server.resource["^/api/otp$"]["GET"] = getOTP; server.resource["^/api/apps$"]["GET"] = getApps; server.resource["^/api/apps$"]["POST"] = saveApp; server.resource["^/api/apps/reorder$"]["POST"] = reorderApps; server.resource["^/api/apps/delete$"]["POST"] = deleteApp; server.resource["^/api/apps/launch$"]["POST"] = launchApp; server.resource["^/api/apps/close$"]["POST"] = closeApp; server.resource["^/api/logs$"]["GET"] = getLogs; server.resource["^/api/config$"]["GET"] = getConfig; server.resource["^/api/config$"]["POST"] = saveConfig; server.resource["^/api/configLocale$"]["GET"] = getLocale; server.resource["^/api/restart$"]["POST"] = restart; server.resource["^/api/quit$"]["POST"] = quit; server.resource["^/api/reset-display-device-persistence$"]["POST"] = resetDisplayDevicePersistence; server.resource["^/api/password$"]["POST"] = savePassword; server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll; server.resource["^/api/clients/list$"]["GET"] = getClients; server.resource["^/api/clients/update$"]["POST"] = updateClient; server.resource["^/api/clients/unpair$"]["POST"] = unpair; server.resource["^/api/clients/disconnect$"]["POST"] = disconnect; server.resource["^/api/covers/upload$"]["POST"] = uploadCover; server.resource["^/images/apollo.ico$"]["GET"] = getFaviconImage; server.resource["^/images/logo-apollo-45.png$"]["GET"] = getApolloLogoImage; server.resource["^/assets\\/.+$"]["GET"] = getNodeModules; server.config.reuse_address = true; server.config.address = net::af_to_any_address_string(address_family); server.config.port = port_https; auto accept_and_run = [&](auto *server) { try { server->start([port_https](unsigned short port) { BOOST_LOG(info) << "Configuration UI available at [https://localhost:"sv << port << "]"; }); } 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; } }; std::thread tcp { accept_and_run, &server }; // Wait for any event shutdown_event->view(); server.stop(); tcp.join(); } } // namespace confighttp