update Tue 07/14/2026 11:38:16.92

This commit is contained in:
2026-07-14 11:38:17 +10:00
commit b2b555ef16
38 changed files with 7348 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
/* 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.');
});