From 76b3a8596f8e002e299a709fca062f4d5b0c507d Mon Sep 17 00:00:00 2001 From: Cilps the Pumpkin <125075544+Cilps@users.noreply.github.com> Date: Tue, 27 Jan 2026 22:34:03 +0100 Subject: [PATCH] feat(api): add application image endpoint (#4627) Co-authored-by: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> --- docs/api.md | 3 + src/confighttp.cpp | 113 ++++++++++++--- src/process.cpp | 59 ++++++-- src/process.h | 3 + tests/unit/test_process.cpp | 272 ++++++++++++++++++++++++++++++++++++ 5 files changed, 426 insertions(+), 24 deletions(-) create mode 100644 tests/unit/test_process.cpp diff --git a/docs/api.md b/docs/api.md index 0834b6e2..65748dbc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -39,6 +39,9 @@ basic authentication with the admin username and password. ## POST /api/config @copydoc confighttp::saveConfig() +## GET /api/covers/{index} +@copydoc confighttp::getCover() + ## POST /api/covers/upload @copydoc confighttp::uploadCover() diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 00312ccc..ada69168 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -191,13 +191,14 @@ namespace confighttp { * @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) { + void not_found(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message = "Not Found") { constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_not_found; nlohmann::json tree; tree["status_code"] = code; - tree["error"] = "Not Found"; + tree["error"] = error_message; SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json"); @@ -262,6 +263,28 @@ namespace confighttp { return true; } + /** + * @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 Get the index page. * @param response The HTTP response object. @@ -711,25 +734,19 @@ namespace confighttp { try { 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"]; const int index = std::stoi(request->path_match[1]); - if (index < 0 || index >= static_cast(apps_node.size())) { - std::string error; - if (const int max_index = static_cast(apps_node.size()) - 1; max_index < 0) { - error = "No applications to delete"; - } else { - error = std::format("'index' {} out of range, max index is {}", index, max_index); - } - bad_request(response, request, error); + if (!check_app_index(response, request, index)) { return; } - for (size_t i = 0; i < apps_node.size(); ++i) { + 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"]; + + for (size_t i = 0; i < apps.size(); ++i) { if (i != index) { - new_apps.push_back(apps_node[i]); + new_apps.push_back(apps[i]); } } file_tree["apps"] = new_apps; @@ -928,6 +945,67 @@ namespace confighttp { } } + /** + * @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 (!check_content_type(response, request, "application/json")) { + return; + } + 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 + // This handles extension validation, PNG signature validation, and path resolution + std::string validated_path = proc::validate_app_image_path(app_image_path); + + // Open and stream the validated file + std::ifstream in(validated_path, std::ios::binary); + if (!in) { + 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, in, 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. @@ -1324,7 +1402,9 @@ namespace confighttp { server.default_resource["PUT"] = [](resp_https_t response, req_https_t request) { bad_request(response, request); }; - server.default_resource["GET"] = not_found; + server.default_resource["GET"] = [](resp_https_t response, req_https_t request) { + not_found(response, request); + }; server.resource["^/$"]["GET"] = getIndexPage; server.resource["^/pin/?$"]["GET"] = getPinPage; server.resource["^/apps/?$"]["GET"] = getAppsPage; @@ -1351,6 +1431,7 @@ namespace confighttp { server.resource["^/api/clients/unpair$"]["POST"] = unpair; server.resource["^/api/apps/close$"]["POST"] = closeApp; server.resource["^/api/covers/upload$"]["POST"] = uploadCover; + server.resource["^/api/covers/([0-9]+)$"]["GET"] = getCover; server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getSunshineLogoImage; server.resource["^/assets\\/.+$"]["GET"] = getNodeModules; diff --git a/src/process.cpp b/src/process.cpp index 84c1a6cf..95c7bdae 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -39,8 +39,6 @@ #include #endif -#define DEFAULT_APP_IMAGE_PATH SUNSHINE_ASSETS_DIR "/box.png" - namespace proc { using namespace std::literals; namespace pt = boost::property_tree; @@ -466,6 +464,40 @@ namespace proc { return ss.str(); } + /** + * @brief Validates a path whether it is a valid PNG. + * @param path The path to the PNG file. + * @return true if the file has a valid PNG signature, false otherwise. + */ + bool check_valid_png(const std::filesystem::path &path) { + // PNG signature as defined in PNG specification + // http://www.libpng.org/pub/png/spec/1.2/PNG-Structure.html + static constexpr std::array PNG_SIGNATURE = { + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A + }; + + std::ifstream file(path, std::ios::binary); + if (!file) { + return false; + } + + std::array header; + file.read(reinterpret_cast(header.data()), 8); + + if (file.gcount() != 8) { + return false; + } + + return header == PNG_SIGNATURE; + } + std::string validate_app_image_path(std::string app_image_path) { if (app_image_path.empty()) { return DEFAULT_APP_IMAGE_PATH; @@ -475,28 +507,39 @@ namespace proc { auto image_extension = std::filesystem::path(app_image_path).extension().string(); boost::to_lower(image_extension); - // return the default box image if extension is not "png" + // return the default box image if the extension is not "png" if (image_extension != ".png") { return DEFAULT_APP_IMAGE_PATH; } // check if image is in assets directory - auto full_image_path = std::filesystem::path(SUNSHINE_ASSETS_DIR) / app_image_path; - if (std::filesystem::exists(full_image_path)) { + if (auto full_image_path = std::filesystem::path(SUNSHINE_ASSETS_DIR) / app_image_path; std::filesystem::exists(full_image_path)) { + // Validate PNG signature + if (!check_valid_png(full_image_path)) { + BOOST_LOG(warning) << "Invalid PNG file at path ["sv << full_image_path << ']'; + return DEFAULT_APP_IMAGE_PATH; + } return full_image_path.string(); - } else if (app_image_path == "./assets/steam.png") { + } + + if (app_image_path == "./assets/steam.png") { // handle old default steam image definition return SUNSHINE_ASSETS_DIR "/steam.png"; } // check if specified image exists - std::error_code code; - if (!std::filesystem::exists(app_image_path, code)) { + if (std::error_code code; !std::filesystem::exists(app_image_path, code)) { // return default box image if image does not exist BOOST_LOG(warning) << "Couldn't find app image at path ["sv << app_image_path << ']'; return DEFAULT_APP_IMAGE_PATH; } + // Validate PNG signature + if (!check_valid_png(app_image_path)) { + BOOST_LOG(warning) << "Invalid PNG file at path ["sv << app_image_path << ']'; + return DEFAULT_APP_IMAGE_PATH; + } + // image is a png, and not in assets directory // return only "content-type" http header compatible image type return app_image_path; diff --git a/src/process.h b/src/process.h index f5a81e90..0f2f5f51 100644 --- a/src/process.h +++ b/src/process.h @@ -21,6 +21,8 @@ #include "rtsp.h" #include "utility.h" +#define DEFAULT_APP_IMAGE_PATH SUNSHINE_ASSETS_DIR "/box.png" + namespace proc { using file_t = util::safe_ptr_v2; @@ -120,6 +122,7 @@ namespace proc { */ std::tuple calculate_app_id(const std::string &app_name, std::string app_image_path, int index); + bool check_valid_png(const std::filesystem::path &path); std::string validate_app_image_path(std::string app_image_path); void refresh(const std::string &file_name); std::optional parse(const std::string &file_name); diff --git a/tests/unit/test_process.cpp b/tests/unit/test_process.cpp new file mode 100644 index 00000000..437b051a --- /dev/null +++ b/tests/unit/test_process.cpp @@ -0,0 +1,272 @@ +/** + * @file tests/unit/test_process.cpp + * @brief Test src/process.* functions. + */ +// test imports +#include "../tests_common.h" + +// standard imports +#include +#include + +// local imports +#include + +namespace fs = std::filesystem; + +class ProcessPNGTest: public ::testing::Test { +protected: + void SetUp() override { + // Create test directory + test_dir = fs::temp_directory_path() / "sunshine_process_png_test"; + fs::create_directories(test_dir); + } + + void TearDown() override { + // Clean up test directory + if (fs::exists(test_dir)) { + fs::remove_all(test_dir); + } + } + + // Helper function to create a file with specific content + void createTestFile(const fs::path &path, const std::vector &content) const { + std::ofstream file(path, std::ios::binary); + file.write(reinterpret_cast(content.data()), content.size()); + file.close(); + } + + fs::path test_dir; +}; + +// Tests for check_valid_png function +TEST_F(ProcessPNGTest, CheckValidPNG_ValidSignature) { + // Valid PNG signature + const std::vector valid_png_data = { + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, // PNG signature + // Add some dummy data to make it more realistic + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52 + }; + + const fs::path test_file = test_dir / "valid.png"; + createTestFile(test_file, valid_png_data); + + EXPECT_TRUE(proc::check_valid_png(test_file)); +} + +TEST_F(ProcessPNGTest, CheckValidPNG_WrongSignature) { + // Invalid PNG signature (wrong magic bytes) + const std::vector invalid_png_data = { + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00 + }; + + const fs::path test_file = test_dir / "invalid.png"; + createTestFile(test_file, invalid_png_data); + + EXPECT_FALSE(proc::check_valid_png(test_file)); +} + +TEST_F(ProcessPNGTest, CheckValidPNG_TooShort) { + // File too short (less than 8 bytes) + const std::vector short_data = { + 0x89, + 0x50, + 0x4E, + 0x47 + }; + + const fs::path test_file = test_dir / "short.png"; + createTestFile(test_file, short_data); + + EXPECT_FALSE(proc::check_valid_png(test_file)); +} + +TEST_F(ProcessPNGTest, CheckValidPNG_EmptyFile) { + // Empty file + const std::vector empty_data = {}; + + const fs::path test_file = test_dir / "empty.png"; + createTestFile(test_file, empty_data); + + EXPECT_FALSE(proc::check_valid_png(test_file)); +} + +TEST_F(ProcessPNGTest, CheckValidPNG_NonExistentFile) { + // File doesn't exist + const fs::path test_file = test_dir / "nonexistent.png"; + + EXPECT_FALSE(proc::check_valid_png(test_file)); +} + +TEST_F(ProcessPNGTest, CheckValidPNG_RealFile) { + // Test with the actual sunshine.png from the project root + + // Only run this test if the file exists + if (const fs::path sunshine_png = fs::path(SUNSHINE_SOURCE_DIR) / "sunshine.png"; fs::exists(sunshine_png)) { + EXPECT_TRUE(proc::check_valid_png(sunshine_png)); + } else { + GTEST_SKIP() << "sunshine.png not found in project root"; + } +} + +TEST_F(ProcessPNGTest, CheckValidPNG_JPEGFile) { + // JPEG signature (not PNG) + const std::vector jpeg_data = { + 0xFF, + 0xD8, + 0xFF, + 0xE0, + 0x00, + 0x10, + 0x4A, + 0x46 + }; + + const fs::path test_file = test_dir / "fake.png"; + createTestFile(test_file, jpeg_data); + + EXPECT_FALSE(proc::check_valid_png(test_file)); +} + +TEST_F(ProcessPNGTest, CheckValidPNG_PartialSignature) { + // Partial PNG signature (first 4 bytes correct, rest wrong) + const std::vector partial_png_data = { + 0x89, + 0x50, + 0x4E, + 0x47, + 0x00, + 0x00, + 0x00, + 0x00 + }; + + const fs::path test_file = test_dir / "partial.png"; + createTestFile(test_file, partial_png_data); + + EXPECT_FALSE(proc::check_valid_png(test_file)); +} + +// Tests for validate_app_image_path function +TEST_F(ProcessPNGTest, ValidateAppImagePath_EmptyPath) { + // Empty path should return default + const std::string result = proc::validate_app_image_path(""); + EXPECT_EQ(result, DEFAULT_APP_IMAGE_PATH); +} + +TEST_F(ProcessPNGTest, ValidateAppImagePath_NonPNGExtension) { + // Non-PNG extension should return default + const std::string result = proc::validate_app_image_path("image.jpg"); + EXPECT_EQ(result, DEFAULT_APP_IMAGE_PATH); +} + +TEST_F(ProcessPNGTest, ValidateAppImagePath_CaseInsensitiveExtension) { + // Test that .PNG (uppercase) is recognized + // Create a valid PNG file + const std::vector valid_png_data = { + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52 + }; + + const fs::path test_file = test_dir / "test.PNG"; + createTestFile(test_file, valid_png_data); + + const std::string result = proc::validate_app_image_path(test_file.string()); + // Should accept uppercase .PNG extension + EXPECT_NE(result, DEFAULT_APP_IMAGE_PATH); +} + +TEST_F(ProcessPNGTest, ValidateAppImagePath_NonExistentFile) { + // Non-existent PNG file should return default + const std::string result = proc::validate_app_image_path("/nonexistent/path/image.png"); + EXPECT_EQ(result, DEFAULT_APP_IMAGE_PATH); +} + +TEST_F(ProcessPNGTest, ValidateAppImagePath_InvalidPNGSignature) { + // File with .png extension but invalid signature should return default + const std::vector invalid_data = { + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00 + }; + + const fs::path test_file = test_dir / "invalid.png"; + createTestFile(test_file, invalid_data); + + const std::string result = proc::validate_app_image_path(test_file.string()); + EXPECT_EQ(result, DEFAULT_APP_IMAGE_PATH); +} + +TEST_F(ProcessPNGTest, ValidateAppImagePath_ValidPNG) { + // Valid PNG file should return the path + const std::vector valid_png_data = { + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52 + }; + + const fs::path test_file = test_dir / "valid.png"; + createTestFile(test_file, valid_png_data); + + const std::string result = proc::validate_app_image_path(test_file.string()); + EXPECT_EQ(result, test_file.string()); +} + +TEST_F(ProcessPNGTest, ValidateAppImagePath_OldSteamDefault) { + // Test the special case for old steam image path + const std::string result = proc::validate_app_image_path("./assets/steam.png"); + EXPECT_EQ(result, SUNSHINE_ASSETS_DIR "/steam.png"); +}