142 lines
5.5 KiB
JavaScript
142 lines
5.5 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()));
|
|
|
|
// ---------- 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.');
|
|
});
|