Skip to content
Open
Show file tree
Hide file tree
Changes from all 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: 1 addition & 1 deletion src_assets/common/assets/web/Apps.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
<div class="app-poster-container">
<img
v-if="app['image-path']"
:src="'/api/covers/' + index"
:src="'./api/covers/' + index"
class="app-poster"
:alt="app.name"
@error="handleImageError"
Expand Down
4 changes: 2 additions & 2 deletions src_assets/common/assets/web/Navbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,13 @@ export default {
methods: {
logout() {
const cacheBuster = Date.now().toString()
const logoutPageUrl = new URL('/logout', globalThis.location.href)
const logoutPageUrl = new URL('logout', document.baseURI)
const request = new XMLHttpRequest()
const finish = () => {
globalThis.location.replace(logoutPageUrl.toString())
}

request.open('GET', '/', true, 'sunshine-logout', cacheBuster)
request.open('GET', './', true, 'sunshine-logout', cacheBuster)
request.setRequestHeader('Cache-Control', 'no-store')
request.onload = finish
request.onerror = finish
Expand Down
10 changes: 5 additions & 5 deletions src_assets/common/assets/web/Troubleshooting.vue
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,7 @@
},
created() {
this._logsCopyTimeout = null;
fetch("/api/config")
fetch("./api/config")
.then((r) => r.json())
.then((r) => {
this.platform = r.platform;
Expand Down Expand Up @@ -1003,7 +1003,7 @@
},
ddResetPersistence() {
this.ddResetPressed = true;
apiFetch("/api/reset-display-device-persistence", {
apiFetch("./api/reset-display-device-persistence", {
method: "POST",
headers: {
"Content-Type": "application/json"
Expand Down Expand Up @@ -1084,7 +1084,7 @@
});
},
refreshVirtualInputStatus() {
fetch("/api/virtual-input/status")
fetch("./api/virtual-input/status")
.then((r) => r.json())
.then((r) => {
const virtualhid = r.virtualhid || {};
Expand Down Expand Up @@ -1216,7 +1216,7 @@
refreshLicenseStatus() {
this.licenseBusy = true;
this.licenseError = '';
fetch("/api/virtual-input/license")
fetch("./api/virtual-input/license")
.then(async (response) => {
const status = await response.json();
if (!response.ok) {
Expand All @@ -1243,7 +1243,7 @@
body.license_key = this.licenseKey.trim();
}

apiFetch("/api/virtual-input/license", {
apiFetch("./api/virtual-input/license", {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand Down
19 changes: 18 additions & 1 deletion src_assets/common/assets/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,24 @@
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<script>
// Resolve the web UI's base path from the current URL, so the UI also works when a reverse proxy
// serves it under a sub-path (for example https://example.com/sunshine/). Assets, API requests and
// vue-router resolve against this <base>. Keep the route names in sync with the routes in router.js.
(function () {
var routes = ['apps', 'clients', 'config', 'featured', 'logout', 'password', 'pin', 'troubleshooting', 'welcome'];
var segments = location.pathname.split('/');
// Drop the final path segment; it is empty when the path ends with a slash.
segments.pop();
// A route visited with a trailing slash (for example /logout/) leaves its name as the last segment.
if (segments.length > 1 && routes.indexOf(segments[segments.length - 1]) !== -1) {
segments.pop();
Comment on lines +16 to +18

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not discard a proxy prefix just because it matches a route

For a proxy mounted at /apps/, both /apps/ and /apps/config produce base /; for a proxy mounted at /proxy/config/, both /proxy/config/ and /proxy/config/apps produce /proxy/. Assets and API calls therefore leave the configured mount and the UI still fails to load. I reproduced these outputs with this inline script. These are valid mount prefixes and known routes, not the documented unknown-path limitation.

The same pathname can represent either a direct route or a proxy mount, so a route-name heuristic cannot distinguish every case. Please provide an explicit base-prefix override (or another unambiguous source of the mount prefix) rather than stripping a matching segment unconditionally. Once that input is available, extend tests/web/base_path.test.js to cover configured /apps/ and /proxy/config/ mounts at their roots and at /config or /apps respectively, while retaining direct /apps/ and /logout/ coverage. Assert both the selected base and that an asset/API URL stays under it. A directly applicable one-line replacement would be misleading here because the required prefix input is not present yet.

Review completed by Codex | Model: unavailable | Thinking: unavailable

}
var base = document.createElement('base');
base.href = segments.join('/') + '/';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep the generated base on the document's origin

If the pathname starts with repeated slashes, this assignment can produce a network-path reference rather than a root-relative path. Relative built scripts, styles and API URLs then resolve against a different origin. I verified the inline script's output and URL resolution, not an end-to-end exploit or the deployed server/proxy's normalization. The previous fixed / base did not have this behavior.

Ensure the output has exactly one leading slash before assigning it. This focused guard preserves the ordinary / and /sunshine/config cases in an isolated script check:

Suggested change
base.href = segments.join('/') + '/';
base.href = ('/' + segments.join('/') + '/').replace(/^\/+/, '/');

Also add a regression in tests/web/base_path.test.js that exercises repeated leading slashes and asserts that resolving the returned base against a document URL preserves its origin. Run npx vitest run tests/web/base_path.test.js. The suggestion has not been tested in a real Sunshine/proxy deployment and does not address the separate prefix ambiguity.

Review completed by Codex | Model: unavailable | Thinking: unavailable

document.head.appendChild(base);
})();
</script>
<title>Sunshine</title>
<link rel="icon" type="image/x-icon" href="/images/sunshine.ico" />
</head>
Expand Down
2 changes: 1 addition & 1 deletion src_assets/common/assets/web/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createRouter, createWebHistory } from 'vue-router'
* Lazy page imports keep the source split into focused components while the
* server serves the same index document for every browser route.
*/
const routes = [
export const routes = [
{ path: '/', component: () => import('./Home.vue') },
{ path: '/apps', component: () => import('./Apps.vue') },
{ path: '/clients', redirect: '/' },
Expand Down
8 changes: 4 additions & 4 deletions tests/web/Troubleshooting.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ import en from '../../src_assets/common/assets/web/public/assets/locale/en.json'
*/
async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {}, permissions = [], i18n = null) {
vi.stubGlobal('fetch', vi.fn(async (url) => {
if (url === '/api/config') {
if (url === './api/config') {
return { json: async () => ({ platform, gamepad_driver: gamepadDriver }) }
}
if (url === '/api/virtual-input/status') {
if (url === './api/virtual-input/status') {
return {
json: async () => ({
virtualhid: {
Expand All @@ -37,7 +37,7 @@ async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {},
}),
}
}
if (url === '/api/virtual-input/license') {
if (url === './api/virtual-input/license') {
return { ok: true, json: async () => ({ service_available: true, state: 'licensed', licensed: true, ...licenseStatus }) }
}
if (url === './api/permissions') {
Expand Down Expand Up @@ -243,7 +243,7 @@ describe('virtual input troubleshooting', () => {
expect(status.attributes('title')).toBe('troubleshooting.driver_status_compatible')
expect(status.attributes('role')).toBeUndefined()
expect(status.attributes('tabindex')).toBeUndefined()
expect(fetch).toHaveBeenCalledWith('/api/virtual-input/status')
expect(fetch).toHaveBeenCalledWith('./api/virtual-input/status')
wrapper.unmount()
})

Expand Down
91 changes: 91 additions & 0 deletions tests/web/base_path.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { readdirSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'

import { routes } from '../../src_assets/common/assets/web/router.js'

const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../src_assets/common/assets/web')
const indexHtml = readFileSync(join(webRoot, 'index.html'), 'utf8')

/**
* @brief Extract the classic inline script that resolves the base path from index.html.
*
* @return {string} Inline script source.
*/
function baseScriptSource() {
const match = /<script>([\s\S]*?)<\/script>/.exec(indexHtml)
expect(match).not.toBeNull()
return match[1]
}

/**
* @brief Run the index.html base path script for a URL path in an isolated document.
*
* @param {string} pathname Path the browser requested.
* @return {string|null} The href attribute of the inserted base element.
*/
function resolveBase(pathname) {
const doc = document.implementation.createHTMLDocument('Sunshine')
new Function('location', 'document', baseScriptSource())({ pathname }, doc)
return doc.querySelector('base')?.getAttribute('href') ?? null
}

/**
* @brief List web UI source files that are bundled by Vite.
*
* @param {string} dir Directory to scan.
* @return {string[]} Absolute paths of .js and .vue files outside public assets.
*/
function sourceFiles(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const path = join(dir, entry.name)
if (entry.isDirectory()) {
return entry.name === 'public' ? [] : sourceFiles(path)
}
return /\.(js|vue)$/.test(entry.name) ? [path] : []
})
}

describe('web UI base path', () => {
it.each([
['/', '/'],
['/config', '/'],
['/logout/', '/'],
['/unknown', '/'],
['/apps/unknown', '/'],
['/sunshine/', '/sunshine/'],
['/sunshine/config', '/sunshine/'],
['/sunshine/troubleshooting/', '/sunshine/'],
['/sunshine/unknown', '/sunshine/'],
['/proxy/sunshine/pin', '/proxy/sunshine/'],
])('resolves %s to base %s', (pathname, expected) => {
expect(resolveBase(pathname)).toBe(expected)
})

it('knows every top-level route defined in the router', () => {
const routeNames = routes
.map(route => route.path.split('/')[1])
.filter(name => name && !name.startsWith(':'))
const script = baseScriptSource()
const scriptRoutes = JSON.parse(/var routes = (\[[^\]]*\]);/.exec(script)[1].replaceAll("'", '"'))

expect(scriptRoutes.toSorted()).toEqual(routeNames.toSorted())
})

it('does not hard-code a root base element', () => {
expect(indexHtml).not.toMatch(/<base\s/)
})

// Vite rewrites public asset URLs such as /images/... relative to the bundle, but not API request URLs.
it('does not request the API from the domain root', () => {
const rootRelative = /["'`]\/api\//
const offenders = sourceFiles(webRoot)
.flatMap(file => readFileSync(file, 'utf8')
.split('\n')
.filter(line => rootRelative.test(line))
.map(line => `${file}: ${line.trim()}`))

expect(offenders).toEqual([])
})
})
2 changes: 1 addition & 1 deletion vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export default defineConfig({
vue: 'vue/dist/vue.esm-bundler.js'
}
},
base: '/',
base: './',
plugins: [
vue(),
emitTrayIconsPlugin,
Expand Down