/* Newbury Exhibit v2 — server * Express static host + WebSocket sync + admin API + playlist engine. * Modes: EXHIBIT_MODE=dev -> admin open, verbose client error overlay enabled * EXHIBIT_MODE=final -> admin password-protected (ADMIN_PASSWORD env) */ const express = require('express'); const http = require('http'); const path = require('path'); const { WebSocketServer } = require('ws'); const state = require('./state'); const auth = require('./auth'); const Playlist = require('./playlist'); const PORT = process.env.PORT || 33044; const MODE = process.env.EXHIBIT_MODE || 'dev'; const app = express(); app.use(express.json({ limit: '2mb' })); // ---------- public static ---------- app.use(express.static(path.join(__dirname, '..', 'public'))); app.use('/data/ghosts.json', express.static(path.join(__dirname, '..', 'data', 'ghosts.json'))); app.use('/data/gradients.json', express.static(path.join(__dirname, '..', 'data', 'gradients.json'))); // ---------- runtime info ---------- app.get('/api/info', (req, res) => { res.json({ mode: MODE, authRequired: MODE === 'final', version: 2 }); }); // ---------- auth ---------- app.post('/api/login', (req, res) => { if (MODE !== 'final') return res.json({ token: 'dev' }); const t = auth.login(req.body && req.body.password); if (!t) return res.status(401).json({ error: 'bad password' }); res.json({ token: t }); }); function guard(req, res, next) { if (MODE !== 'final') return next(); if (auth.check(req.headers['x-exhibit-token'])) return next(); res.status(401).json({ error: 'unauthorized' }); } // ---------- scene (anchors, buildings, spawns) ---------- app.get('/api/scene', (req, res) => res.json(state.getScene())); app.put('/api/scene', guard, (req, res) => { try { res.json(state.putScene(req.body)); } catch (e) { res.status(400).json({ error: String(e.message || e) }); } }); app.post('/api/scene/reset', guard, (req, res) => res.json(state.resetToSeed())); // ---------- playlist config ---------- app.get('/api/playlist', (req, res) => res.json(playlist.getConfig())); app.put('/api/playlist', guard, (req, res) => { try { res.json(playlist.setConfig(req.body)); } catch (e) { res.status(400).json({ error: String(e.message || e) }); } }); // ---------- ghost roster ---------- app.get('/api/ghosts', (req, res) => res.json(state.getGhosts())); // ---------- model manifests (multi-part OBJ ghosts) ---------- app.get('/api/models', (req, res) => res.json(state.getModels())); app.put('/api/models', guard, (req, res) => { try { res.json(state.putModels(req.body)); } catch (e) { res.status(400).json({ error: String(e.message || e) }); } }); // ---------- client error reporting (dev mode) ---------- const clientErrors = []; app.post('/api/client-error', (req, res) => { clientErrors.push({ t: Date.now(), ...req.body }); if (clientErrors.length > 500) clientErrors.shift(); console.warn('[client-error]', JSON.stringify(req.body).slice(0, 500)); res.json({ ok: true }); }); app.get('/api/client-errors', guard, (req, res) => res.json(clientErrors)); // ---------- server + websocket ---------- const server = http.createServer(app); const wss = new WebSocketServer({ server, path: '/ws' }); function broadcast(obj) { const msg = JSON.stringify(obj); for (const c of wss.clients) if (c.readyState === 1) c.send(msg); } const playlist = new Playlist(state, broadcast); playlist.start(); wss.on('connection', (ws) => { ws.send(JSON.stringify({ type: 'scene', scene: state.getScene() })); ws.send(JSON.stringify({ type: 'active', ghosts: playlist.activeSnapshot() })); ws.on('message', (raw) => { let m; try { m = JSON.parse(raw); } catch { return; } if (m.type === 'ping') ws.send(JSON.stringify({ type: 'pong', t: m.t, server: Date.now() })); }); }); // push scene updates to viewers when admin saves state.onChange(() => broadcast({ type: 'scene', scene: state.getScene() })); server.listen(PORT, () => { console.log(`Newbury Exhibit v2 [${MODE}] on :${PORT}`); if (MODE === 'final' && !process.env.ADMIN_PASSWORD) console.warn('WARNING: final mode with no ADMIN_PASSWORD set — admin is locked out.'); });