181 lines
6.1 KiB
TypeScript
181 lines
6.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 on dedicated ports (strips X-Frame-Options).
|
|
* Each site gets its own port so SPAs work at root — no subpath rewriting needed. */
|
|
const EXTERNAL_PROXY_PORT: Record<string, number> = {
|
|
'botfights.net': 8901,
|
|
'484.kitchen': 8902,
|
|
'present.l484.com': 8903,
|
|
}
|
|
|
|
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 on dedicated ports
|
|
const extPort = EXTERNAL_PROXY_PORT[u.hostname]
|
|
if (extPort) {
|
|
return `${window.location.protocol}//${window.location.hostname}:${extPort}/`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|
const isOpen = ref(false)
|
|
const url = ref('')
|
|
const title = ref('')
|
|
let previousActiveElement: HTMLElement | null = null
|
|
|
|
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
|
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
|
|
// New tab: always use direct port URL so app assets load correctly
|
|
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()
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
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') {
|
|
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,
|
|
}
|
|
})
|