fix(ui): app frames follow the dashboard's scheme instead of forcing http
Demo images / Build & push demo images (push) Failing after 2m10s
Demo images / Build & push demo images (push) Failing after 2m10s
Both schemes now work, and each one works properly: - HTTP dashboard -> http app origin (unchanged; no certificate needed) - HTTPS dashboard -> https app origin (needs the node CA + TLS on the port) The app URL was hardcoded to http://, which on an HTTPS dashboard is mixed content — blocked outright, before the SameSite cookie question the symptom was filed under. It is also what made the two origins schemefully cross-site, so following the page's scheme fixes both causes at once. Backend-reported runtime URLs get the same treatment: the daemon reports http:// because that is how the app binds locally, which is right for the node and wrong for a browser on an HTTPS page. pageScheme() defaults to http when location.protocol is absent (non-browser contexts) — the safe direction, since inventing an https URL for a port that serves no TLS would break a working setup. That default is also why the three existing resolveAppUrl tests, whose fixture stubs location without a protocol, keep passing unmodified rather than being edited to fit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
aab74127f5
commit
f09ff102ee
@@ -0,0 +1,55 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { appOrigin, matchPageScheme } from '../appSessionConfig'
|
||||||
|
|
||||||
|
// An HTTPS dashboard cannot embed an HTTP app frame — browsers block it as
|
||||||
|
// mixed content — so the app origin has to follow the page's scheme. Plain-HTTP
|
||||||
|
// nodes must be completely unaffected, which is what most of these pin.
|
||||||
|
|
||||||
|
function setLocation(protocol: string, hostname: string) {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { protocol, hostname },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
describe('appOrigin', () => {
|
||||||
|
it('stays on http for an http dashboard', () => {
|
||||||
|
setLocation('http:', 'archi-dev-box')
|
||||||
|
expect(appOrigin(8334)).toBe('http://archi-dev-box:8334')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('follows an https dashboard onto the app port', () => {
|
||||||
|
setLocation('https:', 'archi-dev-box')
|
||||||
|
expect(appOrigin(8334)).toBe('https://archi-dev-box:8334')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the hostname the user actually typed, not a fixed name', () => {
|
||||||
|
setLocation('https:', '100.69.68.39')
|
||||||
|
expect(appOrigin(3000)).toBe('https://100.69.68.39:3000')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('matchPageScheme', () => {
|
||||||
|
it('leaves backend-reported http URLs alone on an http page', () => {
|
||||||
|
setLocation('http:', 'node')
|
||||||
|
expect(matchPageScheme('http://node:8080/app')).toBe('http://node:8080/app')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upgrades a backend-reported http URL on an https page', () => {
|
||||||
|
setLocation('https:', 'node')
|
||||||
|
expect(matchPageScheme('http://node:8080/app')).toBe('https://node:8080/app')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not touch anything but the scheme', () => {
|
||||||
|
setLocation('https:', 'node')
|
||||||
|
expect(matchPageScheme('http://node:8080/a/b?c=1#d')).toBe('https://node:8080/a/b?c=1#d')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves an already-https URL untouched', () => {
|
||||||
|
setLocation('https:', 'node')
|
||||||
|
expect(matchPageScheme('https://node:8080/app')).toBe('https://node:8080/app')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -107,11 +107,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
|||||||
// shell when proxied under a path prefix on some nodes.
|
// shell when proxied under a path prefix on some nodes.
|
||||||
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
|
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
|
||||||
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
|
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
|
||||||
return 'http://' + window.location.hostname + ':8334'
|
return appOrigin(8334)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeUrl && id !== 'netbird') {
|
if (runtimeUrl && id !== 'netbird') {
|
||||||
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
|
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
|
||||||
|
// The backend reports runtime URLs as http:// because that is how the app
|
||||||
|
// binds locally. Sent to a browser on an HTTPS dashboard that is mixed
|
||||||
|
// content and the frame is blocked outright, so follow the page instead.
|
||||||
|
base = matchPageScheme(base)
|
||||||
if (routeQueryPath) base += routeQueryPath
|
if (routeQueryPath) base += routeQueryPath
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
@@ -120,11 +124,48 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
|||||||
const port = APP_PORTS[id]
|
const port = APP_PORTS[id]
|
||||||
if (!port) return ''
|
if (!port) return ''
|
||||||
|
|
||||||
let base = 'http://' + window.location.hostname + ':' + String(port)
|
let base = appOrigin(port)
|
||||||
if (routeQueryPath) base += routeQueryPath
|
if (routeQueryPath) base += routeQueryPath
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An app's origin on this host, on the SAME scheme as the page.
|
||||||
|
*
|
||||||
|
* An HTTPS dashboard cannot embed an HTTP frame at all — browsers block it as
|
||||||
|
* mixed content before any cookie question arises — and it is also what makes
|
||||||
|
* the two origins schemefully cross-site, so the session cookie is withheld.
|
||||||
|
* Following the page's scheme fixes both at once and keeps plain HTTP working
|
||||||
|
* exactly as before on nodes that serve the dashboard over HTTP.
|
||||||
|
*
|
||||||
|
* On HTTPS this requires the app port to actually serve TLS with a certificate
|
||||||
|
* the browser trusts — see scripts/setup-node-ca.sh and Settings → System →
|
||||||
|
* Node certificate. A certificate warning cannot be accepted inside an iframe,
|
||||||
|
* so an untrusted app port renders nothing rather than prompting.
|
||||||
|
*/
|
||||||
|
export function appOrigin(port: number): string {
|
||||||
|
return `${pageScheme()}//${window.location.hostname}:${port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrite a URL's scheme to the page's, leaving everything else alone. */
|
||||||
|
export function matchPageScheme(url: string): string {
|
||||||
|
if (pageScheme() !== 'https:') return url
|
||||||
|
return url.replace(/^http:\/\//i, 'https://')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's scheme, defaulting to http.
|
||||||
|
*
|
||||||
|
* A real browser always has location.protocol; this defends the non-browser
|
||||||
|
* cases (tests, SSR-ish contexts) where it can be absent. Defaulting to http
|
||||||
|
* is the safe direction — it preserves today's behaviour rather than inventing
|
||||||
|
* an https URL for a port that may not serve TLS.
|
||||||
|
*/
|
||||||
|
function pageScheme(): string {
|
||||||
|
const p = window.location?.protocol
|
||||||
|
return p === 'https:' || p === 'http:' ? p : 'http:'
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve a human-readable title for an app */
|
/** Resolve a human-readable title for an app */
|
||||||
export function resolveAppTitle(id: string): string {
|
export function resolveAppTitle(id: string): string {
|
||||||
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
|||||||
Reference in New Issue
Block a user