Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ include_directories(third-party/miniupnp)

find_package(Threads REQUIRED)
find_package(OpenSSL REQUIRED)
find_package(CURL REQUIRED)
if(NOT APPLE)
set(Boost_USE_STATIC_LIBS ON)
endif()
Expand Down Expand Up @@ -436,6 +437,7 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES
${FFMPEG_LIBRARIES}
${Boost_LIBRARIES}
${OPENSSL_LIBRARIES}
${CURL_LIBRARIES}
${PLATFORM_LIBRARIES})

if(NOT WIN32)
Expand Down
173 changes: 166 additions & 7 deletions src_assets/common/assets/web/apps.html
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,47 @@ <h1>Applications</h1>
<!-- Image path -->
<div class="mb-3">
<label for="appImagePath" class="form-label">Image</label>
<input
type="text"
class="form-control monospace"
id="appImagePath"
aria-describedby="appImagePathHelp"
v-model="editForm['image-path']"
/>
<div class="input-group dropup">
<input
type="text"
class="form-control monospace"
id="appImagePath"
aria-describedby="appImagePathHelp"
v-model="editForm['image-path']"
/>
<button class="btn btn-secondary dropdown-toggle" type="button" id="findCoverToggle" data-bs-toggle="dropdown"
data-bs-auto-close="outside" aria-expanded="false" v-dropdown-show="showCoverFinder"
ref="coverFinderDropdown">
Find Cover
</button>
<div class="dropdown-menu dropdown-menu-end w-50 cover-finder overflow-hidden"
aria-labelledby="findCoverToggle">
<div class="modal-header">
<h4 class="modal-title">Covers Found</h4>
<button type="button" class="btn-close" aria-label="Close" @click="closeCoverFinder"></button>
</div>
<div class="cover-results px-3 pt-3" :class="{ busy: coverFinderBusy }">
<div class="row">
<div v-if="coverSearching" class="col-12 col-sm-6 col-lg-4 mb-3">
<div class="cover-container">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<div v-for="(cover,i) in coverCandidates" :key="i" class="col-12 col-sm-6 col-lg-4 mb-3"
@click="useCover(cover)">
<div class="cover-container result">
<img class="rounded" :src="cover.url"/>
</div>
<label class="d-block text-nowrap text-center text-truncate">
{{cover.name}}
</label>
</div>
</div>
</div>
</div>
</div>
<div id="appImagePathHelp" class="form-text">
Application icon/picture/image path that will be sent to client. Image must be a PNG file.
If not set, Sunshine will send default box image.
Expand All @@ -196,6 +230,12 @@ <h1>Applications</h1>
</div>

<script>
Vue.directive('dropdown-show', {
bind: function (el, binding) {
el.addEventListener('show.bs.dropdown', binding.value);
}
});

