feat: bitcoin-ui CSS fix, HTTPS proxy support, deploy script improvements

Bitcoin UI:
- Replace cdn.tailwindcss.com with locally bundled tailwind.css (CSP blocks external scripts)
- Make all asset paths relative for nginx proxy compatibility
- Add bitcoin-ui build/deploy to deploy-to-target.sh (was missing entirely)
- Use --network host (bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332)

HTTPS mixed content fix:
- Add HTTPS_PROXY_PATHS in AppSession.vue — when parent page is HTTPS,
  iframe loads through nginx proxy instead of direct HTTP port
- Prevents browser blocking HTTP iframes inside HTTPS pages
- All Tailscale servers use HTTPS, this was breaking all app iframes

Deploy & first-boot improvements:
- first-boot-containers.sh auto-detects disk size for pruning vs txindex
- first-boot-containers.sh checks fallback source path for UI containers
- Added mempool-electrs to APP_PORTS mapping
- ElectrumX container creation in first-boot
- Podman doctor/fix/uptime skills added

Also includes: session persistence, identity management, LND transactions,
ElectrumX status UI, nostr-provider improvements, Web5 enhancements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-16 12:58:35 +00:00
parent 4e54b8bd4d
commit 367b483a72
49 changed files with 6180 additions and 495 deletions

View File

