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 { 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
|
||||
const { mockPush, mockWindowOpen } = vi.hoisted(() => ({
|
||||
@@ -23,6 +35,7 @@ describe('useAppLauncherStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
__setSignedCatalogForTests(SIGNED as never)
|
||||
// Default to HTTP to avoid proxy rewriting
|
||||
Object.defineProperty(window, 'location', {
|
||||
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 { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
|
||||
import { directAppUrl, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { portIsGateFronted } from '@/views/discover/curatedApps'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
@@ -256,20 +257,20 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||
|
||||
// Scheme discipline for everything launched on this host. App ports are
|
||||
// owned by the app gate, which serves TLS on the same port whenever the
|
||||
// node has a certificate — so on an HTTPS connection every same-host
|
||||
// app URL must be https: plain http is a silent downgrade at best and
|
||||
// mixed-content-blocked at worst (remote browsers, the companion
|
||||
// webview). Apps that are ALWAYS https (netbird's secure-context OIDC
|
||||
// dashboard) upgrade regardless of the page, and external hosts keep
|
||||
// their own scheme.
|
||||
// Scheme discipline for everything launched on this host. Ports fronted
|
||||
// by the node's app gate (manifest auth gated/open) serve TLS on the same
|
||||
// port — on an HTTPS connection those must open over https. Ports that
|
||||
// are NOT gate-fronted (legacy curated installs like Nginx Proxy Manager,
|
||||
// Tailscale; `auth: none` publishes) are plain HTTP and https would fail
|
||||
// to connect outright, so they keep http. External hosts keep their own
|
||||
// scheme.
|
||||
try {
|
||||
const u = new URL(launchUrl, window.location.origin)
|
||||
const sameHost = u.hostname === window.location.hostname
|
||||
const alwaysHttps = !!resolvedId && HTTPS_APP_IDS.has(resolvedId)
|
||||
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
|
||||
// a trailing slash and change the string the caller handed over).
|
||||
launchUrl = launchUrl.replace(/^http:\/\//i, 'https://')
|
||||
|
||||
@@ -184,6 +184,7 @@ import {
|
||||
categorizeCommunityApp,
|
||||
getCuratedAppList,
|
||||
} from './marketplace/marketplaceData'
|
||||
import { fetchAppCatalog } from './discover/curatedApps'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -238,10 +239,17 @@ watch(() => route.query.category, (category) => {
|
||||
// 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
|
||||
// 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[]>({
|
||||
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,
|
||||
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 { 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', () => {
|
||||
beforeEach(() => {
|
||||
__setSignedCatalogForTests(SIGNED as never)
|
||||
})
|
||||
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('photoprism')).toBe(true)
|
||||
@@ -56,7 +87,9 @@ describe('appSessionConfig', () => {
|
||||
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', () => {
|
||||
@@ -66,53 +99,52 @@ describe('appSessionConfig', () => {
|
||||
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
|
||||
// native WebView on phones) used to hardcode http:// — so a node reached
|
||||
// over HTTPS opened Vaultwarden and friends in cleartext. These pin the
|
||||
// scheme-following contract on both page schemes.
|
||||
it('builds direct app URLs on the page scheme — https page, https app', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.0.2.10', protocol: 'https:' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
// over HTTPS opened Vaultwarden and friends in cleartext. It must follow
|
||||
// the page scheme ONLY for ports the app gate fronts (TLS on the same
|
||||
// port); legacy installs without manifests (Nginx Proxy Manager, Tailscale)
|
||||
// and auth:none ports stay on http or https would fail to connect.
|
||||
it('builds direct app URLs on the page scheme — https page, gate-fronted app', () => {
|
||||
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
|
||||
|
||||
expect(directAppUrl('vaultwarden')).toBe('https://192.0.2.10:8082')
|
||||
expect(directAppUrl('gitea')).toBe('https://192.0.2.10:3001')
|
||||
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', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.0.2.10', protocol: 'http:' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
stubLocation({ hostname: '192.0.2.10', protocol: 'http:' })
|
||||
|
||||
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', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.0.2.10', protocol: 'http:' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
stubLocation({ hostname: '192.0.2.10', protocol: 'http:' })
|
||||
|
||||
expect(directAppUrl('netbird')).toBe('https://192.0.2.10:8087')
|
||||
})
|
||||
|
||||
it('resolves session app URLs on the page scheme too (https page)', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.0.2.10', protocol: 'https:' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
it('resolves session app URLs on the page scheme for gate-fronted ports only (https page)', () => {
|
||||
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
|
||||
|
||||
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 */
|
||||
|
||||
import { portIsGateFronted } from '../discover/curatedApps'
|
||||
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
||||
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.
|
||||
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
|
||||
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
|
||||
return appOrigin(8334)
|
||||
return appOrigin(8334, id)
|
||||
}
|
||||
|
||||
if (runtimeUrl && id !== 'netbird') {
|
||||
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)
|
||||
// binds locally. On an HTTPS dashboard that is mixed content and the
|
||||
// frame is blocked outright — but ONLY upgrade when the gate fronts the
|
||||
// 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
|
||||
return base
|
||||
}
|
||||
@@ -124,13 +130,14 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
const port = APP_PORTS[id]
|
||||
if (!port) return ''
|
||||
|
||||
let base = appOrigin(port)
|
||||
let base = appOrigin(port, id)
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
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
|
||||
* 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,
|
||||
* so an untrusted app port renders nothing rather than prompting.
|
||||
*/
|
||||
export function appOrigin(port: number): string {
|
||||
return `${pageScheme()}//${window.location.hostname}:${port}`
|
||||
export function appOrigin(port: number, appId?: string): string {
|
||||
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. */
|
||||
@@ -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
|
||||
* (see appgate/tls.rs), so following the page scheme is always answerable.
|
||||
* 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 {
|
||||
const port = DIRECT_APP_PORTS[appId]
|
||||
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}`
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig'
|
||||
import { portIsGateFronted } from '../discover/curatedApps'
|
||||
import { isAutoTabApp } from '@/utils/autoTabApps'
|
||||
|
||||
export type AppsTab = 'apps' | 'websites' | 'services'
|
||||
@@ -301,10 +302,13 @@ export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
|
||||
if (!addr || typeof window === 'undefined') return addr
|
||||
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
|
||||
// binds locally — on an HTTPS connection that is a cleartext downgrade
|
||||
// (and mixed-content-blocked when opened from the dashboard). The gate
|
||||
// serves TLS on every app port, so follow the page's scheme, exactly like
|
||||
// resolveAppUrl() does for the same runtime URLs.
|
||||
// binds locally — on an HTTPS connection that is a cleartext downgrade.
|
||||
// Upgrade only when the app gate fronts the port (it serves TLS there);
|
||||
// a container-published plain-HTTP port would fail over https outright.
|
||||
try {
|
||||
const port = new URL(local).port
|
||||
if (!portIsGateFronted(pkg.manifest.id, port)) return local
|
||||
} catch { /* keep as-is */ }
|
||||
return matchPageScheme(local)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,17 +18,94 @@ export interface AppCatalog {
|
||||
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 catalogFetchedAt = 0
|
||||
const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
|
||||
|
||||
/** Catalog URLs tried in order. First success wins.
|
||||
* Primary is the backend proxy (`/api/app-catalog`) — server-side fetch
|
||||
* bypasses CORS on the upstream Gitea and CSP restrictions on the IP-port
|
||||
* fallback. If the backend is offline (mid-restart etc.) we fall back
|
||||
* to the static copy baked into the frontend build. */
|
||||
/** Catalog URLs for the community listing. The signed catalog is served by
|
||||
* the backend proxy (`/api/app-catalog`) — server-side fetch bypasses CORS
|
||||
* on the upstream Gitea and verifies the release-root signature. If the
|
||||
* backend is offline (mid-restart etc.) the static community copy baked
|
||||
* into the frontend build still renders the store. */
|
||||
const CATALOG_URLS = [
|
||||
'/api/app-catalog',
|
||||
'/catalog.json',
|
||||
]
|
||||
|
||||
@@ -38,29 +115,61 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
||||
// Return cache if fresh
|
||||
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) {
|
||||
try {
|
||||
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(20000) })
|
||||
if (!res.ok) continue
|
||||
const data = await res.json() as AppCatalog
|
||||
if (!data.apps?.length) continue
|
||||
|
||||
// Expand short docker image refs to full registry paths
|
||||
const registry = data.registry || R
|
||||
for (const app of data.apps) {
|
||||
if (app.dockerImage && !app.dockerImage.includes('/')) {
|
||||
app.dockerImage = `${registry}/${app.dockerImage}`
|
||||
}
|
||||
}
|
||||
cachedCatalog = data
|
||||
catalogFetchedAt = Date.now()
|
||||
// Cache in localStorage for offline fallback
|
||||
try { localStorage.setItem('archy_catalog', JSON.stringify(data)) } catch {}
|
||||
return data
|
||||
} catch (e) {
|
||||
console.warn(`[catalog] fetch failed for ${url}:`, e)
|
||||
continue
|
||||
community = data
|
||||
break
|
||||
} catch { /* try the next source */ }
|
||||
}
|
||||
|
||||
if (signedOk || community) {
|
||||
// Community copy wins for shared ids (curated descriptions, webUrl-only
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user