diff --git a/package.json b/package.json index 1043790409d..eba5add9ee6 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,15 @@ "scripts": { "build": "vite build --debug", "build-clean": "vite build --debug --emptyOutDir", - "dev": "vite build --watch", + "dev": "vite", "serve": "serve ./tests/fixtures/http --no-port-switching" }, "dependencies": { "@lizardbyte/shared-web": "2025.326.11214", + "@popperjs/core": "2.11.8", "vue": "3.5.13", - "vue-i18n": "11.1.3" + "vue-i18n": "11.1.3", + "vue-router": "4.5.1" }, "devDependencies": { "@codecov/vite-plugin": "1.9.0", diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 4ba09b88a5a..7bb8732b4ea 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -142,10 +142,19 @@ namespace confighttp { // If credentials are shown, redirect the user to a /welcome page if (config::sunshine.username.empty()) { + if (request->path == "/welcome") { + return true; + } send_redirect(response, request, "/welcome"); return false; } + // Redirect after /welcome to / + if (request->path == "/welcome") { + send_redirect(response, request, "/"); + return false; + } + auto fg = util::fail_guard([&]() { send_unauthorized(response, request); }); @@ -225,191 +234,76 @@ namespace confighttp { } print_req(request); - - std::string content = file_handler::read_file(WEB_DIR "index.html"); - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("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)) { - return; - } - - print_req(request); - - std::string content = file_handler::read_file(WEB_DIR "pin.html"); - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("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)) { - return; + if (request->path.starts_with("/api")) { + return not_found(response, request); } - - print_req(request); - - std::string content = file_handler::read_file(WEB_DIR "apps.html"); + std::string content = file_handler::read_file(WEB_DIR "index.html"); SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "text/html; charset=utf-8"); - headers.emplace("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. + * @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. */ - void getClientsPage(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 "clients.html"); - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "text/html; charset=utf-8"); - response->write(content, headers); + bool isChildPath(fs::path const &base, fs::path const &query) { + auto relPath = fs::relative(base, query); + return *(relPath.begin()) != fs::path(".."); } /** - * @brief Get the configuration page. + * @brief Get an asset from the node_modules directory. * @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)) { - return; - } - + void getNodeModules(resp_https_t response, req_https_t request) { print_req(request); + fs::path webDirPath(WEB_DIR); + fs::path nodeModulesPath(webDirPath / "assets"); - std::string content = file_handler::read_file(WEB_DIR "config.html"); - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "text/html; charset=utf-8"); - response->write(content, headers); - } + // .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()); - /** - * @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)) { + // 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; } - - print_req(request); - - std::string content = file_handler::read_file(WEB_DIR "password.html"); - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("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, "/"); + if (!fs::exists(filePath)) { + not_found(response, request); return; } - std::string content = file_handler::read_file(WEB_DIR "welcome.html"); - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("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)) { + 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)); + // check if the extension is in the map at the x position + if (mimeType == mime_types.end()) { + bad_request(response, request); return; } - print_req(request); - - std::string content = file_handler::read_file(WEB_DIR "troubleshooting.html"); - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("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. - * @todo combine function with getSunshineLogoImage and possibly getNodeModules - * @todo use mime_types map - */ - void getFaviconImage(resp_https_t response, req_https_t request) { - print_req(request); - - std::ifstream in(WEB_DIR "images/sunshine.ico", std::ios::binary); - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "image/x-icon"); - response->write(SimpleWeb::StatusCode::success_ok, in, headers); - } - - /** - * @brief Get the Sunshine 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 getSunshineLogoImage(resp_https_t response, req_https_t request) { - print_req(request); - - std::ifstream in(WEB_DIR "images/logo-sunshine-45.png", std::ios::binary); + // if it is, set the content type to the mime type SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "image/png"); + headers.emplace("Content-Type", mimeType->second); + std::ifstream in(filePath.string(), std::ios::binary); 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) { + void getImages(resp_https_t response, req_https_t request) { print_req(request); fs::path webDirPath(WEB_DIR); - fs::path nodeModulesPath(webDirPath / "assets"); + fs::path nodeModulesPath(webDirPath / "images"); // .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()); @@ -1092,15 +986,8 @@ 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"] = getIndexPage; server.resource["^/$"]["GET"] = getIndexPage; - server.resource["^/pin/?$"]["GET"] = getPinPage; - server.resource["^/apps/?$"]["GET"] = getAppsPage; - server.resource["^/clients/?$"]["GET"] = getClientsPage; - server.resource["^/config/?$"]["GET"] = getConfigPage; - server.resource["^/password/?$"]["GET"] = getPasswordPage; - server.resource["^/welcome/?$"]["GET"] = getWelcomePage; - server.resource["^/troubleshooting/?$"]["GET"] = getTroubleshootingPage; server.resource["^/api/pin$"]["POST"] = savePin; server.resource["^/api/apps$"]["GET"] = getApps; server.resource["^/api/logs$"]["GET"] = getLogs; @@ -1117,9 +1004,8 @@ 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["^/images/sunshine.ico$"]["GET"] = getFaviconImage; - server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getSunshineLogoImage; server.resource["^/assets\\/.+$"]["GET"] = getNodeModules; + server.resource["^/images\\/.+$"]["GET"] = getImages; server.config.reuse_address = true; server.config.address = net::af_to_any_address_string(address_family); server.config.port = port_https; diff --git a/src_assets/common/assets/web/Navbar.vue b/src_assets/common/assets/web/Navbar.vue deleted file mode 100644 index 166398b9fa7..00000000000 --- a/src_assets/common/assets/web/Navbar.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - - - diff --git a/src_assets/common/assets/web/index.html b/src_assets/common/assets/web/index.html index 4a8660af376..2876e01a29c 100644 --- a/src_assets/common/assets/web/index.html +++ b/src_assets/common/assets/web/index.html @@ -1,157 +1,15 @@ - - - <%- header %> - - - - -
-

{{ $t('index.welcome') }}

-

{{ $t('index.description') }}

-
-
- -

-
-
- - View Logs -
- -
-
-

Version {{version.version}}

-
-
- {{ $t('index.loading_latest') }} -
-
- {{ $t('index.version_dirty') }} 🌇 -
-
- {{ $t('index.installed_version_not_stable') }} -
-
-
- {{ $t('index.version_latest') }} -
-
-
-
-
-
-

{{ $t('index.new_pre_release') }}

-
- {{ $t('index.download') }} -
-
{{preReleaseVersion.release.name}}
-
{{preReleaseVersion.release.body}}
-
-
-
-
-
-
-

{{ $t('index.new_stable') }}

-
- {{ $t('index.download') }} -
-

{{githubVersion.release.name}}

-
{{githubVersion.release.body}}
-
-
-
-
- -
- -
-
- - - + + + + + Sunshine + + + + + +
+ + diff --git a/src_assets/common/assets/web/public/assets/css/sunshine.css b/src_assets/common/assets/web/public/assets/css/sunshine.css deleted file mode 100644 index 843600feebd..00000000000 --- a/src_assets/common/assets/web/public/assets/css/sunshine.css +++ /dev/null @@ -1,16 +0,0 @@ -/* Hide pages while localization is loading */ -[v-cloak] { - display: none; -} - -[data-bs-theme=dark] .element { - color: var(--bs-primary-text-emphasis); - background-color: var(--bs-primary-bg-subtle); -} - -@media (prefers-color-scheme: dark) { - .element { - color: var(--bs-primary-text-emphasis); - background-color: var(--bs-primary-bg-subtle); - } -} diff --git a/src_assets/common/assets/web/src/App.vue b/src_assets/common/assets/web/src/App.vue new file mode 100644 index 00000000000..0a463ef9346 --- /dev/null +++ b/src_assets/common/assets/web/src/App.vue @@ -0,0 +1,9 @@ + + diff --git a/src_assets/common/assets/web/src/assets/css/sunshine.css b/src_assets/common/assets/web/src/assets/css/sunshine.css new file mode 100644 index 00000000000..1cebeda4bf2 --- /dev/null +++ b/src_assets/common/assets/web/src/assets/css/sunshine.css @@ -0,0 +1,107 @@ +/* Hide pages while localization is loading */ +[v-cloak] { + display: none; +} + +[data-bs-theme=dark] .element { + color: var(--bs-primary-text-emphasis); + background-color: var(--bs-primary-bg-subtle); +} + +@media (prefers-color-scheme: dark) { + .element { + color: var(--bs-primary-text-emphasis); + background-color: var(--bs-primary-bg-subtle); + } +} + +.navbar-background { + background-color: #ffc400 +} + +.header .nav-link { + color: rgba(0, 0, 0, .65) !important; +} + +.header .nav-link.active { + color: rgb(0, 0, 0) !important; +} + +.header .nav-link:hover { + color: rgb(0, 0, 0) !important; +} + +.header .navbar-toggler { + color: rgba(var(--bs-dark-rgb), .65) !important; + border: var(--bs-border-width) solid rgba(var(--bs-dark-rgb), 0.15) !important; +} + +.header .navbar-toggler-icon { + --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") !important; +} + +.form-control::placeholder { + opacity: 0.5; +} + +.precmd-head { + width: 200px; +} + +.monospace { + font-family: monospace; +} + +.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; +} + +.config-page { + padding: 1em; + border: 1px solid #dee2e6; + border-top: none; +} + +td { + padding: 0 0.5em; +} + +.env-table td { + padding: 0.25em; + border-bottom: rgba(0, 0, 0, 0.25) 1px solid; + vertical-align: top; +} \ No newline at end of file diff --git a/src_assets/common/assets/web/Checkbox.vue b/src_assets/common/assets/web/src/components/Checkbox.vue similarity index 100% rename from src_assets/common/assets/web/Checkbox.vue rename to src_assets/common/assets/web/src/components/Checkbox.vue diff --git a/src_assets/common/assets/web/src/components/Navbar.vue b/src_assets/common/assets/web/src/components/Navbar.vue new file mode 100644 index 00000000000..0b4fd7e69a7 --- /dev/null +++ b/src_assets/common/assets/web/src/components/Navbar.vue @@ -0,0 +1,53 @@ + + + diff --git a/src_assets/common/assets/web/PlatformLayout.vue b/src_assets/common/assets/web/src/components/PlatformLayout.vue similarity index 100% rename from src_assets/common/assets/web/PlatformLayout.vue rename to src_assets/common/assets/web/src/components/PlatformLayout.vue diff --git a/src_assets/common/assets/web/ResourceCard.vue b/src_assets/common/assets/web/src/components/ResourceCard.vue similarity index 100% rename from src_assets/common/assets/web/ResourceCard.vue rename to src_assets/common/assets/web/src/components/ResourceCard.vue diff --git a/src_assets/common/assets/web/ThemeToggle.vue b/src_assets/common/assets/web/src/components/ThemeToggle.vue similarity index 95% rename from src_assets/common/assets/web/ThemeToggle.vue rename to src_assets/common/assets/web/src/components/ThemeToggle.vue index 7c34916adc9..13d71b5fe44 100644 --- a/src_assets/common/assets/web/ThemeToggle.vue +++ b/src_assets/common/assets/web/src/components/ThemeToggle.vue @@ -1,5 +1,5 @@ + } + \ No newline at end of file diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/src/views/ConfigView.vue similarity index 94% rename from src_assets/common/assets/web/config.html rename to src_assets/common/assets/web/src/views/ConfigView.vue index e6a85b874bb..1f7602a149b 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/src/views/ConfigView.vue @@ -1,23 +1,4 @@ - - - - - <%- header %> - - - - - + + + \ No newline at end of file diff --git a/src_assets/common/assets/web/src/views/IndexView.vue b/src_assets/common/assets/web/src/views/IndexView.vue new file mode 100644 index 00000000000..45042b15018 --- /dev/null +++ b/src_assets/common/assets/web/src/views/IndexView.vue @@ -0,0 +1,166 @@ + + + \ No newline at end of file diff --git a/src_assets/common/assets/web/password.html b/src_assets/common/assets/web/src/views/PasswordView.vue similarity index 86% rename from src_assets/common/assets/web/password.html rename to src_assets/common/assets/web/src/views/PasswordView.vue index 854ee596271..5c8b7338b3b 100644 --- a/src_assets/common/assets/web/password.html +++ b/src_assets/common/assets/web/src/views/PasswordView.vue @@ -1,23 +1,4 @@ - - - - - <%- header %> - - - - - +