diff --git a/demo/README-curated-files.md b/demo/README-curated-files.md new file mode 100644 index 00000000..58593c8f --- /dev/null +++ b/demo/README-curated-files.md @@ -0,0 +1,22 @@ +# Curated demo files + +Drop real files into `demo/files/` to make them the cloud's content for **every** +demo visitor (read-only — visitors can browse, download, and "buy" them, but only +maintainers add them). This is the "private login": the only way to add files is +to commit them here, which requires repo access. + +``` +demo/files/ + Documents/whitepaper.pdf + Photos/rig.jpg + Music/track.mp3 +``` + +- Folder structure becomes the cloud's folders. +- Text files (`.md .txt .json .csv …`, < 1 MB) are inlined; everything else is + streamed from disk on download. +- If `demo/files/` is empty, the demo falls back to the built-in seeded set + (Documents/Photos/Music/Videos with sample content). + +After adding files, commit and push — CI rebuilds the `:demo` image and Portainer +redeploys. Keep the total modest (these load into the demo image). diff --git a/demo/files/.gitkeep b/demo/files/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/neode-ui/Dockerfile.backend b/neode-ui/Dockerfile.backend index 1881cc70..fde87eb3 100644 --- a/neode-ui/Dockerfile.backend +++ b/neode-ui/Dockerfile.backend @@ -14,6 +14,11 @@ RUN npm install # Copy application code COPY neode-ui/ ./ +# Sibling assets the mock backend reads relative to /app (../docker, ../demo): +# the Bitcoin UI mock shell and any curated cloud files dropped into demo/files. +COPY docker/bitcoin-ui /docker/bitcoin-ui +COPY demo/files /demo/files + # Expose port EXPOSE 5959 diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 1353f83c..92d8f723 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -36,6 +36,7 @@ const DEMO = // Find container socket: Podman (macOS/Linux) or Docker import { existsSync, readFileSync } from 'fs' +import * as fsSync from 'fs' // Report the real app version, suffixed with -demo in the public sandbox so it's // obviously the demo while still tracking whatever version the UI ships. @@ -3448,6 +3449,53 @@ const SEED_FILE_CONTENTS = { '/Documents/backup-log.json': JSON.stringify({ backups: [{ id: 'bkp-2025-03-01', timestamp: '2025-03-01T02:00:00Z', type: 'full', apps: ['bitcoin-knots', 'lnd', 'mempool'], size_mb: 2340, status: 'success' }] }, null, 2), } +// Curated real files: drop files into /demo/files// and they +// replace the seeded cloud content for every visitor (read-only — visitors can +// view/download/buy them but only maintainers add them, via git = the "private +// login"). If the folder is absent/empty the hardcoded seeds above are kept. +// Binary files are streamed from disk on demand (diskFilePaths); text is inlined. +const diskFilePaths = {} +function loadDemoDiskFiles() { + const root = path.join(__dirname, '..', 'demo', 'files') + let top + try { top = fsSync.readdirSync(root, { withFileTypes: true }) } catch { return } + const tree = { '/': [] } + const contents = {} + const TEXT_EXT = new Set(['txt', 'md', 'json', 'csv', 'log', 'yaml', 'yml', 'xml', 'conf', 'ini']) + const walk = (absDir, relDir) => { + let entries + try { entries = fsSync.readdirSync(absDir, { withFileTypes: true }) } catch { return } + tree[relDir] = tree[relDir] || [] + for (const e of entries) { + if (e.name.startsWith('.')) continue + const abs = path.join(absDir, e.name) + const rel = relDir === '/' ? `/${e.name}` : `${relDir}/${e.name}` + if (e.isDirectory()) { + tree[relDir].push({ name: e.name, path: rel, size: 0, modified: new Date().toISOString(), isDir: true, type: '' }) + walk(abs, rel) + } else { + let st; try { st = fsSync.statSync(abs) } catch { continue } + const ext = (e.name.includes('.') ? e.name.split('.').pop() : '').toLowerCase() + const type = fbType(e.name) + tree[relDir].push({ name: e.name, path: rel, size: st.size, modified: st.mtime.toISOString(), isDir: false, type }) + if (TEXT_EXT.has(ext) && st.size < 1_000_000) { + try { contents[rel] = fsSync.readFileSync(abs, 'utf-8') } catch { /* skip */ } + } else { + diskFilePaths[rel] = abs // streamed from disk by the raw handler + } + } + } + } + walk(root, '/') + if (!tree['/'].length) return // empty folder → keep the hardcoded seeds + for (const k of Object.keys(SEED_FILES)) delete SEED_FILES[k] + Object.assign(SEED_FILES, tree) + for (const k of Object.keys(SEED_FILE_CONTENTS)) delete SEED_FILE_CONTENTS[k] + Object.assign(SEED_FILE_CONTENTS, contents) + console.log(`[Demo] Loaded curated files from demo/files (${Object.keys(diskFilePaths).length} binary, ${Object.keys(contents).length} text)`) +} +loadDemoDiskFiles() + // FileBrowser UI (demo placeholder when launched directly) app.get('/app/filebrowser/', (req, res) => { res.type('html').send(` @@ -3605,6 +3653,13 @@ 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. + if (diskFilePaths[full]) { + res.type(fbContentType(fbBase(full))) + return fsSync.createReadStream(diskFilePaths[full]).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') res.type(fbContentType(fbBase(full)))