import { defineStore } from 'pinia' import { ref, watch } from 'vue' import { rpcClient } from '@/api/rpc-client' /** Hostnames of external sites that block iframes via X-Frame-Options or CSP. * These always open in a new tab. Other external sites load directly in the iframe. */ const IFRAME_BLOCKED_HOSTS: string[] = [ '484.kitchen', 'botfights.net', 'present.l484.com', ] /** External site proxy paths — disabled. External URLs load directly in the iframe * via their standard https:// URL. The /ext/ subpath approach broke SPAs. */ const EXTERNAL_PROXY_PATH: Record = {} function mustOpenInNewTab(url: string): boolean { try { const u = new URL(url) // External sites that block iframes if (IFRAME_BLOCKED_HOSTS.some(h => u.hostname === h || u.hostname.endsWith(`.${h}`))) { return true } // Local apps with X-Frame-Options or CSP frame-ancestors blocking iframes if ( u.port === '23000' || // BTCPay — X-Frame-Options: DENY u.port === '3000' || // Grafana — X-Frame-Options: deny u.port === '8082' || // Vaultwarden — X-Frame-Options: SAMEORIGIN + CSP frame-ancestors u.port === '2342' || // PhotoPrism — X-Frame-Options: DENY + CSP frame-ancestors: 'none' u.port === '8085' || // Nextcloud — X-Frame-Options: SAMEORIGIN u.port === '3001' || // Uptime Kuma — X-Frame-Options: SAMEORIGIN u.port === '8123' // Home Assistant — X-Frame-Options: SAMEORIGIN ) { return true } return false } catch { return false } } /** Port → proxy path for apps (nginx strips X-Frame-Options + avoids mixed content) */ const PORT_TO_PROXY: Record = { '81': '/app/nginx-proxy-manager/', '3000': '/app/grafana/', '3001': '/app/uptime-kuma/', '8080': '/app/endurain/', '8081': '/app/lnd/', '8082': '/app/vaultwarden/', '8083': '/app/filebrowser/', '8085': '/app/nextcloud/', '8096': '/app/jellyfin/', '8123': '/app/homeassistant/', '8240': '/app/tailscale/', '8334': '/app/bitcoin-ui/', '8888': '/app/searxng/', '9000': '/app/portainer/', '9001': '/app/penpot/', '9980': '/app/onlyoffice/', '11434': '/app/ollama/', '2283': '/app/immich/', '23000': '/app/btcpay/', '2342': '/app/photoprism/', '4080': '/app/mempool/', '50002': '/app/electrs/', '8175': '/app/fedimint/', '8176': '/app/fedimint-gateway/', '3100': '/app/dwn/', '18081': '/app/nostr-rs-relay/', } /** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content. * On HTTP, direct port URLs are used — they avoid subpath routing issues * (apps' root-relative asset paths like /static/main.js break under /app/xxx/). * On HTTPS, must proxy to avoid mixed-content blocks; nginx also strips X-Frame-Options. */ function toEmbeddableUrl(url: string): string { try { const u = new URL(url) const origin = window.location.origin // External sites proxied through nginx path-based locations const extPath = EXTERNAL_PROXY_PATH[u.hostname] if (extPath) { return `${origin}${extPath}` } const proxyPath = PORT_TO_PROXY[u.port] const sameHost = u.hostname === window.location.hostname const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:' if (proxyPath && sameHost && needsProxy) { return `${origin}${proxyPath}` } } catch { /* ignore */ } return url } const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins' function getApprovedOrigins(): Set { try { const stored = localStorage.getItem(APPROVED_ORIGINS_KEY) return stored ? new Set(JSON.parse(stored) as string[]) : new Set() } catch { return new Set() } } function saveApprovedOrigin(origin: string) { const origins = getApprovedOrigins() origins.add(origin) localStorage.setItem(APPROVED_ORIGINS_KEY, JSON.stringify([...origins])) } export interface NostrConsentRequest { appName: string method: string eventKind?: number content?: string resolve: (remember: boolean) => void reject: () => void } export const useAppLauncherStore = defineStore('appLauncher', () => { const isOpen = ref(false) const url = ref('') const title = ref('') const consentRequest = ref(null) const showConsent = ref(false) let previousActiveElement: HTMLElement | null = null function open(payload: { url: string; title: string; openInNewTab?: boolean }) { if (payload.openInNewTab || mustOpenInNewTab(payload.url)) { window.open(payload.url, '_blank', 'noopener,noreferrer') return } const embeddableUrl = toEmbeddableUrl(payload.url) previousActiveElement = (document.activeElement as HTMLElement) || null url.value = embeddableUrl title.value = payload.title isOpen.value = true } function close() { const toRestore = previousActiveElement previousActiveElement = null isOpen.value = false url.value = '' title.value = '' if (toRestore && typeof toRestore.focus === 'function') { requestAnimationFrame(() => { toRestore.focus() }) } } function approveConsent(remember: boolean) { if (consentRequest.value) { consentRequest.value.resolve(remember) consentRequest.value = null } showConsent.value = false } function denyConsent() { if (consentRequest.value) { consentRequest.value.reject() consentRequest.value = null } showConsent.value = false } function requestConsent(appName: string, method: string, eventKind?: number, content?: string): Promise { return new Promise((resolve, reject) => { consentRequest.value = { appName, method, eventKind, content, resolve, reject } showConsent.value = true }) } // NIP-07 postMessage handler — responds to nostr-request from iframe apps async function handleNostrRequest(event: MessageEvent) { if (!event.data || event.data.type !== 'nostr-request') return const { id, method, params } = event.data const source = event.source as Window | null if (!source) return const origin = url.value || 'unknown' try { let result: unknown if (method === 'getPublicKey') { const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' }) result = res.nostr_pubkey } else if (method === 'signEvent') { // Check if origin is pre-approved const approved = getApprovedOrigins() if (!approved.has(origin)) { const eventKind = params?.event?.kind as number | undefined const content = params?.event?.content as string | undefined try { const remember = await requestConsent(title.value || 'App', 'signEvent', eventKind, content) if (remember) saveApprovedOrigin(origin) } catch { source.postMessage({ type: 'nostr-response', id, error: 'User denied signing request' }, '*') return } } const res = await rpcClient.call({ method: 'node.nostr-sign', params: { event: params.event } }) result = res } else if (method === 'getRelays') { result = {} } else if (method === 'nip04.encrypt') { const res = await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { pubkey: params.pubkey, plaintext: params.plaintext } }) result = res.ciphertext } else if (method === 'nip04.decrypt') { const res = await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { pubkey: params.pubkey, ciphertext: params.ciphertext } }) result = res.plaintext } else if (method === 'nip44.encrypt') { const res = await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { pubkey: params.pubkey, plaintext: params.plaintext } }) result = res.ciphertext } else if (method === 'nip44.decrypt') { const res = await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { pubkey: params.pubkey, ciphertext: params.ciphertext } }) result = res.plaintext } else { throw new Error(`Unsupported NIP-07 method: ${method}`) } source.postMessage({ type: 'nostr-response', id, result }, '*') } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' source.postMessage({ type: 'nostr-response', id, error: message }, '*') } } // Listen for NIP-07 requests only while an app is open watch(isOpen, (open) => { if (open) { window.addEventListener('message', handleNostrRequest) } else { window.removeEventListener('message', handleNostrRequest) } }) return { isOpen, url, title, open, close, showConsent, consentRequest, approveConsent, denyConsent, } })