archy/neode-ui/src/stores/appLauncher.ts
Dorian 1d3a8e2050 feat: add noStrudel Nostr client with NIP-07 iframe proxy support
Added nostrudel.ninja as a web-only app in Marketplace (community category).
Configured nginx reverse proxy at /ext/nostrudel/ with NIP-07 provider
injection in both HTTP and HTTPS blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:38:22 +00:00

252 lines
8.1 KiB
TypeScript

import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
/** Apps that set X-Frame-Options or CSP frame-ancestors, blocking iframe embedding.
* Verified by checking response headers from each app container.
* These always open in a new tab. Other apps load in the iframe overlay.
*/
/** Hostnames of external sites that block iframes via X-Frame-Options or CSP.
* Sites listed here that also appear in EXTERNAL_PROXY will be proxied (not blocked).
*/
const IFRAME_BLOCKED_HOSTS: string[] = []
/** External sites proxied through nginx path-based locations (strips X-Frame-Options).
* Uses /ext/{key}/ paths on the main nginx port so it works over Tailscale too. */
const EXTERNAL_PROXY_PATH: Record<string, string> = {
'botfights.net': '/ext/botfights/',
'484.kitchen': '/ext/484-kitchen/',
'present.l484.com': '/ext/arch-presentation/',
'nostrudel.ninja': '/ext/nostrudel/',
}
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<string, string> = {
'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<string> {
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<NostrConsentRequest | null>(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<boolean> {
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<unknown>({ method: 'identity.nostr-sign', params: { event: params.event } })
result = res
} else if (method === 'getRelays') {
result = {}
} 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,
}
})