new Vue({
el: "#app",
data() {
Expand All @@ -204,6 +244,9 @@ <h1>Applications</h1>
showEditForm: false,
editForm: null,
detachedCmd: "",
coverSearching: false,
coverFinderBusy: false,
coverCandidates: [],
};
},
created() {
Expand Down Expand Up @@ -253,6 +296,80 @@ <h1>Applications</h1>
undo: "",
});
},
showCoverFinder($event) {
this.coverCandidates = [];
this.coverSearching = true;

function getSearchKey(q) {
const key = q.toLowerCase().replaceAll(/[^\da-z]+/g, '.');
if (key.endsWith('.')) {
return key.substring(0, key.length - 1);
}
return key;
}

function getSearchBucket(key) {
let index = key.indexOf('.');
if (index < 0) {
index = key.length;
}
const s = key.substring(0, Math.min(2, index));
if (!s || s === '.') return '@';
return s;
}

function searchCovers(key) {
let bucket = getSearchBucket(key);
return fetch("https://mariotaku.org/gcdb/buckets/" + bucket + ".json").then(r => {
Comment thread
LizardByte-bot marked this conversation as resolved.
Outdated
if (!r.ok) throw new Error("Failed to search covers");
return r.json();
}).then(maps => {
let result = [];
for (let itemKey of Object.keys(maps)) {
if (itemKey.startsWith(key)) {
let item = maps[itemKey];
result.push({
name: item.name,
key: itemKey,
url: "https://images.igdb.com/igdb/image/upload/t_cover_big/" + item.hash + ".jpg",
saveUrl: "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/" + item.hash + ".png",
});
}
}
return result;
});
}

searchCovers(getSearchKey(this.editForm["name"].toString()))
.then(body => this.coverCandidates = body)
.finally(() => this.coverSearching = false);
},
closeCoverFinder() {
const ref = this.$refs.coverFinderDropdown;
if (!ref) {
return;
}
const dropdown = this.coverFinderDropdown = bootstrap.Dropdown.getInstance(ref);
if (!dropdown) {
return;
}
dropdown.hide();
},
useCover(cover) {
this.coverFinderBusy = true;
fetch("/api/covers/upload", {
method: "POST",
body: JSON.stringify({
key: cover.key,
url: cover.saveUrl,
})
}).then(r => {
if (!r.ok) throw new Error("Failed to download covers");
return r.json();
}).then(body => this.$set(this.editForm, "image-path", body.path))
.then(() => this.closeCoverFinder())
.finally(() => this.coverFinderBusy = false);
},
save() {
this.editForm["image-path"] = this.editForm["image-path"].toString().replace(/"/g, '');
fetch("/api/apps", {
Expand All @@ -274,4 +391,46 @@ <h1>Applications</h1>
.monospace {
font-family: monospace;
}

.cover-finder {
}

.cover-finder .cover-results {
max-height: 400px;
overflow-x: hidden;
overflow-y: auto;
}

.cover-finder .cover-results.busy * {
cursor: wait !important;
pointer-events: none;
}

.cover-container {
padding-top: 133.33%;
position: relative;
}

.cover-container.result {
cursor: pointer;
}

.spinner-border {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
margin: auto;
}

.cover-container img {
display: block;
position: absolute;
top: 0;
width: 100%;
height: 100%;
object-fit: cover;
}

</style>
61 changes: 60 additions & 1 deletion sunshine/confighttp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

#include <boost/asio/ssl/context.hpp>

#include <boost/filesystem.hpp>

#include <Simple-Web-Server/crypto.hpp>
#include <Simple-Web-Server/server_https.hpp>
#include <boost/asio/ssl/context_base.hpp>
Expand Down Expand Up @@ -165,9 +167,12 @@ void getAppsPage(resp_https_t response, req_https_t request) {

print_req(request);

SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Access-Control-Allow-Origin", "https://images.igdb.com/");

std::string header = read_file(WEB_DIR "header.html");
std::string content = read_file(WEB_DIR "apps.html");
response->write(header + content);
response->write(header + content, headers);
}

void getClientsPage(resp_https_t response, req_https_t request) {
Expand Down Expand Up @@ -412,6 +417,59 @@ void deleteApp(resp_https_t response, req_https_t request) {
proc::refresh(config::stream.file_apps);
}

void uploadCover(resp_https_t response, req_https_t request) {
if(!authenticate(response, request)) return;

std::stringstream ss;
std::stringstream configStream;
ss << request->content.rdbuf();
pt::ptree outputTree;
auto g = util::fail_guard([&]() {
std::ostringstream data;

SimpleWeb::StatusCode code = SimpleWeb::StatusCode::success_ok;
if (outputTree.get_child_optional("error").has_value()) {
code = SimpleWeb::StatusCode::client_error_bad_request;
}

pt::write_json(data, outputTree);
response->write(code, data.str());
});
pt::ptree inputTree;
try {
//TODO: Input Validation
pt::read_json(ss, inputTree);
}
catch(std::exception &e) {
BOOST_LOG(warning) << "UploadCover: "sv << e.what();
outputTree.put("status", "false");
outputTree.put("error", e.what());
return;
}

auto key = inputTree.get<std::string>("key");
auto url = inputTree.get("url", "");

const std::string coverdir = SUNSHINE_ASSETS_DIR "/covers/";
Comment thread
mariotaku marked this conversation as resolved.
Outdated
if(!boost::filesystem::exists(coverdir)) {
boost::filesystem::create_directory(coverdir);
}

std::basic_string path = coverdir + key + ".png";
if (!url.empty()) {
if (!http::download_file(url, path)) {
Comment thread
mariotaku marked this conversation as resolved.
Outdated
outputTree.put("error", "Failed to download cover");
return;
}
} else {
auto data = SimpleWeb::Crypto::Base64::decode(inputTree.get<std::string>("data"));

std::ofstream imgfile(path);
imgfile.write(data.data(), (int)data.size());
}
outputTree.put("path", path);
}

void getConfig(resp_https_t response, req_https_t request) {
if(!authenticate(response, request)) return;

Expand Down Expand Up @@ -617,6 +675,7 @@ void start() {
server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp;
server.resource["^/api/clients/unpair$"]["POST"] = unpairAll;
server.resource["^/api/apps/close"]["POST"] = closeApp;
server.resource["^/api/covers/upload$"]["POST"] = uploadCover;
server.resource["^/images/favicon.ico$"]["GET"] = getFaviconImage;
server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getSunshineLogoImage;
server.resource["^/third_party/bootstrap.min.css$"]["GET"] = getBootstrapCss;
Expand Down
17 changes: 17 additions & 0 deletions sunshine/httpcommon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <Simple-Web-Server/server_http.hpp>
#include <Simple-Web-Server/server_https.hpp>
#include <boost/asio/ssl/context_base.hpp>
#include <curl/curl.h>

#include "config.h"
#include "crypto.h"
Expand Down Expand Up @@ -180,4 +181,20 @@ int create_creds(const std::string &pkey, const std::string &cert) {

return 0;
}

bool download_file(const std::string &url, const std::string &file) {
CURL *curl = curl_easy_init();
if (!curl) {
return false;
}
FILE *fp = fopen(file.c_str(), "wb");
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, fwrite);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
bool result = curl_easy_perform(curl) == CURLE_OK;
curl_easy_cleanup(curl);
fclose(fp);
return result;
}

} // namespace http
1 change: 1 addition & 0 deletions sunshine/httpcommon.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ int save_user_creds(
bool run_our_mouth = false);

int reload_user_creds(const std::string &file);
bool download_file(const std::string &url, const std::string &file);
extern std::string unique_id;
extern net::net_e origin_pin_allowed;
extern net::net_e origin_web_ui_allowed;
Expand Down