Files
newbury-exhibit-v2/server/server.js
T

163 lines
6.4 KiB
JavaScript

/* 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 { Assets } = require('./uploads');
const PORT = process.env.PORT || 33044;
const MODE = process.env.EXHIBIT_MODE || 'dev';
const app = express();
app.use(express.json({ limit: '20mb' })); // base64 asset uploads
// ---------- 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')));
// ---------- uploaded assets (models + textures) ----------
const assets = new Assets(state.liveDir);
app.use('/assets', express.static(path.join(state.liveDir, 'assets'), { maxAge: '1h' }));
app.get('/api/assets', (req, res) => res.json(assets.list()));
app.post('/api/assets', guard, (req, res) => {
try { res.json(assets.save(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
app.delete('/api/assets/:id', guard, (req, res) => {
try { res.json(assets.remove(req.params.id)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- landing page ----------
app.get('/api/landing', (req, res) => res.json(state.getLanding()));
app.put('/api/landing', guard, (req, res) => {
try { res.json(state.putLanding(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- character overrides ----------
app.get('/api/characters', (req, res) => res.json(state.getCharacters()));
app.put('/api/characters', guard, (req, res) => {
try { res.json(state.putCharacters(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- 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()));
// ---------- named layouts (scene + its playlist) ----------
app.get('/api/layouts', (req, res) => res.json(state.listLayouts()));
app.post('/api/layouts', guard, (req, res) => {
try {
const name = (req.body && req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'name required' });
res.json(state.saveLayout(name, playlist.getConfig()));
} catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
app.post('/api/layouts/:slug/load', guard, (req, res) => {
try {
const { scene, playlist: pl } = state.loadLayout(req.params.slug);
if (pl) playlist.reloadFromState();
res.json({ scene, layouts: state.listLayouts() });
} catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
app.delete('/api/layouts/:slug', guard, (req, res) => {
try { res.json(state.deleteLayout(req.params.slug)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- 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: 'characters', characters: state.getCharacters() }));
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() });
broadcast({ type: 'characters', characters: state.getCharacters() });
});
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.');
});