@@ -3,97 +3,59 @@ import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import router from '@/router'
/** 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. */
/** Legacy: these used to open in new tabs. Now all apps go through AppSession. */
const IFRAME_BLOCKED_HOSTS: string[] = []
/** 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<string, string> = {}
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
const NEW_TAB_PORTS = new Set([
'23000', // BTCPay — X-Frame-Options: DENY
'3000', // Grafana — X-Frame-Options: deny
'2342', // PhotoPrism — X-Frame-Options: DENY
'8123', // Home Assistant — X-Frame-Options: SAMEORIGIN
'8082', // Vaultwarden — X-Frame-Options: SAMEORIGIN
'8085', // Nextcloud — X-Frame-Options: SAMEORIGIN
'3001', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
'9001', // Penpot — not reachable
// IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab
])
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
return NEW_TAB_PORTS.has(u.port)
} 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/',
'8175': '/app/fedimint/',
'8176': '/app/fedimint-gateway/',
'3100': '/app/dwn/',
'18081': '/app/nostr-rs-relay/',
'7777': '/app/indeedhub/',
/** Port → app ID for resolving URLs to AppSession routes */
const PORT_TO_APP_ID: Record<string, string> = {
'81': 'nginx-proxy-manager',
'3000': 'grafana',
'3001': 'uptime-kuma',
'8080': 'endurain',
'8081': 'lnd',
'8082': 'vaultwarden',
'8083': 'filebrowser',
'8085': 'nextcloud',
'8096': 'jellyfin',
'8123': 'homeassistant',
'8240': 'tailscale',
'8334': 'bitcoin-knots',
'8888': 'searxng',
'9000': 'portainer',
'9001': 'penpot',
'9980': 'onlyoffice',
'11434': 'ollama',
'2283': 'immich',
'23000': 'btcpay-server',
'2342': 'photoprism',
'4080': 'mempool',
'8175': 'fedimint',
'8176': 'fedimint-gateway',
'3100': 'dwn',
'18081': 'nostr-rs-relay',
'7777': 'indeedhub',
'50002': 'electrumx',
}
/** 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'
@@ -121,6 +83,8 @@ export interface NostrConsentRequest {
reject: () => void
}
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
export const useAppLauncherStore = defineStore('appLauncher', () => {
const isOpen = ref(false)
const url = ref('')
@@ -129,9 +93,22 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const showConsent = ref(false)
let previousActiveElement: HTMLElement | null = null
/** Open app in full-page session view (preferred — no iframe subpath issues) */
/** Active app in panel mode (store-based, no route change) */
const panelAppId = ref<string | null>(null)
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
function openSession(appId: string) {
router.push({ name: 'app-session', params: { appId } })
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
if (mode === 'panel') {
panelAppId.value = appId
} else {
panelAppId.value = null
router.push({ name: 'app-session', params: { appId } })
}
}
function closePanel() {
panelAppId.value = null
}
/** Legacy: open app in iframe overlay (kept for backward compat) */
@@ -142,13 +119,13 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
openSession(resolvedId)
return
}
// Apps that block iframes — open directly in new tab
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
url.value = payload.url
title.value = payload.title
isOpen.value = true
}
@@ -158,11 +135,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
try {
const u = new URL(urlStr)
// Check port-based apps
for (const [port, proxyPath] of Object.entries(PORT_TO_PROXY)) {
if (u.port === port) {
return proxyPath.replace('/app/', '').replace(/\/$/, '')
}
}
const appId = PORT_TO_APP_ID[u.port]
if (appId) return appId
// Check external URLs
const EXTERNAL_APP_HOSTS: Record<string, string> = {
'botfights.net': 'botfights',
@@ -326,6 +300,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
open,
openSession,
close,
closePanel,
panelAppId,
showConsent,
consentRequest,
approveConsent,

View File

@@ -16,6 +16,17 @@ export interface BundledApp {
lan_address?: string // Runtime launch URL from backend
}
/** Map bundled app ID to the podman container name(s) used for status matching.
* Some apps have a different container name than their app ID, or use a
* separate UI container (e.g., bitcoin-knots node → bitcoin-ui web container). */
const CONTAINER_NAME_MAP: Record<string, string[]> = {
'bitcoin-knots': ['bitcoin-knots', 'bitcoin-ui'],
'lnd': ['lnd', 'archy-lnd-ui'],
'btcpay-server': ['btcpay-server'],
'mempool': ['archy-mempool-web'],
'electrumx': ['archy-electrs-ui', 'electrumx', 'mempool-electrs'],
}
export const BUNDLED_APPS: BundledApp[] = [
{
id: 'bitcoin-knots',
@@ -23,7 +34,7 @@ export const BUNDLED_APPS: BundledApp[] = [
image: 'localhost/bitcoinknots/bitcoin:29',
description: 'Full Bitcoin node with additional features',
icon: '₿',
ports: [{ host: 8332, container: 8332 }, { host: 8333, container: 8333 }],
ports: [{ host: 8334, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/bitcoin', container: '/data' }],
category: 'bitcoin',
},
@@ -33,7 +44,7 @@ export const BUNDLED_APPS: BundledApp[] = [
image: 'docker.io/lightninglabs/lnd:v0.18.4-beta',
description: 'Lightning Network Daemon for fast Bitcoin payments',
icon: '⚡',
ports: [{ host: 9735, container: 9735 }, { host: 10009, container: 10009 }],
ports: [{ host: 8081, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/lnd', container: '/root/.lnd' }],
category: 'lightning',
},
@@ -48,12 +59,12 @@ export const BUNDLED_APPS: BundledApp[] = [
category: 'home',
},
{
id: 'btcpayserver',
id: 'btcpay-server',
name: 'BTCPay Server',
image: 'docker.io/btcpayserver/btcpayserver:latest',
description: 'Self-hosted Bitcoin payment processor',
icon: '💳',
ports: [{ host: 23000, container: 23000 }],
ports: [{ host: 23000, container: 49392 }],
volumes: [{ host: '/var/lib/archipelago/btcpay', container: '/datadir' }],
category: 'bitcoin',
},
@@ -63,30 +74,10 @@ export const BUNDLED_APPS: BundledApp[] = [
image: 'docker.io/mempool/frontend:latest',
description: 'Bitcoin blockchain and mempool visualizer',
icon: '🔍',
ports: [{ host: 8080, container: 8080 }],
ports: [{ host: 4080, container: 8080 }],
volumes: [{ host: '/var/lib/archipelago/mempool', container: '/data' }],
category: 'bitcoin',
},
{
id: 'nostr-rs-relay',
name: 'Nostr Relay (RS)',
image: 'docker.io/scsibug/nostr-rs-relay:latest',
description: 'Rust-based Nostr relay for decentralized social',
icon: '🦩',
ports: [{ host: 8008, container: 8080 }],
volumes: [{ host: '/var/lib/archipelago/nostr-rs', container: '/usr/src/app/db' }],
category: 'other',
},
{
id: 'strfry',
name: 'Strfry Relay',
image: 'docker.io/hoytech/strfry:latest',
description: 'High-performance Nostr relay',
icon: '⚡',
ports: [{ host: 7777, container: 7777 }],
volumes: [{ host: '/var/lib/archipelago/strfry', container: '/app/strfry-db' }],
category: 'other',
},
{
id: 'tailscale',
name: 'Tailscale VPN',
@@ -124,14 +115,18 @@ export const useContainerStore = defineStore('container', () => {
healthStatus.value[appId] || 'unknown'
)
// Get container for a bundled app (matches by name)
// Get container for a bundled app (matches by explicit name map, then by exact name)
const getContainerForApp = computed(() => (appId: string) => {
return containers.value.find(c =>
c.name === appId ||
c.name.includes(appId) ||
c.name === `archipelago-${appId}` ||
c.name === `archipelago-${appId}-dev`
)
const nameList = CONTAINER_NAME_MAP[appId]
if (nameList) {
// Try each known container name in priority order
for (const n of nameList) {
const found = containers.value.find(c => c.name === n)
if (found) return found
}
}
// Fallback: exact match on app ID
return containers.value.find(c => c.name === appId)
})
// Check if an app is currently loading (starting/stopping)