Skip to content

Commit 76b3a85

Browse files
feat(api): add application image endpoint (#4627)
Co-authored-by: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
1 parent 2141917 commit 76b3a85

5 files changed

Lines changed: 426 additions & 24 deletions

File tree

docs/api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ basic authentication with the admin username and password.
3939
## POST /api/config
4040
@copydoc confighttp::saveConfig()
4141

42+
## GET /api/covers/{index}
43+
@copydoc confighttp::getCover()
44+
4245
## POST /api/covers/upload
4346
@copydoc confighttp::uploadCover()
4447

src/confighttp.cpp

Lines changed: 97 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -191,13 +191,14 @@ namespace confighttp {
191191
* @brief Send a 404 Not Found response.
192192
* @param response The HTTP response object.
193193
* @param request The HTTP request object.
194+
* @param error_message The error message to include in the response.
194195
*/
195-
void not_found(resp_https_t response, [[maybe_unused]] req_https_t request) {
196+
void not_found(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message = "Not Found") {
196197
constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_not_found;
197198

198199
nlohmann::json tree;
199200
tree["status_code"] = code;
200-
tree["error"] = "Not Found";
201+
tree["error"] = error_message;
201202

202203
SimpleWeb::CaseInsensitiveMultimap headers;
203204
headers.emplace("Content-Type", "application/json");
@@ -262,6 +263,28 @@ namespace confighttp {
262263
return true;
263264
}
264265

266+
/**
267+
* @brief Validates the application index and sends error response if invalid.
268+
* @param response The HTTP response object.
269+
* @param request The HTTP request object.
270+
* @param index The application index/id.
271+
*/
272+
bool check_app_index(resp_https_t response, req_https_t request, int index) {
273+
std::string file = file_handler::read_file(config::stream.file_apps.c_str());
274+
nlohmann::json file_tree = nlohmann::json::parse(file);
275+
if (const auto &apps = file_tree["apps"]; index < 0 || index >= static_cast<int>(apps.size())) {
276+
std::string error;
277+
if (const int max_index = static_cast<int>(apps.size()) - 1; max_index < 0) {
278+
error = "No applications found";
279+
} else {
280+
error = std::format("'index' {} out of range, max index is {}", index, max_index);
281+
}
282+
bad_request(std::move(response), std::move(request), error);
283+
return false;
284+
}
285+
return true;
286+
}
287+
265288
/**
266289
* @brief Get the index page.
267290
* @param response The HTTP response object.
@@ -711,25 +734,19 @@ namespace confighttp {
711734
try {
712735
nlohmann::json output_tree;
713736
nlohmann::json new_apps = nlohmann::json::array();
714-
std::string file = file_handler::read_file(config::stream.file_apps.c_str());
715-
nlohmann::json file_tree = nlohmann::json::parse(file);
716-
auto &apps_node = file_tree["apps"];
717737
const int index = std::stoi(request->path_match[1]);
718738

719-
if (index < 0 || index >= static_cast<int>(apps_node.size())) {
720-
std::string error;
721-
if (const int max_index = static_cast<int>(apps_node.size()) - 1; max_index < 0) {
722-
error = "No applications to delete";
723-
} else {
724-
error = std::format("'index' {} out of range, max index is {}", index, max_index);
725-
}
726-
bad_request(response, request, error);
739+
if (!check_app_index(response, request, index)) {
727740
return;
728741
}
729742

730-
for (size_t i = 0; i < apps_node.size(); ++i) {
743+
std::string file = file_handler::read_file(config::stream.file_apps.c_str());
744+
nlohmann::json file_tree = nlohmann::json::parse(file);
745+
auto &apps = file_tree["apps"];
746+
747+
for (size_t i = 0; i < apps.size(); ++i) {
731748
if (i != index) {
732-
new_apps.push_back(apps_node[i]);
749+
new_apps.push_back(apps[i]);
733750
}
734751
}
735752
file_tree["apps"] = new_apps;
@@ -928,6 +945,67 @@ namespace confighttp {
928945
}
929946
}
930947

948+
/**
949+
* @brief Get an application's image.
950+
* @param response The HTTP response object.
951+
* @param request The HTTP request object.
952+
*
953+
* @note{The index in the url path is the application index.}
954+
*
955+
* @api_examples{/api/covers/9999 | GET| null}
956+
*/
957+
void getCover(resp_https_t response, req_https_t request) {
958+
if (!check_content_type(response, request, "application/json")) {
959+
return;
960+
}
961+
if (!authenticate(response, request)) {
962+
return;
963+
}
964+
965+
print_req(request);
966+
967+
try {
968+
const int index = std::stoi(request->path_match[1]);
969+
if (!check_app_index(response, request, index)) {
970+
return;
971+
}
972+
973+
std::string file = file_handler::read_file(config::stream.file_apps.c_str());
974+
nlohmann::json file_tree = nlohmann::json::parse(file);
975+
auto &apps = file_tree["apps"];
976+
977+
auto &app = apps[index];
978+
979+
// Get the image path from the app configuration
980+
std::string app_image_path;
981+
if (app.contains("image-path") && !app["image-path"].is_null()) {
982+
app_image_path = app["image-path"];
983+
}
984+
985+
// Use validate_app_image_path to resolve and validate the path
986+
// This handles extension validation, PNG signature validation, and path resolution
987+
std::string validated_path = proc::validate_app_image_path(app_image_path);
988+
989+
// Open and stream the validated file
990+
std::ifstream in(validated_path, std::ios::binary);
991+
if (!in) {
992+
BOOST_LOG(warning) << "Unable to read cover image file: " << validated_path;
993+
bad_request(response, request, "Unable to read cover image file");
994+
return;
995+
}
996+
997+
SimpleWeb::CaseInsensitiveMultimap headers;
998+
headers.emplace("Content-Type", "image/png");
999+
headers.emplace("X-Frame-Options", "DENY");
1000+
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
1001+
1002+
response->write(SimpleWeb::StatusCode::success_ok, in, headers);
1003+
} catch (std::exception &e) {
1004+
BOOST_LOG(warning) << "GetCover: "sv << e.what();
1005+
bad_request(response, request, e.what());
1006+
}
1007+
}
1008+
9311009
/**
9321010
* @brief Upload a cover image.
9331011
* @param response The HTTP response object.
@@ -1324,7 +1402,9 @@ namespace confighttp {
13241402
server.default_resource["PUT"] = [](resp_https_t response, req_https_t request) {
13251403
bad_request(response, request);
13261404
};
1327-
server.default_resource["GET"] = not_found;
1405+
server.default_resource["GET"] = [](resp_https_t response, req_https_t request) {
1406+
not_found(response, request);
1407+
};
13281408
server.resource["^/$"]["GET"] = getIndexPage;
13291409
server.resource["^/pin/?$"]["GET"] = getPinPage;
13301410
server.resource["^/apps/?$"]["GET"] = getAppsPage;
@@ -1351,6 +1431,7 @@ namespace confighttp {
13511431
server.resource["^/api/clients/unpair$"]["POST"] = unpair;
13521432
server.resource["^/api/apps/close$"]["POST"] = closeApp;
13531433
server.resource["^/api/covers/upload$"]["POST"] = uploadCover;
1434+
server.resource["^/api/covers/([0-9]+)$"]["GET"] = getCover;
13541435
server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage;
13551436
server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getSunshineLogoImage;
13561437
server.resource["^/assets\\/.+$"]["GET"] = getNodeModules;

src/process.cpp

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,6 @@
3939
#include <share.h>
4040
#endif
4141

42-
#define DEFAULT_APP_IMAGE_PATH SUNSHINE_ASSETS_DIR "/box.png"
43-
4442
namespace proc {
4543
using namespace std::literals;
4644
namespace pt = boost::property_tree;
@@ -466,6 +464,40 @@ namespace proc {
466464
return ss.str();
467465
}
468466

467+
/**
468+
* @brief Validates a path whether it is a valid PNG.
469+
* @param path The path to the PNG file.
470+
* @return true if the file has a valid PNG signature, false otherwise.
471+
*/
472+
bool check_valid_png(const std::filesystem::path &path) {
473+
// PNG signature as defined in PNG specification
474+
// http://www.libpng.org/pub/png/spec/1.2/PNG-Structure.html
475+
static constexpr std::array<unsigned char, 8> PNG_SIGNATURE = {
476+
0x89,
477+
0x50,
478+
0x4E,
479+
0x47,
480+
0x0D,
481+
0x0A,
482+
0x1A,
483+
0x0A
484+
};
485+
486+
std::ifstream file(path, std::ios::binary);
487+
if (!file) {
488+
return false;
489+
}
490+
491+
std::array<unsigned char, 8> header;
492+
file.read(reinterpret_cast<char *>(header.data()), 8);
493+
494+
if (file.gcount() != 8) {
495+
return false;
496+
}
497+
498+
return header == PNG_SIGNATURE;
499+
}
500+
469501
std::string validate_app_image_path(std::string app_image_path) {
470502
if (app_image_path.empty()) {
471503
return DEFAULT_APP_IMAGE_PATH;
@@ -475,28 +507,39 @@ namespace proc {
475507
auto image_extension = std::filesystem::path(app_image_path).extension().string();
476508
boost::to_lower(image_extension);
477509

478-
// return the default box image if extension is not "png"
510+
// return the default box image if the extension is not "png"
479511
if (image_extension != ".png") {
480512
return DEFAULT_APP_IMAGE_PATH;
481513
}
482514

483515
// check if image is in assets directory
484-
auto full_image_path = std::filesystem::path(SUNSHINE_ASSETS_DIR) / app_image_path;
485-
if (std::filesystem::exists(full_image_path)) {
516+
if (auto full_image_path = std::filesystem::path(SUNSHINE_ASSETS_DIR) / app_image_path; std::filesystem::exists(full_image_path)) {
517+
// Validate PNG signature
518+
if (!check_valid_png(full_image_path)) {
519+
BOOST_LOG(warning) << "Invalid PNG file at path ["sv << full_image_path << ']';
520+
return DEFAULT_APP_IMAGE_PATH;
521+
}
486522
return full_image_path.string();
487-
} else if (app_image_path == "./assets/steam.png") {
523+
}
524+
525+
if (app_image_path == "./assets/steam.png") {
488526
// handle old default steam image definition
489527
return SUNSHINE_ASSETS_DIR "/steam.png";
490528
}
491529

492530
// check if specified image exists
493-
std::error_code code;
494-
if (!std::filesystem::exists(app_image_path, code)) {
531+
if (std::error_code code; !std::filesystem::exists(app_image_path, code)) {
495532
// return default box image if image does not exist
496533
BOOST_LOG(warning) << "Couldn't find app image at path ["sv << app_image_path << ']';
497534
return DEFAULT_APP_IMAGE_PATH;
498535
}
499536

537+
// Validate PNG signature
538+
if (!check_valid_png(app_image_path)) {
539+
BOOST_LOG(warning) << "Invalid PNG file at path ["sv << app_image_path << ']';
540+
return DEFAULT_APP_IMAGE_PATH;
541+
}
542+
500543
// image is a png, and not in assets directory
501544
// return only "content-type" http header compatible image type
502545
return app_image_path;

src/process.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
#include "rtsp.h"
2222
#include "utility.h"
2323

24+
#define DEFAULT_APP_IMAGE_PATH SUNSHINE_ASSETS_DIR "/box.png"
25+
2426
namespace proc {
2527
using file_t = util::safe_ptr_v2<FILE, int, fclose>;
2628

@@ -120,6 +122,7 @@ namespace proc {
120122
*/
121123
std::tuple<std::string, std::string> calculate_app_id(const std::string &app_name, std::string app_image_path, int index);
122124

125+
bool check_valid_png(const std::filesystem::path &path);
123126
std::string validate_app_image_path(std::string app_image_path);
124127
void refresh(const std::string &file_name);
125128
std::optional<proc::proc_t> parse(const std::string &file_name);

0 commit comments

Comments
 (0)