feat(demo): curated cloud files drop-in + fix backend asset copies

- demo/files/<Folder>/<file> becomes the cloud's content for every visitor
  (read-only; "private login" = git/repo access). Text inlined, binaries streamed
  from disk; empty folder falls back to the built-in seeded set.
- Dockerfile.backend now copies docker/bitcoin-ui and demo/files into the image
  (they live outside neode-ui/) — this also fixes the Bitcoin UI mock, which the
  backend reads from /docker/bitcoin-ui and was previously absent in the container.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-22 11:11:40 -04:00
parent df2ae3d7d8
commit 79c3769542
4 changed files with 82 additions and 0 deletions

View File

@ -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).

0
demo/files/.gitkeep Normal file
View File

View File

@ -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

View File

@ -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 <repo>/demo/files/<Folder>/<file> 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(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@ -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)))