fix(ui): gate-fronted https launches + signed-catalog App Store
Demo images / Build & push demo images (push) Failing after 36s
Demo images / Build & push demo images (push) Failing after 36s
directAppUrl(), the legacy open() path, and resolveRuntimeLaunchUrl() now upgrade to https only for ports the app gate fronts — decided from the signed catalog's embedded manifest ports (auth gated/open), so plain-HTTP publishes (legacy installs, auth:none API ports like Cuprate's RPC) keep http instead of failing outright. fetchAppCatalog() merges the daemon-verified signed catalog into the App Store listing (signed entries appear immediately; community copy supplies featured and curated metadata), and Marketplace.vue uses the same dynamic fetcher as Discover so the grid sees signed-new apps too.
This commit is contained in:
@@ -1,5 +1,17 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { setActivePinia, createPinia } from 'pinia'
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { __setSignedCatalogForTests } from '@/views/discover/curatedApps'
|
||||||
|
|
||||||
|
// The signed catalog's embedded manifests decide which ports the app gate
|
||||||
|
// fronts (TLS on the same port) — prime the same shape the live catalog
|
||||||
|
// carries for the apps these tests launch.
|
||||||
|
const SIGNED = {
|
||||||
|
apps: {
|
||||||
|
vaultwarden: { version: '1.37.1', manifest: { app: { ports: [{ host: 8082, auth: 'gated' }] } } },
|
||||||
|
gitea: { version: '1.23', manifest: { app: { ports: [{ host: 3001, auth: 'open' }] } } },
|
||||||
|
'nginx-proxy-manager': { version: 'latest' }, // legacy: no manifest → http
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// vi.hoisted runs before vi.mock hoisting
|
// vi.hoisted runs before vi.mock hoisting
|
||||||
const { mockPush, mockWindowOpen } = vi.hoisted(() => ({
|
const { mockPush, mockWindowOpen } = vi.hoisted(() => ({
|
||||||
@@ -23,6 +35,7 @@ describe('useAppLauncherStore', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
__setSignedCatalogForTests(SIGNED as never)
|
||||||
// Default to HTTP to avoid proxy rewriting
|
// Default to HTTP to avoid proxy rewriting
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
value: { origin: 'http://192.0.2.10', protocol: 'http:', hostname: '192.0.2.10' },
|
value: { origin: 'http://192.0.2.10', protocol: 'http:', hostname: '192.0.2.10' },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { recordAppLaunch } from '@/utils/appUsage'
|
|||||||
import { requestExternalOpen } from '@/api/remote-relay'
|
import { requestExternalOpen } from '@/api/remote-relay'
|
||||||
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
|
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
|
||||||
import { directAppUrl, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
import { directAppUrl, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||||
|
import { portIsGateFronted } from '@/views/discover/curatedApps'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||||
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||||
@@ -256,20 +257,20 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||||
|
|
||||||
// Scheme discipline for everything launched on this host. App ports are
|
// Scheme discipline for everything launched on this host. Ports fronted
|
||||||
// owned by the app gate, which serves TLS on the same port whenever the
|
// by the node's app gate (manifest auth gated/open) serve TLS on the same
|
||||||
// node has a certificate — so on an HTTPS connection every same-host
|
// port — on an HTTPS connection those must open over https. Ports that
|
||||||
// app URL must be https: plain http is a silent downgrade at best and
|
// are NOT gate-fronted (legacy curated installs like Nginx Proxy Manager,
|
||||||
// mixed-content-blocked at worst (remote browsers, the companion
|
// Tailscale; `auth: none` publishes) are plain HTTP and https would fail
|
||||||
// webview). Apps that are ALWAYS https (netbird's secure-context OIDC
|
// to connect outright, so they keep http. External hosts keep their own
|
||||||
// dashboard) upgrade regardless of the page, and external hosts keep
|
// scheme.
|
||||||
// their own scheme.
|
|
||||||
try {
|
try {
|
||||||
const u = new URL(launchUrl, window.location.origin)
|
const u = new URL(launchUrl, window.location.origin)
|
||||||
const sameHost = u.hostname === window.location.hostname
|
const sameHost = u.hostname === window.location.hostname
|
||||||
const alwaysHttps = !!resolvedId && HTTPS_APP_IDS.has(resolvedId)
|
const alwaysHttps = !!resolvedId && HTTPS_APP_IDS.has(resolvedId)
|
||||||
const httpsPage = window.location.protocol === 'https:'
|
const httpsPage = window.location.protocol === 'https:'
|
||||||
if (u.protocol === 'http:' && (alwaysHttps || (httpsPage && sameHost && (resolvedId || mustOpenInNewTab(launchUrl))))) {
|
const gateFronted = !!resolvedId && portIsGateFronted(resolvedId, u.port)
|
||||||
|
if (u.protocol === 'http:' && sameHost && (alwaysHttps || (httpsPage && gateFronted))) {
|
||||||
// Pure prefix swap — never re-serialize the URL (URL.href would add
|
// Pure prefix swap — never re-serialize the URL (URL.href would add
|
||||||
// a trailing slash and change the string the caller handed over).
|
// a trailing slash and change the string the caller handed over).
|
||||||
launchUrl = launchUrl.replace(/^http:\/\//i, 'https://')
|
launchUrl = launchUrl.replace(/^http:\/\//i, 'https://')
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ import {
|
|||||||
categorizeCommunityApp,
|
categorizeCommunityApp,
|
||||||
getCuratedAppList,
|
getCuratedAppList,
|
||||||
} from './marketplace/marketplaceData'
|
} from './marketplace/marketplaceData'
|
||||||
|
import { fetchAppCatalog } from './discover/curatedApps'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -238,10 +239,17 @@ watch(() => route.query.category, (category) => {
|
|||||||
// Community marketplace state — cached (D-09/D-06: near-static catalog, long
|
// Community marketplace state — cached (D-09/D-06: near-static catalog, long
|
||||||
// TTL) behind a shared key so Discover.vue's identical loader picks up the
|
// TTL) behind a shared key so Discover.vue's identical loader picks up the
|
||||||
// same cache entry without its own conversion (plan 02-04). Non-sensitive
|
// same cache entry without its own conversion (plan 02-04). Non-sensitive
|
||||||
// and small, so it persists across reloads.
|
// and small, so it persists across reloads. Dynamic-catalog-first: the
|
||||||
|
// daemon-verified signed catalog is what makes a newly published app appear
|
||||||
|
// without a dashboard release — the static list below is only the offline
|
||||||
|
// fallback (same fetcher contract as Discover.vue for this shared key).
|
||||||
const catalogResource = useCachedResource<MarketplaceApp[]>({
|
const catalogResource = useCachedResource<MarketplaceApp[]>({
|
||||||
key: 'app-catalog',
|
key: 'app-catalog',
|
||||||
fetcher: async () => getCuratedAppList(),
|
fetcher: async () => {
|
||||||
|
const catalog = await fetchAppCatalog()
|
||||||
|
if (catalog && catalog.apps.length) return catalog.apps
|
||||||
|
return getCuratedAppList()
|
||||||
|
},
|
||||||
ttlMs: 300_000,
|
ttlMs: 300_000,
|
||||||
persist: true,
|
persist: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,39 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, beforeEach } from 'vitest'
|
||||||
import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
|
import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
|
||||||
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
|
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
|
||||||
|
import { __setSignedCatalogForTests } from '../../discover/curatedApps'
|
||||||
|
|
||||||
|
// Mirror of the live signed catalog's embedded manifests (the ports[] auth
|
||||||
|
// that decides TLS eligibility). Kept minimal — only what the scheme logic
|
||||||
|
// consults.
|
||||||
|
const SIGNED = {
|
||||||
|
apps: {
|
||||||
|
vaultwarden: { version: '1.37.1', manifest: { app: { ports: [{ host: 8082, auth: 'gated' }] } } },
|
||||||
|
gitea: { version: '1.23', manifest: { app: { ports: [{ host: 3001, auth: 'open' }, { host: 2222, auth: 'none' }] } } },
|
||||||
|
'btcpay-server': { version: '2.4.3', manifest: { app: { ports: [{ host: 23000, auth: 'open' }] } } },
|
||||||
|
mempool: { version: '3.3.1', manifest: { app: { ports: [{ host: 4080, auth: 'gated' }] } } },
|
||||||
|
filebrowser: { version: '2.27.0', manifest: { app: { ports: [{ host: 8083, auth: 'gated' }] } } },
|
||||||
|
// Legacy curated installs — in the community list, NOT in the signed
|
||||||
|
// catalog's manifests. Their ports publish plain HTTP: https fails.
|
||||||
|
'nginx-proxy-manager': { version: 'latest' },
|
||||||
|
tailscale: { version: 'stable' },
|
||||||
|
// auth:none ports are container-published too — https would fail.
|
||||||
|
cuprate: { version: '0.1.0-preview', manifest: { app: { ports: [{ host: 18090, auth: 'none' }] } } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubLocation(value: { hostname: string; protocol: string }) {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('appSessionConfig', () => {
|
describe('appSessionConfig', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__setSignedCatalogForTests(SIGNED as never)
|
||||||
|
})
|
||||||
it('keeps manifest-owned new-tab apps marked on every viewport', () => {
|
it('keeps manifest-owned new-tab apps marked on every viewport', () => {
|
||||||
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
|
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
|
||||||
expect(NEW_TAB_APPS.has('photoprism')).toBe(true)
|
expect(NEW_TAB_APPS.has('photoprism')).toBe(true)
|
||||||
@@ -56,7 +87,9 @@ describe('appSessionConfig', () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.0.2.10:8087')
|
// NetBird's dashboard needs a secure context (OIDC PKCE), so it is
|
||||||
|
// ALWAYS launched over https — on either page scheme.
|
||||||
|
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('https://192.0.2.10:8087')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses backend runtime URLs for apps with dynamic launch surfaces', () => {
|
it('uses backend runtime URLs for apps with dynamic launch surfaces', () => {
|
||||||
@@ -66,53 +99,52 @@ describe('appSessionConfig', () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
|
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:8083')).toBe('http://192.0.2.10:8083')
|
||||||
})
|
})
|
||||||
|
|
||||||
// The direct-port launch path (new-tab apps on desktop, the companion's
|
// The direct-port launch path (new-tab apps on desktop, the companion's
|
||||||
// native WebView on phones) used to hardcode http:// — so a node reached
|
// native WebView on phones) used to hardcode http:// — so a node reached
|
||||||
// over HTTPS opened Vaultwarden and friends in cleartext. These pin the
|
// over HTTPS opened Vaultwarden and friends in cleartext. It must follow
|
||||||
// scheme-following contract on both page schemes.
|
// the page scheme ONLY for ports the app gate fronts (TLS on the same
|
||||||
it('builds direct app URLs on the page scheme — https page, https app', () => {
|
// port); legacy installs without manifests (Nginx Proxy Manager, Tailscale)
|
||||||
Object.defineProperty(window, 'location', {
|
// and auth:none ports stay on http or https would fail to connect.
|
||||||
value: { hostname: '192.0.2.10', protocol: 'https:' },
|
it('builds direct app URLs on the page scheme — https page, gate-fronted app', () => {
|
||||||
writable: true,
|
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
|
||||||
configurable: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(directAppUrl('vaultwarden')).toBe('https://192.0.2.10:8082')
|
expect(directAppUrl('vaultwarden')).toBe('https://192.0.2.10:8082')
|
||||||
expect(directAppUrl('gitea')).toBe('https://192.0.2.10:3001')
|
expect(directAppUrl('gitea')).toBe('https://192.0.2.10:3001')
|
||||||
expect(directAppUrl('btcpay-server')).toBe('https://192.0.2.10:23000')
|
expect(directAppUrl('btcpay-server')).toBe('https://192.0.2.10:23000')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps legacy manifest-less apps on http even on an https page', () => {
|
||||||
|
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
|
||||||
|
|
||||||
|
expect(directAppUrl('nginx-proxy-manager')).toBe('http://192.0.2.10:8081')
|
||||||
|
expect(directAppUrl('tailscale')).toBe('http://192.0.2.10:8240')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps plain-http direct app URLs on a plain-http page', () => {
|
it('keeps plain-http direct app URLs on a plain-http page', () => {
|
||||||
Object.defineProperty(window, 'location', {
|
stubLocation({ hostname: '192.0.2.10', protocol: 'http:' })
|
||||||
value: { hostname: '192.0.2.10', protocol: 'http:' },
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(directAppUrl('vaultwarden')).toBe('http://192.0.2.10:8082')
|
expect(directAppUrl('vaultwarden')).toBe('http://192.0.2.10:8082')
|
||||||
|
expect(directAppUrl('nginx-proxy-manager')).toBe('http://192.0.2.10:8081')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('always launches secure-context apps over https, on either page scheme', () => {
|
it('always launches secure-context apps over https, on either page scheme', () => {
|
||||||
Object.defineProperty(window, 'location', {
|
stubLocation({ hostname: '192.0.2.10', protocol: 'http:' })
|
||||||
value: { hostname: '192.0.2.10', protocol: 'http:' },
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(directAppUrl('netbird')).toBe('https://192.0.2.10:8087')
|
expect(directAppUrl('netbird')).toBe('https://192.0.2.10:8087')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resolves session app URLs on the page scheme too (https page)', () => {
|
it('resolves session app URLs on the page scheme for gate-fronted ports only (https page)', () => {
|
||||||
Object.defineProperty(window, 'location', {
|
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
|
||||||
value: { hostname: '192.0.2.10', protocol: 'https:' },
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(resolveAppUrl('mempool')).toBe('https://192.0.2.10:4080')
|
expect(resolveAppUrl('mempool')).toBe('https://192.0.2.10:4080')
|
||||||
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('https://192.0.2.10:18083')
|
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:8083')).toBe('https://192.0.2.10:8083')
|
||||||
|
// A runtime port the gate does NOT front keeps plain http (https would
|
||||||
|
// fail to connect outright).
|
||||||
|
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
|
||||||
|
// Cuprate's UI port is auth:none — plain HTTP stays plain.
|
||||||
|
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Static configuration maps for app session routing and display */
|
/** Static configuration maps for app session routing and display */
|
||||||
|
|
||||||
|
import { portIsGateFronted } from '../discover/curatedApps'
|
||||||
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
||||||
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
|
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
|
||||||
|
|
||||||
@@ -107,15 +108,20 @@ 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 appOrigin(8334)
|
return appOrigin(8334, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// 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
|
// binds locally. On an HTTPS dashboard that is mixed content and the
|
||||||
// content and the frame is blocked outright, so follow the page instead.
|
// frame is blocked outright — but ONLY upgrade when the gate fronts the
|
||||||
base = matchPageScheme(base)
|
// port (it serves TLS there); a container-published plain-HTTP port
|
||||||
|
// would fail to connect over https at all.
|
||||||
|
try {
|
||||||
|
const port = new URL(base).port
|
||||||
|
if (portIsGateFronted(id, port)) base = matchPageScheme(base)
|
||||||
|
} catch { /* keep as-is */ }
|
||||||
if (routeQueryPath) base += routeQueryPath
|
if (routeQueryPath) base += routeQueryPath
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
@@ -124,13 +130,14 @@ 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 = appOrigin(port)
|
let base = appOrigin(port, id)
|
||||||
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 app's origin on this host, on the SAME scheme as the page when the
|
||||||
|
* app gate fronts the port (TLS on the same port), plain http otherwise.
|
||||||
*
|
*
|
||||||
* An HTTPS dashboard cannot embed an HTTP frame at all — browsers block it as
|
* 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
|
* mixed content before any cookie question arises — and it is also what makes
|
||||||
@@ -143,8 +150,11 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
|||||||
* Node certificate. A certificate warning cannot be accepted inside an iframe,
|
* Node certificate. A certificate warning cannot be accepted inside an iframe,
|
||||||
* so an untrusted app port renders nothing rather than prompting.
|
* so an untrusted app port renders nothing rather than prompting.
|
||||||
*/
|
*/
|
||||||
export function appOrigin(port: number): string {
|
export function appOrigin(port: number, appId?: string): string {
|
||||||
return `${pageScheme()}//${window.location.hostname}:${port}`
|
const https = appId
|
||||||
|
? HTTPS_APP_IDS.has(appId) || (portIsGateFronted(appId, port) && pageScheme() === 'https:')
|
||||||
|
: pageScheme() === 'https:'
|
||||||
|
return `${https ? 'https' : 'http'}://${window.location.hostname}:${port}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rewrite a URL's scheme to the page's, leaving everything else alone. */
|
/** Rewrite a URL's scheme to the page's, leaving everything else alone. */
|
||||||
@@ -202,10 +212,25 @@ export const DIRECT_APP_PORTS: Record<string, string> = {
|
|||||||
* DIRECT_APP_PORTS is served by the app gate with TLS on the same port
|
* DIRECT_APP_PORTS is served by the app gate with TLS on the same port
|
||||||
* (see appgate/tls.rs), so following the page scheme is always answerable.
|
* (see appgate/tls.rs), so following the page scheme is always answerable.
|
||||||
* Plain-HTTP dashboards keep today's behaviour exactly. */
|
* Plain-HTTP dashboards keep today's behaviour exactly. */
|
||||||
|
/** Whether an app's direct port should follow the page's scheme (https on
|
||||||
|
* an https connection). True only when the node's app gate fronts the port
|
||||||
|
* (manifest auth gated/open — TLS served on the same port) or the app is
|
||||||
|
* unconditionally https (netbird). Legacy curated installs without a
|
||||||
|
* manifest (Nginx Proxy Manager, Tailscale) and `auth: none` ports (Cuprate's
|
||||||
|
* RPC) publish plain HTTP and must NOT be upgraded — https would fail to
|
||||||
|
* connect outright. */
|
||||||
|
function shouldFollowPageScheme(appId: string, port: number | string): boolean {
|
||||||
|
if (HTTPS_APP_IDS.has(appId)) return true
|
||||||
|
return portIsGateFronted(appId, port)
|
||||||
|
}
|
||||||
|
|
||||||
export function directAppUrl(appId: string): string | null {
|
export function directAppUrl(appId: string): string | null {
|
||||||
const port = DIRECT_APP_PORTS[appId]
|
const port = DIRECT_APP_PORTS[appId]
|
||||||
if (!port || typeof window === 'undefined') return null
|
if (!port || typeof window === 'undefined') return null
|
||||||
const scheme = HTTPS_APP_IDS.has(appId) || pageScheme() === 'https:' ? 'https' : 'http'
|
const scheme = HTTPS_APP_IDS.has(appId)
|
||||||
|
|| (portIsGateFronted(appId, port) && pageScheme() === 'https:')
|
||||||
|
? 'https'
|
||||||
|
: 'http'
|
||||||
return `${scheme}://${window.location.hostname}:${port}`
|
return `${scheme}://${window.location.hostname}:${port}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Ref } from 'vue'
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||||
import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig'
|
import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig'
|
||||||
|
import { portIsGateFronted } from '../discover/curatedApps'
|
||||||
import { isAutoTabApp } from '@/utils/autoTabApps'
|
import { isAutoTabApp } from '@/utils/autoTabApps'
|
||||||
|
|
||||||
export type AppsTab = 'apps' | 'websites' | 'services'
|
export type AppsTab = 'apps' | 'websites' | 'services'
|
||||||
@@ -301,10 +302,13 @@ export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
|
|||||||
if (!addr || typeof window === 'undefined') return addr
|
if (!addr || typeof window === 'undefined') return addr
|
||||||
const local = addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
|
const local = addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
|
||||||
// The backend reports runtime URLs as http:// because that is how the app
|
// The backend reports runtime URLs as http:// because that is how the app
|
||||||
// binds locally — on an HTTPS connection that is a cleartext downgrade
|
// binds locally — on an HTTPS connection that is a cleartext downgrade.
|
||||||
// (and mixed-content-blocked when opened from the dashboard). The gate
|
// Upgrade only when the app gate fronts the port (it serves TLS there);
|
||||||
// serves TLS on every app port, so follow the page's scheme, exactly like
|
// a container-published plain-HTTP port would fail over https outright.
|
||||||
// resolveAppUrl() does for the same runtime URLs.
|
try {
|
||||||
|
const port = new URL(local).port
|
||||||
|
if (!portIsGateFronted(pkg.manifest.id, port)) return local
|
||||||
|
} catch { /* keep as-is */ }
|
||||||
return matchPageScheme(local)
|
return matchPageScheme(local)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,17 +18,94 @@ export interface AppCatalog {
|
|||||||
apps: MarketplaceApp[]
|
apps: MarketplaceApp[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shape of the release-signed catalog (`releases/app-catalog.json`) served
|
||||||
|
* by the daemon at /api/app-catalog after release-root verification. `apps`
|
||||||
|
* is keyed by app id and each entry embeds the app's full manifest — the
|
||||||
|
* ports[] there (auth: gated/open/none) are what decides whether a port is
|
||||||
|
* fronted by the node's app gate (and therefore serves TLS on the same
|
||||||
|
* port) or published by the container as plain HTTP. */
|
||||||
|
export interface SignedAppCatalog {
|
||||||
|
schema?: number
|
||||||
|
updated?: string
|
||||||
|
apps: Record<string, SignedAppEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignedAppEntry {
|
||||||
|
version: string
|
||||||
|
image?: string
|
||||||
|
manifest?: {
|
||||||
|
app?: {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
version?: string
|
||||||
|
description?: string
|
||||||
|
category?: string
|
||||||
|
container?: { image?: string }
|
||||||
|
metadata?: { icon?: string; author?: string; repo?: string }
|
||||||
|
ports?: { host?: number | string; container?: number | string; auth?: string }[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert the signed catalog's keyed entries into store-listing apps.
|
||||||
|
* Pure — unit-tested against the live catalog's shape (Cuprate). */
|
||||||
|
export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[] {
|
||||||
|
const out: MarketplaceApp[] = []
|
||||||
|
for (const [id, entry] of Object.entries(catalog.apps || {})) {
|
||||||
|
const app = entry.manifest?.app
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
title: app?.name || id,
|
||||||
|
version: entry.version || app?.version || '',
|
||||||
|
description: app?.description || '',
|
||||||
|
icon: app?.metadata?.icon || '/assets/icon/favico-black-v2.svg',
|
||||||
|
author: app?.metadata?.author,
|
||||||
|
dockerImage: entry.image || app?.container?.image || '',
|
||||||
|
repoUrl: app?.metadata?.repo,
|
||||||
|
category: app?.category,
|
||||||
|
source: 'signed-catalog',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The daemon-verified signed catalog, kept for synchronous port-auth lookups
|
||||||
|
* after fetchAppCatalog() has run. Test-hookable. */
|
||||||
|
let signedCatalogCache: SignedAppCatalog | null = null
|
||||||
|
|
||||||
|
/** Port auth for an app's host port, from the signed catalog's embedded
|
||||||
|
* manifest. `gated`/`open` = the node's app gate owns the port and serves
|
||||||
|
* TLS on it; `none`/`local` = container-published plain HTTP; null = app
|
||||||
|
* unknown to the signed catalog (legacy curated installs). */
|
||||||
|
export function portAuth(appId: string, hostPort: number | string): string | null {
|
||||||
|
const ports = signedCatalogCache?.apps?.[appId]?.manifest?.app?.ports
|
||||||
|
if (!Array.isArray(ports)) return null
|
||||||
|
const hit = ports.find(p => String(p.host) === String(hostPort))
|
||||||
|
return hit?.auth ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an app's host port is fronted by the node's app gate (and so
|
||||||
|
* serves TLS alongside HTTP on the same port). Unknown apps are NOT —
|
||||||
|
* assuming TLS for a container-published port breaks it outright. */
|
||||||
|
export function portIsGateFronted(appId: string, hostPort: number | string): boolean {
|
||||||
|
const auth = portAuth(appId, hostPort)
|
||||||
|
return auth === 'gated' || auth === 'open'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __setSignedCatalogForTests(catalog: SignedAppCatalog | null) {
|
||||||
|
signedCatalogCache = catalog
|
||||||
|
}
|
||||||
|
|
||||||
let cachedCatalog: AppCatalog | null = null
|
let cachedCatalog: AppCatalog | null = null
|
||||||
let catalogFetchedAt = 0
|
let catalogFetchedAt = 0
|
||||||
const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
|
const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
|
||||||
|
|
||||||
/** Catalog URLs tried in order. First success wins.
|
/** Catalog URLs for the community listing. The signed catalog is served by
|
||||||
* Primary is the backend proxy (`/api/app-catalog`) — server-side fetch
|
* the backend proxy (`/api/app-catalog`) — server-side fetch bypasses CORS
|
||||||
* bypasses CORS on the upstream Gitea and CSP restrictions on the IP-port
|
* on the upstream Gitea and verifies the release-root signature. If the
|
||||||
* fallback. If the backend is offline (mid-restart etc.) we fall back
|
* backend is offline (mid-restart etc.) the static community copy baked
|
||||||
* to the static copy baked into the frontend build. */
|
* into the frontend build still renders the store. */
|
||||||
const CATALOG_URLS = [
|
const CATALOG_URLS = [
|
||||||
'/api/app-catalog',
|
|
||||||
'/catalog.json',
|
'/catalog.json',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -38,29 +115,61 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
|||||||
// Return cache if fresh
|
// Return cache if fresh
|
||||||
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return cachedCatalog
|
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return cachedCatalog
|
||||||
|
|
||||||
|
// The daemon-verified signed catalog first (release-root signature checked
|
||||||
|
// server-side): it is what makes a newly published app appear without a
|
||||||
|
// dashboard release. The community catalog supplies the featured banner
|
||||||
|
// and curated copy for shared ids; signed-only ids join the listing as-is.
|
||||||
|
let signedApps: MarketplaceApp[] = []
|
||||||
|
let signedOk = false
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/app-catalog', { credentials: 'include', signal: AbortSignal.timeout(20000) })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json() as SignedAppCatalog
|
||||||
|
if (data.apps && !Array.isArray(data.apps)) {
|
||||||
|
signedCatalogCache = data
|
||||||
|
signedApps = signedCatalogToApps(data)
|
||||||
|
signedOk = signedApps.length > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* fall through to the community catalog */ }
|
||||||
|
|
||||||
|
let community: AppCatalog | null = null
|
||||||
for (const url of CATALOG_URLS) {
|
for (const url of CATALOG_URLS) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(20000) })
|
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(20000) })
|
||||||
if (!res.ok) continue
|
if (!res.ok) continue
|
||||||
const data = await res.json() as AppCatalog
|
const data = await res.json() as AppCatalog
|
||||||
if (!data.apps?.length) continue
|
if (!data.apps?.length) continue
|
||||||
|
|
||||||
// Expand short docker image refs to full registry paths
|
|
||||||
const registry = data.registry || R
|
const registry = data.registry || R
|
||||||
for (const app of data.apps) {
|
for (const app of data.apps) {
|
||||||
if (app.dockerImage && !app.dockerImage.includes('/')) {
|
if (app.dockerImage && !app.dockerImage.includes('/')) {
|
||||||
app.dockerImage = `${registry}/${app.dockerImage}`
|
app.dockerImage = `${registry}/${app.dockerImage}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cachedCatalog = data
|
community = data
|
||||||
catalogFetchedAt = Date.now()
|
break
|
||||||
// Cache in localStorage for offline fallback
|
} catch { /* try the next source */ }
|
||||||
try { localStorage.setItem('archy_catalog', JSON.stringify(data)) } catch {}
|
}
|
||||||
return data
|
|
||||||
} catch (e) {
|
if (signedOk || community) {
|
||||||
console.warn(`[catalog] fetch failed for ${url}:`, e)
|
// Community copy wins for shared ids (curated descriptions, webUrl-only
|
||||||
continue
|
// apps); signed entries fill version/image gaps and append brand-new apps.
|
||||||
|
const byId = new Map<string, MarketplaceApp>()
|
||||||
|
for (const app of signedApps) byId.set(app.id, app)
|
||||||
|
for (const app of community?.apps ?? []) {
|
||||||
|
const existing = byId.get(app.id)
|
||||||
|
byId.set(app.id, existing ? { ...app, version: app.version || existing.version, dockerImage: app.dockerImage || existing.dockerImage } : app)
|
||||||
}
|
}
|
||||||
|
const merged: AppCatalog = {
|
||||||
|
version: community?.version ?? 1,
|
||||||
|
registry: community?.registry ?? R,
|
||||||
|
featured: community?.featured ?? { id: 'bitcoin-knots', banner: '', headline: '', description: '', tag: '' },
|
||||||
|
apps: [...byId.values()],
|
||||||
|
}
|
||||||
|
cachedCatalog = merged
|
||||||
|
catalogFetchedAt = Date.now()
|
||||||
|
try { localStorage.setItem('archy_catalog', JSON.stringify(merged)) } catch {}
|
||||||
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try localStorage cache as final fallback
|
// Try localStorage cache as final fallback
|
||||||
|
|||||||
Reference in New Issue
Block a user