diff --git a/.dockerignore b/.dockerignore index 32d336cb..f323aac2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,8 @@ docker/* !docker/bitcoin-ui/ !docker/electrs-ui/ +!docker/lnd-ui/ +!docker/fedimint-ui/ # Allow backend source for ISO source builds !core/ diff --git a/neode-ui/Dockerfile.backend b/neode-ui/Dockerfile.backend index 3b4af25b..f9ed0339 100644 --- a/neode-ui/Dockerfile.backend +++ b/neode-ui/Dockerfile.backend @@ -18,6 +18,8 @@ COPY neode-ui/ ./ # the Bitcoin UI mock shell and any curated cloud files dropped into demo/files. COPY docker/bitcoin-ui /docker/bitcoin-ui COPY docker/electrs-ui /docker/electrs-ui +COPY docker/lnd-ui /docker/lnd-ui +COPY docker/fedimint-ui /docker/fedimint-ui COPY demo/files /demo/files # Expose port diff --git a/neode-ui/docker/nginx-demo.conf b/neode-ui/docker/nginx-demo.conf index ba042f16..e4fda720 100644 --- a/neode-ui/docker/nginx-demo.conf +++ b/neode-ui/docker/nginx-demo.conf @@ -70,6 +70,20 @@ http { proxy_set_header X-Real-IP $remote_addr; } + # LND UI endpoints (polled by the lnd-ui shell) + location /proxy/ { + proxy_pass http://neode-backend:5959; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /lnd-connect-info { + proxy_pass http://neode-backend:5959; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + # Proxy FileBrowser API to mock backend (demo mode) location /app/filebrowser/ { client_max_body_size 10G; @@ -80,6 +94,19 @@ http { proxy_request_buffering off; } + # IndeeHub: reverse-proxy the real site same-origin and strip its + # X-Frame-Options / CSP so it can load inside the in-app iframe. + location /app/indeedhub/ { + proxy_pass https://indee.tx1138.com/; + proxy_http_version 1.1; + proxy_set_header Host indee.tx1138.com; + proxy_ssl_server_name on; + proxy_set_header Accept-Encoding ""; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_hide_header Content-Security-Policy-Report-Only; + } + # Proxy every other app UI (/app//) to the mock backend, which serves # the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the # generic "Not available in the demo" notice for the rest. diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 06e8bd45..4278613f 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -1087,65 +1087,83 @@ app.get('/electrs-status', (_req, res) => { }) }) -app.get(['/app/lnd/', '/app/lnd-ui/', '/app/archy-lnd-ui/', '/app/thunderhub/'], (_req, res) => { - res.type('html').send(demoAppShell('LND', 'Lightning Network Daemon · signet', '/assets/img/app-icons/lnd.svg', ` -
-
Node
● Synced to chain
-
Channel balance
8,250,000 sat
-
On-chain balance
2,350,000 sat
-
Active channels
4
-
Peers
11
-
Routing (30d)
1,284 sat
-
-
Channels
- - - - - -
PeerCapacityLocal / RemoteState
ACINQ5,000,0002,450,000 / 2,550,000active
Wallet of Satoshi2,000,0001,200,000 / 800,000active
Voltage10,000,0004,500,000 / 5,500,000active
Kraken3,000,0001,800,000 / 1,200,000active
-
`)) -}) +// Serve a real registry UI shell (docker//index.html), filled by the +// dummy endpoints each shell polls (defined below). This gives accurate UX. +function serveDockerUI(dir) { + return async (_req, res) => { + try { + const html = await fs.readFile(path.join(__dirname, '..', 'docker', dir, 'index.html'), 'utf8') + res.type('html').send(html) + } catch (e) { + res.status(500).type('text/plain').send(`Unable to load ${dir} mock: ${e.message}`) + } + } +} -app.get(['/app/bitcoin-core/'], (_req, res) => { - res.type('html').send(demoAppShell('Bitcoin Core', 'Full node · signet', '/assets/img/app-icons/bitcoin-core.png', ` -
-
Status
● Synced
-
Block height
902,418
-
Chain
signet
-
Connections
18
-
Mempool
12,840 txs
-
Version
v28.4.0
-
-
Peers
- - - - -
AddressTypeHeight
node1.signet…onionoutbound902,418
node2.signet…onionoutbound902,418
192.168.1.42inbound902,417
-
`)) -}) +// ── Bitcoin Core + Knots: the real bitcoin-ui shell, per-implementation status ─ +app.get('/app/bitcoin-core/', serveDockerUI('bitcoin-ui')) +app.get('/app/bitcoin-knots/', serveDockerUI('bitcoin-ui')) +function bitcoinStatusPayload(impl) { + const net = mockBitcoinNetworkInfo() + net.subversion = impl === 'knots' ? '/Satoshi:28.1.0/Knots:20250305/' : '/Satoshi:28.4.0/' + return { + ok: true, stale: false, updated_at_ms: Date.now(), error: null, + blockchain_info: mockBitcoinBlockchainInfo(), + network_info: net, + index_info: { + txindex: { synced: true, best_block_height: 902418 }, + blockfilterindex: { synced: true, best_block_height: 902418 }, + }, + zmq_notifications: [ + { type: 'pubhashblock', address: 'tcp://127.0.0.1:28332', hwm: 1000 }, + { type: 'pubrawtx', address: 'tcp://127.0.0.1:28333', hwm: 1000 }, + ], + } +} +app.get('/app/bitcoin-core/bitcoin-status', (_req, res) => res.json(bitcoinStatusPayload('core'))) +app.get('/app/bitcoin-knots/bitcoin-status', (_req, res) => res.json(bitcoinStatusPayload('knots'))) -app.get(['/app/fedimint/', '/app/fedimintd/'], (_req, res) => { - res.type('html').send(demoAppShell('Fedimint Guardian', 'Archipelago Federation · 4-of-5 consensus', '/assets/img/app-icons/fedimint.png', ` -
-
Consensus
● Online
-
Epoch
48,217
-
Guardians online
5 / 5
-
E-cash issued
12,480,000 sat
-
Lightning gateway
● Connected
-
Block sync
902,418
-
-
Guardians
- - - - - - -
GuardianPeerStatus
Guardian Alphapeer-0online
Guardian Betapeer-1online
Guardian Gammapeer-2online
Guardian Deltapeer-3online
Guardian Epsilonpeer-4online
-
`)) -}) +// ── LND: the real lnd-ui shell + the endpoints it polls (same-origin) ──────── +app.get(['/app/lnd/', '/app/lnd-ui/', '/app/archy-lnd-ui/', '/app/thunderhub/'], serveDockerUI('lnd-ui')) +function lndGetinfo() { + return { + alias: 'archipelago-lnd', identity_pubkey: '02' + randomHex(32), + num_active_channels: 4, num_inactive_channels: 0, num_pending_channels: 0, num_peers: 11, + block_height: 902418, block_hash: randomHex(32), + synced_to_chain: true, synced_to_graph: true, version: '0.18.3-beta', + chains: [{ chain: 'bitcoin', network: 'signet' }], + } +} +function lndChannels() { + const peers = [['ACINQ', 5_000_000, 2_450_000], ['Wallet of Satoshi', 2_000_000, 1_200_000], + ['Voltage', 10_000_000, 4_500_000], ['Kraken', 3_000_000, 1_800_000]] + return { + channels: peers.map(([alias, cap, local]) => ({ + active: true, remote_pubkey: '02' + randomHex(32), peer_alias: alias, + capacity: String(cap), local_balance: String(local), remote_balance: String(cap - local), + total_satoshis_sent: '12840', total_satoshis_received: '9120', private: false, + })), + } +} +app.get('/proxy/lnd/v1/getinfo', (_req, res) => res.json(lndGetinfo())) +app.get('/proxy/lnd/v1/channels', (_req, res) => res.json(lndChannels())) +app.get('/lnd-connect-info', (_req, res) => res.json({ + getinfo: lndGetinfo(), channelCount: 4, cert_base: 'demo', + grpcReachable: true, restReachable: true, + tor_onion: 'lnd' + randomHex(16) + '.onion', error: null, +})) +app.get('/api/container/logs', (_req, res) => res.json({ + logs: [ + '[INF] LND: Version 0.18.3-beta commit=v0.18.3-beta', + '[INF] CHDB: Inserting 4 channel(s) into database', + '[INF] LND: Channel(s) active; synced_to_chain=true', + '[INF] PEER: Connected to 11 peers', + '[INF] RPCS: gRPC proxy started at 0.0.0.0:8080', + ].join('\n'), +})) + +// ── Fedimint: the real fedimint-ui shell (static) ──────────────────────────── +app.get(['/app/fedimint/', '/app/fedimintd/'], serveDockerUI('fedimint-ui')) app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => { res.json({ @@ -3705,12 +3723,31 @@ app.patch('/app/filebrowser/api/resources/*', (req, res) => { // FileBrowser raw file content (text reads, blob/stream fetches) app.get('/app/filebrowser/api/raw/*', (req, res) => { const full = fbNormalize(req.params[0] || '') - // Curated binary files live on disk and stream directly. + // Curated binary files live on disk and stream directly, with HTTP Range + // support so audio/video can seek and play in the browser. if (diskFilePaths[full]) { + const abs = diskFilePaths[full] + let stat + try { stat = fsSync.statSync(abs) } catch { return res.status(404).send('File not found') } res.type(fbContentType(fbBase(full))) - return fsSync.createReadStream(diskFilePaths[full]).on('error', () => { - if (!res.headersSent) res.status(404).send('File not found') - }).pipe(res) + res.setHeader('Accept-Ranges', 'bytes') + const range = req.headers.range + if (range) { + const m = /bytes=(\d*)-(\d*)/.exec(range) + let start = m && m[1] ? parseInt(m[1], 10) : 0 + let end = m && m[2] ? parseInt(m[2], 10) : stat.size - 1 + if (isNaN(start) || start < 0) start = 0 + if (isNaN(end) || end >= stat.size) end = stat.size - 1 + if (start > end) { res.status(416).setHeader('Content-Range', `bytes */${stat.size}`); return res.end() } + res.status(206) + res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`) + res.setHeader('Content-Length', end - start + 1) + return fsSync.createReadStream(abs, { start, end }) + .on('error', () => { if (!res.headersSent) res.status(404).end() }).pipe(res) + } + res.setHeader('Content-Length', stat.size) + return fsSync.createReadStream(abs) + .on('error', () => { if (!res.headersSent) res.status(404).send('File not found') }).pipe(res) } const content = currentStore().files.contents[full] if (content === undefined) return res.status(404).send('File not found') diff --git a/neode-ui/src/composables/useDemoIntro.ts b/neode-ui/src/composables/useDemoIntro.ts index eeb5c022..393b3b57 100644 --- a/neode-ui/src/composables/useDemoIntro.ts +++ b/neode-ui/src/composables/useDemoIntro.ts @@ -53,15 +53,16 @@ export function clearDemoIntroSeen(): void { // external site). Everything else shows "No demo" on a disabled install button // and is not launchable. const DEMO_EXTERNAL_URLS: Record = { - // Real, public sites loaded in the in-app iframe. - indeedhub: 'https://indee.tx1138.com/', + // Real, public sites opened in a NEW TAB (they block iframing). mempool: 'https://mempool.space/testnet', 'mempool-web': 'https://mempool.space/testnet', } -// Apps with a same-origin mock UI served by the demo backend. +// Apps with a same-origin URL loaded in the in-app iframe — either a mock UI +// served by the demo backend, or a header-stripping reverse-proxy (IndeeHub). const DEMO_MOCK_UI: Record = { - 'bitcoin-knots': '/app/bitcoin-ui/', + indeedhub: '/app/indeedhub/', + 'bitcoin-knots': '/app/bitcoin-knots/', 'bitcoin-core': '/app/bitcoin-core/', bitcoin: '/app/bitcoin-core/', 'bitcoin-ui': '/app/bitcoin-ui/', @@ -78,11 +79,11 @@ const DEMO_MOCK_UI: Record = { } /** - * Whether a demo app opens in a new tab. Nothing does now — both real external - * sites (mempool.space, indee.tx1138.com) load in the in-app iframe. + * Whether a demo app opens in a new tab. Mempool.space sets X-Frame-Options and + * cannot be iframed, so it opens in a new tab; IndeeHub still loads in-iframe. */ -export function isDemoExternal(_appId: string): boolean { - return false +export function isDemoExternal(appId: string): boolean { + return appId === 'mempool' || appId === 'mempool-web' } /** Can this app be launched/installed in the demo? */ diff --git a/neode-ui/vite.config.ts b/neode-ui/vite.config.ts index 251bfc0d..57c98183 100644 --- a/neode-ui/vite.config.ts +++ b/neode-ui/vite.config.ts @@ -157,7 +157,11 @@ export default defineConfig({ '/app/lnd': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, '/app/fedimint': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, '/app/bitcoin-core': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, + '/app/bitcoin-knots': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, '/electrs-status': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, + '/proxy': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, + '/lnd-connect-info': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, + '/app/indeedhub': { target: 'https://indee.tx1138.com', changeOrigin: true, secure: false, rewrite: (p: string) => p.replace(/^\/app\/indeedhub/, '') }, // Serve the node's deployed AIUI same-origin like production (set VITE_AIUI_URL=/aiui/) '/aiui': { target: process.env.AIUI_PROXY_TARGET || 'http://127.0.0.1:80',