2026-08-30 14:50:37 +00:00
|
|
|
#!/usr/bin/env node
|
|
|
|
|
/**
|
|
|
|
|
* ProdDash — modular production dashboard for Waters Church.
|
|
|
|
|
*
|
|
|
|
|
* Shell server: serves the dashboard client from public/, discovers modules
|
|
|
|
|
* in modules/, mounts each enabled module's server routes under
|
2026-09-08 14:42:15 +00:00
|
|
|
* /api/modules/<id>/, and stores server-side module config + named layouts
|
|
|
|
|
* in a per-machine data directory (see DATA_DIR below) so that updating the
|
|
|
|
|
* checkout never loses them.
|
2026-08-30 14:50:37 +00:00
|
|
|
*
|
|
|
|
|
* Zero dependencies — Node built-ins only (Node 18+ for global fetch).
|
2026-09-08 14:42:15 +00:00
|
|
|
* Configure via config/proddash.json (defaults, in the repo), an optional
|
|
|
|
|
* proddash.json in the data directory (this machine's overrides), or env vars:
|
|
|
|
|
* PORT (default: value in proddash.json, else 24500)
|
|
|
|
|
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
|
|
|
|
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
2026-08-30 14:50:37 +00:00
|
|
|
*/
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
const http = require('http');
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const os = require('os');
|
2026-08-30 15:16:09 +00:00
|
|
|
const crypto = require('crypto');
|
2026-08-30 14:50:37 +00:00
|
|
|
|
|
|
|
|
const ROOT = __dirname;
|
|
|
|
|
const PUBLIC_DIR = path.join(ROOT, 'public');
|
2026-08-30 14:55:25 +00:00
|
|
|
const MODULES_DIR = path.join(ROOT, 'modules');
|
2026-09-08 14:42:15 +00:00
|
|
|
/** Checked-in defaults (proddash.json) — and where runtime state used to live. */
|
|
|
|
|
const REPO_CONFIG_DIR = path.join(ROOT, 'config');
|
|
|
|
|
|
|
|
|
|
/* ── data directory ─────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Everything the app writes — module config (connection settings, API keys,
|
|
|
|
|
* enabled flags) and named layouts — lives in a per-machine data directory
|
|
|
|
|
* OUTSIDE the checkout. Pulling, re-cloning or resetting the app folder from
|
|
|
|
|
* main therefore never touches it, and nothing has to be re-entered in /admin.
|
|
|
|
|
*
|
|
|
|
|
* PRODDASH_DATA_DIR overrides the location. A relative path resolves
|
|
|
|
|
* against the app folder ("config" = the old in-repo spot).
|
|
|
|
|
* default Windows %APPDATA%\ProdDash
|
|
|
|
|
* macOS ~/Library/Application Support/ProdDash
|
|
|
|
|
* other $XDG_CONFIG_HOME/proddash, else ~/.config/proddash
|
|
|
|
|
*
|
|
|
|
|
* If the directory cannot be created or written, the server says so and
|
|
|
|
|
* falls back to the in-repo config/ folder rather than refusing to start.
|
|
|
|
|
*/
|
|
|
|
|
function defaultDataDir() {
|
|
|
|
|
const home = os.homedir();
|
|
|
|
|
if (process.platform === 'win32') {
|
|
|
|
|
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
|
|
|
|
|
}
|
|
|
|
|
if (process.platform === 'darwin') {
|
|
|
|
|
return path.join(home, 'Library', 'Application Support', 'ProdDash');
|
|
|
|
|
}
|
|
|
|
|
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveDataDir() {
|
|
|
|
|
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
|
|
|
|
const dir = fromEnv ? path.resolve(ROOT, fromEnv) : defaultDataDir();
|
|
|
|
|
try {
|
|
|
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
|
|
|
fs.accessSync(dir, fs.constants.W_OK);
|
|
|
|
|
return dir;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn(`[proddash] cannot use data directory ${dir} (${err.message}) — falling back to ${REPO_CONFIG_DIR}`);
|
|
|
|
|
return REPO_CONFIG_DIR;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DATA_DIR = resolveDataDir();
|
|
|
|
|
const MODULES_CONFIG_PATH = path.join(DATA_DIR, 'modules.json');
|
|
|
|
|
const LAYOUTS_DIR = path.join(DATA_DIR, 'layouts');
|
2026-08-30 14:50:37 +00:00
|
|
|
|
2026-08-30 14:55:25 +00:00
|
|
|
/* ── config files ───────────────────────────────────────────────────── */
|
2026-08-30 14:50:37 +00:00
|
|
|
|
|
|
|
|
function readJson(file, fallback) {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
|
|
|
} catch {
|
|
|
|
|
return fallback;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 14:55:25 +00:00
|
|
|
function writeJson(file, value) {
|
|
|
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
|
|
|
const tmp = file + '.tmp';
|
|
|
|
|
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n');
|
|
|
|
|
fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 14:42:15 +00:00
|
|
|
/**
|
|
|
|
|
* One-time move-in. Earlier versions kept modules.json and layouts/ inside the
|
|
|
|
|
* repo's config/ folder; if the data directory has none yet and those files
|
|
|
|
|
* exist, copy them across (the originals are left alone).
|
|
|
|
|
*/
|
|
|
|
|
function migrateLegacyState() {
|
|
|
|
|
if (DATA_DIR === REPO_CONFIG_DIR) return;
|
|
|
|
|
const moved = [];
|
|
|
|
|
const oldModules = path.join(REPO_CONFIG_DIR, 'modules.json');
|
|
|
|
|
if (!fs.existsSync(MODULES_CONFIG_PATH) && fs.existsSync(oldModules)) {
|
|
|
|
|
try {
|
|
|
|
|
fs.copyFileSync(oldModules, MODULES_CONFIG_PATH);
|
|
|
|
|
moved.push('modules.json');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn(`[proddash] could not copy ${oldModules} into the data directory: ${err.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const oldLayouts = path.join(REPO_CONFIG_DIR, 'layouts');
|
|
|
|
|
let oldFiles = [];
|
|
|
|
|
try {
|
|
|
|
|
oldFiles = fs.readdirSync(oldLayouts).filter((f) => f.endsWith('.json'));
|
|
|
|
|
} catch { /* none to move */ }
|
|
|
|
|
let haveLayouts = false;
|
|
|
|
|
try {
|
|
|
|
|
haveLayouts = fs.readdirSync(LAYOUTS_DIR).some((f) => f.endsWith('.json'));
|
|
|
|
|
} catch { /* no layouts dir yet */ }
|
|
|
|
|
if (oldFiles.length && !haveLayouts) {
|
|
|
|
|
try {
|
|
|
|
|
fs.mkdirSync(LAYOUTS_DIR, { recursive: true });
|
|
|
|
|
for (const f of oldFiles) fs.copyFileSync(path.join(oldLayouts, f), path.join(LAYOUTS_DIR, f));
|
|
|
|
|
moved.push(`${oldFiles.length} layout${oldFiles.length === 1 ? '' : 's'}`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn(`[proddash] could not copy layouts into the data directory: ${err.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (moved.length) {
|
|
|
|
|
console.log(`[proddash] moved ${moved.join(' and ')} from ${REPO_CONFIG_DIR} into ${DATA_DIR}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
migrateLegacyState();
|
|
|
|
|
|
|
|
|
|
/** Shell settings: checked-in defaults, then this machine's overrides on top. */
|
|
|
|
|
const shellConfig = {
|
|
|
|
|
...readJson(path.join(REPO_CONFIG_DIR, 'proddash.json'), {}),
|
|
|
|
|
...readJson(path.join(DATA_DIR, 'proddash.json'), {}),
|
|
|
|
|
};
|
2026-08-30 14:50:37 +00:00
|
|
|
const PORT = Number(process.env.PORT || shellConfig.port || 24500);
|
|
|
|
|
|
2026-08-30 14:55:25 +00:00
|
|
|
/** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */
|
|
|
|
|
let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
|
|
|
|
|
|
|
|
|
|
/* ── module discovery ───────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
/** @type {Map<string, object>} module id -> manifest (with .dir added) */
|
|
|
|
|
const manifests = new Map();
|
|
|
|
|
|
|
|
|
|
function discoverModules() {
|
|
|
|
|
manifests.clear();
|
|
|
|
|
let dirs = [];
|
|
|
|
|
try {
|
|
|
|
|
dirs = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
|
|
|
|
|
.filter((d) => d.isDirectory())
|
|
|
|
|
.map((d) => d.name);
|
|
|
|
|
} catch {
|
|
|
|
|
return; // no modules/ folder yet — the shell still runs
|
|
|
|
|
}
|
|
|
|
|
for (const dir of dirs) {
|
|
|
|
|
const manifestPath = path.join(MODULES_DIR, dir, 'module.json');
|
|
|
|
|
const man = readJson(manifestPath, null);
|
|
|
|
|
if (!man || typeof man !== 'object') {
|
|
|
|
|
if (fs.existsSync(manifestPath)) console.warn(`[proddash] ${dir}/module.json is invalid — skipped`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (man.id && man.id !== dir) {
|
|
|
|
|
console.warn(`[proddash] module "${dir}": manifest id "${man.id}" must match its folder name — skipped`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
man.id = dir;
|
|
|
|
|
if (!man.client) {
|
|
|
|
|
console.warn(`[proddash] module "${dir}": manifest has no "client" entry — skipped`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
man.dir = path.join(MODULES_DIR, dir);
|
|
|
|
|
manifests.set(dir, man);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function moduleState(id) {
|
|
|
|
|
const s = modulesConfig[id];
|
|
|
|
|
return s && typeof s === 'object' ? s : {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isEnabled(id) {
|
|
|
|
|
return moduleState(id).enabled !== false; // newly dropped modules default to enabled
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Admin config for a module: schema defaults overlaid with stored values. */
|
|
|
|
|
function effectiveConfig(id) {
|
|
|
|
|
const man = manifests.get(id);
|
|
|
|
|
const out = {};
|
|
|
|
|
for (const [key, spec] of Object.entries(man?.configSchema || {})) {
|
|
|
|
|
if (spec && 'default' in spec) out[key] = spec.default;
|
|
|
|
|
}
|
|
|
|
|
const stored = moduleState(id).config;
|
|
|
|
|
if (stored && typeof stored === 'object') Object.assign(out, stored);
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Config as sent to browsers: password-typed fields never leave the server. */
|
|
|
|
|
function clientConfig(id) {
|
|
|
|
|
const man = manifests.get(id);
|
|
|
|
|
const cfg = effectiveConfig(id);
|
|
|
|
|
const out = {};
|
|
|
|
|
for (const [key, value] of Object.entries(cfg)) {
|
|
|
|
|
if (man?.configSchema?.[key]?.type === 'password') continue;
|
|
|
|
|
out[key] = value;
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── module server mounting ─────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A module's optional server.js exports:
|
|
|
|
|
* init({ config, log }) -> handle with stop() (and optionally health())
|
|
|
|
|
* routes({ config, log }) -> { 'GET /state': handler, 'GET /stream': handler,
|
|
|
|
|
* 'GET /proxy/*': handler, '* /any/*': handler }
|
2026-08-31 17:53:06 +00:00
|
|
|
* tiles({ config, log }) -> optional, sync or async: the tiles this module
|
|
|
|
|
* presents in the Add-tile picker, e.g.
|
|
|
|
|
* [{ id, name, description, settings, minSize,
|
|
|
|
|
* defaultSize }]. Called on every picker load,
|
|
|
|
|
* so the list may be dynamic (discovered timers,
|
|
|
|
|
* admin-configured pages). Omitted or empty →
|
|
|
|
|
* the module appears once, as before.
|
2026-08-30 14:55:25 +00:00
|
|
|
* Routes are mounted under /api/modules/<id>/. A trailing "/*" makes a prefix
|
|
|
|
|
* route; the matched remainder is passed as req.wildcard (req.search carries
|
|
|
|
|
* the query string). init() runs before routes(), so routes can close over
|
|
|
|
|
* state init created.
|
|
|
|
|
*/
|
|
|
|
|
const mounted = new Map(); // id -> { routes: [...], handle, error }
|
|
|
|
|
|
|
|
|
|
function makeLog(id) {
|
|
|
|
|
return (...args) => console.log(`[${id}]`, ...args);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseRouteTable(table) {
|
|
|
|
|
const routes = [];
|
|
|
|
|
for (const [key, handler] of Object.entries(table || {})) {
|
|
|
|
|
if (typeof handler !== 'function') continue;
|
|
|
|
|
const sp = key.indexOf(' ');
|
|
|
|
|
if (sp < 0) continue;
|
|
|
|
|
const method = key.slice(0, sp).toUpperCase();
|
|
|
|
|
let route = key.slice(sp + 1).trim();
|
|
|
|
|
let wildcard = false;
|
|
|
|
|
if (route.endsWith('/*')) {
|
|
|
|
|
wildcard = true;
|
|
|
|
|
route = route.slice(0, -2) || '';
|
|
|
|
|
}
|
|
|
|
|
if (!route.startsWith('/')) route = '/' + route;
|
|
|
|
|
routes.push({ method, path: route, wildcard, handler });
|
|
|
|
|
}
|
|
|
|
|
return routes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mountModule(id) {
|
|
|
|
|
const man = manifests.get(id);
|
|
|
|
|
if (!man) return;
|
|
|
|
|
const entry = { routes: [], handle: null, error: '' };
|
|
|
|
|
mounted.set(id, entry);
|
|
|
|
|
if (!man.server) return;
|
|
|
|
|
const log = makeLog(id);
|
|
|
|
|
try {
|
|
|
|
|
const serverPath = path.join(man.dir, man.server);
|
|
|
|
|
// Re-require fresh config on every (re)mount; the module code itself stays cached.
|
|
|
|
|
const mod = require(serverPath);
|
|
|
|
|
const config = effectiveConfig(id);
|
|
|
|
|
if (typeof mod.init === 'function') {
|
|
|
|
|
entry.handle = mod.init({ config, log }) || null;
|
|
|
|
|
}
|
|
|
|
|
if (typeof mod.routes === 'function') {
|
|
|
|
|
entry.routes = parseRouteTable(mod.routes({ config, log }));
|
|
|
|
|
}
|
2026-08-31 17:53:06 +00:00
|
|
|
if (typeof mod.tiles === 'function') {
|
|
|
|
|
entry.tiles = () => mod.tiles({ config, log });
|
|
|
|
|
}
|
2026-08-30 14:55:25 +00:00
|
|
|
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
entry.error = err instanceof Error ? err.message : String(err);
|
|
|
|
|
console.error(`[proddash] module "${id}" failed to mount:`, err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function unmountModule(id) {
|
|
|
|
|
const entry = mounted.get(id);
|
|
|
|
|
if (!entry) return;
|
|
|
|
|
try {
|
|
|
|
|
entry.handle?.stop?.();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[proddash] module "${id}" failed to stop cleanly:`, err);
|
|
|
|
|
}
|
|
|
|
|
mounted.delete(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Re-init a module after its config changed (admin page). */
|
|
|
|
|
function remountModule(id) {
|
|
|
|
|
unmountModule(id);
|
|
|
|
|
if (manifests.has(id) && isEnabled(id)) mountModule(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mountAllModules() {
|
|
|
|
|
for (const id of manifests.keys()) {
|
|
|
|
|
if (isEnabled(id)) mountModule(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Health of a mounted module, as reported by its own handle.health(). */
|
|
|
|
|
function moduleHealth(id) {
|
|
|
|
|
const entry = mounted.get(id);
|
|
|
|
|
if (!entry) return null;
|
|
|
|
|
if (entry.error) return { status: 'error', message: entry.error };
|
|
|
|
|
try {
|
|
|
|
|
const h = entry.handle?.health?.();
|
|
|
|
|
if (h && typeof h === 'object') return { status: String(h.status || 'ok'), message: String(h.message || '') };
|
|
|
|
|
} catch { /* a bad health() must not break the admin page */ }
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── shell API helpers ──────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
function sendJson(res, status, body) {
|
|
|
|
|
res.writeHead(status, {
|
|
|
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
|
|
|
'Cache-Control': 'no-store',
|
|
|
|
|
});
|
|
|
|
|
res.end(JSON.stringify(body));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readBody(req, limitBytes = 512 * 1024) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
let data = '';
|
|
|
|
|
let size = 0;
|
|
|
|
|
req.on('data', (chunk) => {
|
|
|
|
|
size += chunk.length;
|
|
|
|
|
if (size > limitBytes) {
|
|
|
|
|
reject(new Error('Request body too large.'));
|
|
|
|
|
req.destroy();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
data += chunk;
|
|
|
|
|
});
|
|
|
|
|
req.on('end', () => resolve(data));
|
|
|
|
|
req.on('error', reject);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 17:53:06 +00:00
|
|
|
/** One entry of a module's tile list, with everything untrusted dropped. */
|
|
|
|
|
function sanitizeTileEntry(raw) {
|
|
|
|
|
if (!raw || typeof raw !== 'object' || !raw.id || !raw.name) return null;
|
|
|
|
|
const out = {
|
|
|
|
|
id: String(raw.id),
|
|
|
|
|
name: String(raw.name),
|
|
|
|
|
description: raw.description ? String(raw.description) : '',
|
|
|
|
|
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {},
|
|
|
|
|
};
|
|
|
|
|
for (const key of ['minSize', 'defaultSize']) {
|
|
|
|
|
const size = raw[key];
|
|
|
|
|
if (size && Number(size.w) > 0 && Number(size.h) > 0) {
|
|
|
|
|
out[key] = { w: Number(size.w), h: Number(size.h) };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The tiles a module presents in the picker. A module's tiles() runs on every
|
|
|
|
|
* picker load so the list can be live data — but a hung or throwing module
|
|
|
|
|
* must never stall the picker, so it gets a short deadline and any failure
|
|
|
|
|
* falls back to the classic single entry (empty list).
|
|
|
|
|
*/
|
|
|
|
|
async function moduleTiles(id) {
|
|
|
|
|
const man = manifests.get(id);
|
|
|
|
|
const entry = mounted.get(id);
|
|
|
|
|
let list = Array.isArray(man.tiles) ? man.tiles : []; // static, for client-only modules
|
|
|
|
|
if (entry?.tiles) {
|
|
|
|
|
try {
|
|
|
|
|
const dynamic = await Promise.race([
|
|
|
|
|
Promise.resolve(entry.tiles()),
|
|
|
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('tiles() timed out')), 2000).unref()),
|
|
|
|
|
]);
|
|
|
|
|
if (Array.isArray(dynamic)) list = dynamic;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[proddash] module "${id}" tiles() failed:`, err?.message || err);
|
|
|
|
|
list = [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return list.map(sanitizeTileEntry).filter(Boolean).slice(0, 100);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 14:55:25 +00:00
|
|
|
/** Manifest as sent to the dashboard client (enabled modules only). */
|
2026-08-31 17:53:06 +00:00
|
|
|
async function clientManifest(id) {
|
2026-08-30 14:55:25 +00:00
|
|
|
const man = manifests.get(id);
|
|
|
|
|
return {
|
|
|
|
|
id: man.id,
|
|
|
|
|
name: String(man.name || man.id),
|
|
|
|
|
version: String(man.version || ''),
|
|
|
|
|
description: String(man.description || ''),
|
|
|
|
|
client: String(man.client),
|
|
|
|
|
style: man.style ? String(man.style) : '',
|
|
|
|
|
minSize: man.minSize || { w: 1, h: 1 },
|
|
|
|
|
defaultSize: man.defaultSize || { w: 4, h: 3 },
|
|
|
|
|
instanceSchema: man.instanceSchema || {},
|
|
|
|
|
hasServer: Boolean(man.server),
|
|
|
|
|
config: clientConfig(id),
|
2026-08-31 17:53:06 +00:00
|
|
|
tiles: await moduleTiles(id),
|
2026-08-30 14:55:25 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 15:16:09 +00:00
|
|
|
/* ── shell events (SSE to every open dashboard/admin page) ──────────── */
|
|
|
|
|
|
|
|
|
|
const shellStreams = new Set();
|
|
|
|
|
|
|
|
|
|
function broadcastShellEvent(type, payload = {}) {
|
|
|
|
|
if (!shellStreams.size) return;
|
|
|
|
|
const frame = `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
|
|
|
for (const res of shellStreams) {
|
|
|
|
|
try {
|
|
|
|
|
res.write(frame);
|
|
|
|
|
} catch {
|
|
|
|
|
shellStreams.delete(res);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
for (const res of shellStreams) {
|
|
|
|
|
try {
|
|
|
|
|
res.write(': ping\n\n');
|
|
|
|
|
} catch {
|
|
|
|
|
shellStreams.delete(res);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, 25000).unref();
|
|
|
|
|
|
|
|
|
|
/* ── admin API ──────────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
|
|
|
|
/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */
|
|
|
|
|
const adminToken = crypto.randomBytes(24).toString('hex');
|
|
|
|
|
|
|
|
|
|
function isAuthed(req) {
|
|
|
|
|
if (!ADMIN_PASSCODE) return true;
|
|
|
|
|
const cookies = String(req.headers.cookie || '');
|
|
|
|
|
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Guard a state-changing admin route. Returns true when the request was refused. */
|
|
|
|
|
function refuseAdminWrite(req, res) {
|
|
|
|
|
if (!isAuthed(req)) {
|
|
|
|
|
sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
// Requiring application/json forces a CORS preflight that a hostile page
|
|
|
|
|
// on some other origin cannot pass (same reasoning as the reference apps).
|
|
|
|
|
const contentType = String(req.headers['content-type'] || '').toLowerCase();
|
|
|
|
|
if (!contentType.startsWith('application/json')) {
|
|
|
|
|
sendJson(res, 415, { error: 'Send application/json.' });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Everything the admin page needs, per module. */
|
|
|
|
|
function adminModuleView(id) {
|
|
|
|
|
const man = manifests.get(id);
|
|
|
|
|
const cfg = effectiveConfig(id);
|
|
|
|
|
const values = {};
|
|
|
|
|
const passwordSet = {};
|
|
|
|
|
for (const [key, spec] of Object.entries(man.configSchema || {})) {
|
|
|
|
|
if (spec?.type === 'password') {
|
|
|
|
|
passwordSet[key] = Boolean(cfg[key]);
|
|
|
|
|
values[key] = ''; // never echo secrets back to a browser
|
|
|
|
|
} else {
|
|
|
|
|
values[key] = cfg[key];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const entry = mounted.get(id);
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
name: String(man.name || id),
|
|
|
|
|
version: String(man.version || ''),
|
|
|
|
|
description: String(man.description || ''),
|
|
|
|
|
hasServer: Boolean(man.server),
|
|
|
|
|
enabled: isEnabled(id),
|
|
|
|
|
configSchema: man.configSchema || {},
|
2026-09-10 15:31:12 -04:00
|
|
|
configGroups: man.configGroups && typeof man.configGroups === 'object' ? man.configGroups : {},
|
2026-08-30 15:16:09 +00:00
|
|
|
config: values,
|
|
|
|
|
passwordSet,
|
|
|
|
|
mountError: entry?.error || '',
|
|
|
|
|
health: isEnabled(id) ? moduleHealth(id) : null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Coerce and store a config patch according to the module's schema.
|
|
|
|
|
Empty password fields mean "keep what is stored". */
|
|
|
|
|
function applyConfigPatch(id, patch) {
|
|
|
|
|
const man = manifests.get(id);
|
|
|
|
|
const current = effectiveConfig(id);
|
|
|
|
|
const next = {};
|
|
|
|
|
for (const [key, spec] of Object.entries(man.configSchema || {})) {
|
|
|
|
|
const type = spec?.type || 'string';
|
|
|
|
|
let value = patch && typeof patch === 'object' && key in patch ? patch[key] : current[key];
|
|
|
|
|
if (type === 'password' && (value === '' || value === undefined || value === null)) {
|
|
|
|
|
value = current[key] || '';
|
|
|
|
|
}
|
|
|
|
|
if (type === 'number') value = Number(value) || 0;
|
2026-09-10 15:31:12 -04:00
|
|
|
else if (type === 'boolean' || type === 'switch') value = Boolean(value);
|
2026-08-31 19:33:08 +00:00
|
|
|
else if (type === 'endpoint') {
|
|
|
|
|
const src = value && typeof value === 'object' ? value : {};
|
|
|
|
|
value = {
|
|
|
|
|
host: String(src.host ?? '').trim(),
|
|
|
|
|
port: Math.max(0, Math.min(65535, Math.trunc(Number(src.port)) || 0)),
|
|
|
|
|
};
|
|
|
|
|
} else value = value === undefined || value === null ? '' : String(value);
|
2026-08-30 15:16:09 +00:00
|
|
|
next[key] = value;
|
|
|
|
|
}
|
|
|
|
|
modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };
|
|
|
|
|
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleAdminApi(req, res, urlPath) {
|
|
|
|
|
if (urlPath === '/api/admin/login' && req.method === 'POST') {
|
|
|
|
|
let body = {};
|
|
|
|
|
try {
|
|
|
|
|
body = JSON.parse((await readBody(req)) || '{}');
|
|
|
|
|
} catch {
|
|
|
|
|
return sendJson(res, 400, { error: 'Malformed request.' });
|
|
|
|
|
}
|
|
|
|
|
if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false });
|
|
|
|
|
if (String(body.passcode || '') !== ADMIN_PASSCODE) {
|
|
|
|
|
return sendJson(res, 403, { error: 'Wrong passcode.' });
|
|
|
|
|
}
|
|
|
|
|
res.writeHead(200, {
|
|
|
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
|
|
|
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
|
|
|
|
|
'Cache-Control': 'no-store',
|
|
|
|
|
});
|
|
|
|
|
return res.end(JSON.stringify({ ok: true }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (urlPath === '/api/admin/state' && req.method === 'GET') {
|
|
|
|
|
if (!isAuthed(req)) {
|
|
|
|
|
return sendJson(res, 401, { authRequired: true, authed: false });
|
|
|
|
|
}
|
|
|
|
|
return sendJson(res, 200, {
|
|
|
|
|
authRequired: Boolean(ADMIN_PASSCODE),
|
|
|
|
|
authed: true,
|
2026-09-08 14:42:15 +00:00
|
|
|
dataDir: DATA_DIR,
|
2026-08-30 15:16:09 +00:00
|
|
|
modules: [...manifests.keys()].sort().map(adminModuleView),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const m = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/(config|enabled)$/);
|
|
|
|
|
if (m && req.method === 'PUT') {
|
|
|
|
|
const id = decodeURIComponent(m[1]);
|
|
|
|
|
if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` });
|
|
|
|
|
if (refuseAdminWrite(req, res)) return;
|
|
|
|
|
let body = {};
|
|
|
|
|
try {
|
|
|
|
|
body = JSON.parse((await readBody(req)) || '{}');
|
|
|
|
|
} catch {
|
|
|
|
|
return sendJson(res, 400, { error: 'Malformed request.' });
|
|
|
|
|
}
|
2026-09-08 14:42:15 +00:00
|
|
|
try {
|
|
|
|
|
if (m[2] === 'config') {
|
|
|
|
|
applyConfigPatch(id, body.config);
|
|
|
|
|
} else {
|
|
|
|
|
modulesConfig[id] = { ...moduleState(id), enabled: Boolean(body.enabled) };
|
|
|
|
|
writeJson(MODULES_CONFIG_PATH, modulesConfig);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return sendJson(res, 500, { error: `Could not save ${MODULES_CONFIG_PATH}: ${err?.message || err}` });
|
2026-08-30 15:16:09 +00:00
|
|
|
}
|
2026-09-08 14:42:15 +00:00
|
|
|
// Config changes take effect without a manual restart; a toggle mounts when
|
|
|
|
|
// enabled and unmounts (only) otherwise.
|
|
|
|
|
remountModule(id);
|
2026-08-30 15:16:09 +00:00
|
|
|
broadcastShellEvent('modules-changed', { id });
|
|
|
|
|
return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 14:42:15 +00:00
|
|
|
/* ── named layouts (JSON files under <data dir>/layouts/) ───────────── */
|
2026-08-30 15:17:52 +00:00
|
|
|
|
|
|
|
|
/** Names double as file names — keep them boring on purpose. */
|
|
|
|
|
function layoutNameOk(name) {
|
|
|
|
|
return typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9 _()-]{0,39}$/.test(name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function layoutPath(name) {
|
|
|
|
|
return path.join(LAYOUTS_DIR, name + '.json');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Keep only the fields a layout is made of; everything else is dropped. */
|
|
|
|
|
function sanitizeLayout(raw) {
|
|
|
|
|
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
|
|
|
|
|
return {
|
|
|
|
|
tiles: tiles.map((t) => ({
|
|
|
|
|
id: String(t?.id || ''),
|
|
|
|
|
module: String(t?.module || ''),
|
|
|
|
|
x: Number(t?.x) || 0,
|
|
|
|
|
y: Number(t?.y) || 0,
|
|
|
|
|
w: Number(t?.w) || 1,
|
|
|
|
|
h: Number(t?.h) || 1,
|
|
|
|
|
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleLayoutsApi(req, res, urlPath) {
|
|
|
|
|
if (urlPath === '/api/layouts' && req.method === 'GET') {
|
|
|
|
|
let files = [];
|
|
|
|
|
try {
|
|
|
|
|
files = fs.readdirSync(LAYOUTS_DIR).filter((f) => f.endsWith('.json'));
|
|
|
|
|
} catch { /* no layouts dir yet */ }
|
|
|
|
|
const layouts = [];
|
|
|
|
|
for (const file of files.sort()) {
|
|
|
|
|
const name = file.slice(0, -5);
|
|
|
|
|
const data = readJson(path.join(LAYOUTS_DIR, file), null);
|
|
|
|
|
if (!data) continue;
|
|
|
|
|
let updated = null;
|
|
|
|
|
try { updated = fs.statSync(path.join(LAYOUTS_DIR, file)).mtime.toISOString(); } catch { /* fine */ }
|
|
|
|
|
layouts.push({ name, tiles: Array.isArray(data.tiles) ? data.tiles.length : 0, updated });
|
|
|
|
|
}
|
|
|
|
|
return sendJson(res, 200, { layouts });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename)?$/);
|
|
|
|
|
if (!m) return sendJson(res, 404, { error: 'Unknown endpoint.' });
|
|
|
|
|
let name;
|
|
|
|
|
try {
|
|
|
|
|
name = decodeURIComponent(m[1]);
|
|
|
|
|
} catch {
|
|
|
|
|
return sendJson(res, 400, { error: 'Bad layout name.' });
|
|
|
|
|
}
|
|
|
|
|
if (!layoutNameOk(name)) {
|
|
|
|
|
return sendJson(res, 400, { error: 'Layout names can use letters, numbers, spaces, dashes and parentheses (max 40).' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!m[2] && req.method === 'GET') {
|
|
|
|
|
const data = readJson(layoutPath(name), null);
|
|
|
|
|
if (!data) return sendJson(res, 404, { error: `No layout named "${name}".` });
|
|
|
|
|
return sendJson(res, 200, { name, layout: sanitizeLayout(data) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Saving is open to every dashboard (volunteers save their booth setups);
|
|
|
|
|
// renaming and deleting are admin actions.
|
|
|
|
|
if (!m[2] && req.method === 'PUT') {
|
|
|
|
|
const contentType = String(req.headers['content-type'] || '').toLowerCase();
|
|
|
|
|
if (!contentType.startsWith('application/json')) {
|
|
|
|
|
return sendJson(res, 415, { error: 'Send application/json.' });
|
|
|
|
|
}
|
|
|
|
|
let body = {};
|
|
|
|
|
try {
|
|
|
|
|
body = JSON.parse((await readBody(req)) || '{}');
|
|
|
|
|
} catch {
|
|
|
|
|
return sendJson(res, 400, { error: 'Malformed request.' });
|
|
|
|
|
}
|
|
|
|
|
const layout = sanitizeLayout(body.layout);
|
|
|
|
|
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
|
2026-09-08 14:42:15 +00:00
|
|
|
try {
|
|
|
|
|
writeJson(layoutPath(name), layout);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` });
|
|
|
|
|
}
|
2026-08-30 15:17:52 +00:00
|
|
|
return sendJson(res, 200, { ok: true, name });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!m[2] && req.method === 'DELETE') {
|
|
|
|
|
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
|
|
|
|
|
try {
|
|
|
|
|
fs.unlinkSync(layoutPath(name));
|
|
|
|
|
} catch {
|
|
|
|
|
return sendJson(res, 404, { error: `No layout named "${name}".` });
|
|
|
|
|
}
|
|
|
|
|
return sendJson(res, 200, { ok: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (m[2] && req.method === 'POST') {
|
|
|
|
|
if (refuseAdminWrite(req, res)) return;
|
|
|
|
|
let body = {};
|
|
|
|
|
try {
|
|
|
|
|
body = JSON.parse((await readBody(req)) || '{}');
|
|
|
|
|
} catch {
|
|
|
|
|
return sendJson(res, 400, { error: 'Malformed request.' });
|
|
|
|
|
}
|
|
|
|
|
const next = String(body.name || '').trim();
|
|
|
|
|
if (!layoutNameOk(next)) {
|
|
|
|
|
return sendJson(res, 400, { error: 'Layout names can use letters, numbers, spaces, dashes and parentheses (max 40).' });
|
|
|
|
|
}
|
|
|
|
|
if (!fs.existsSync(layoutPath(name))) return sendJson(res, 404, { error: `No layout named "${name}".` });
|
|
|
|
|
if (next !== name && fs.existsSync(layoutPath(next))) {
|
|
|
|
|
return sendJson(res, 409, { error: `A layout named "${next}" already exists.` });
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
fs.renameSync(layoutPath(name), layoutPath(next));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return sendJson(res, 500, { error: 'Rename failed: ' + (err?.message || err) });
|
|
|
|
|
}
|
|
|
|
|
return sendJson(res, 200, { ok: true, name: next });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sendJson(res, 405, { error: 'Method not allowed.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 14:55:25 +00:00
|
|
|
/* ── module API dispatch ────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
function dispatchModuleApi(req, res, id, subPath, search) {
|
|
|
|
|
if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` });
|
|
|
|
|
if (!isEnabled(id)) return sendJson(res, 404, { error: `Module "${id}" is disabled.` });
|
|
|
|
|
const entry = mounted.get(id);
|
|
|
|
|
if (!entry) return sendJson(res, 503, { error: `Module "${id}" is not mounted.` });
|
|
|
|
|
if (entry.error) return sendJson(res, 502, { error: `Module "${id}" failed to start: ${entry.error}` });
|
|
|
|
|
|
|
|
|
|
for (const route of entry.routes) {
|
|
|
|
|
if (route.method !== '*' && route.method !== req.method) continue;
|
|
|
|
|
if (route.wildcard) {
|
|
|
|
|
if (subPath === route.path || subPath.startsWith(route.path + '/')) {
|
|
|
|
|
req.wildcard = subPath.slice(route.path.length) || '/';
|
|
|
|
|
req.search = search;
|
|
|
|
|
try {
|
|
|
|
|
route.handler(req, res);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[${id}] route handler threw:`, err);
|
|
|
|
|
if (!res.headersSent) sendJson(res, 500, { error: 'Module error.' });
|
|
|
|
|
else try { res.end(); } catch { /* gone */ }
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} else if (subPath === route.path) {
|
|
|
|
|
req.search = search;
|
|
|
|
|
try {
|
|
|
|
|
route.handler(req, res);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[${id}] route handler threw:`, err);
|
|
|
|
|
if (!res.headersSent) sendJson(res, 500, { error: 'Module error.' });
|
|
|
|
|
else try { res.end(); } catch { /* gone */ }
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sendJson(res, 404, { error: `Module "${id}" has no route for ${req.method} ${subPath}` });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 14:50:37 +00:00
|
|
|
/* ── static files ───────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
const MIME = {
|
|
|
|
|
'.html': 'text/html; charset=utf-8',
|
|
|
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
|
|
|
'.mjs': 'text/javascript; charset=utf-8',
|
|
|
|
|
'.css': 'text/css; charset=utf-8',
|
|
|
|
|
'.json': 'application/json; charset=utf-8',
|
|
|
|
|
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
|
|
|
|
'.png': 'image/png',
|
|
|
|
|
'.jpg': 'image/jpeg',
|
|
|
|
|
'.svg': 'image/svg+xml',
|
|
|
|
|
'.ico': 'image/x-icon',
|
|
|
|
|
'.woff2': 'font/woff2',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Serve a file from inside baseDir; urlPath is relative to it. */
|
|
|
|
|
function serveFile(res, baseDir, urlPath) {
|
|
|
|
|
let rel;
|
|
|
|
|
try {
|
|
|
|
|
rel = decodeURIComponent(urlPath).replace(/^\/+/, '');
|
|
|
|
|
} catch {
|
|
|
|
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
|
|
|
return res.end('Bad request');
|
|
|
|
|
}
|
|
|
|
|
if (rel === '') rel = 'index.html';
|
|
|
|
|
// Resolve inside baseDir only — never let a crafted path escape the web root.
|
|
|
|
|
const target = path.resolve(baseDir, rel);
|
|
|
|
|
if (target !== baseDir && !target.startsWith(baseDir + path.sep)) {
|
|
|
|
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
|
|
|
return res.end('Forbidden');
|
|
|
|
|
}
|
|
|
|
|
fs.stat(target, (serr, stat) => {
|
|
|
|
|
const file = !serr && stat.isDirectory() ? path.join(target, 'index.html') : target;
|
|
|
|
|
fs.readFile(file, (err, data) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
|
|
|
return res.end('Not found');
|
|
|
|
|
}
|
|
|
|
|
const ext = path.extname(file).toLowerCase();
|
|
|
|
|
// Icons can cache; everything else must always revalidate so UI updates
|
|
|
|
|
// reach open dashboards immediately.
|
|
|
|
|
const noCache = ext !== '.png' && ext !== '.ico' && ext !== '.svg' && ext !== '.woff2';
|
|
|
|
|
res.writeHead(200, {
|
|
|
|
|
'Content-Type': MIME[ext] || 'application/octet-stream',
|
|
|
|
|
'Cache-Control': noCache ? 'no-cache' : 'public, max-age=3600',
|
|
|
|
|
});
|
|
|
|
|
res.end(data);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── request routing ────────────────────────────────────────────────── */
|
|
|
|
|
|
2026-08-30 14:55:25 +00:00
|
|
|
function handleRequest(req, res) {
|
|
|
|
|
let urlPath;
|
|
|
|
|
let search = '';
|
|
|
|
|
try {
|
|
|
|
|
const u = new URL(req.url, 'http://proddash.invalid');
|
|
|
|
|
urlPath = u.pathname;
|
|
|
|
|
search = u.search || '';
|
|
|
|
|
} catch {
|
|
|
|
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
|
|
|
return res.end('Bad request');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* — shell API — */
|
|
|
|
|
if (urlPath === '/api/modules' && req.method === 'GET') {
|
2026-08-31 17:53:06 +00:00
|
|
|
Promise.all([...manifests.keys()].filter(isEnabled).map(clientManifest))
|
|
|
|
|
.then((list) => sendJson(res, 200, { modules: list }))
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
console.error('[proddash] /api/modules failed:', err);
|
|
|
|
|
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
|
|
|
|
});
|
|
|
|
|
return;
|
2026-08-30 14:55:25 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-30 15:16:09 +00:00
|
|
|
/* — shell events: open dashboards learn about admin changes live — */
|
|
|
|
|
if (urlPath === '/api/events' && req.method === 'GET') {
|
|
|
|
|
res.writeHead(200, {
|
|
|
|
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
|
|
|
'Cache-Control': 'no-cache, no-transform',
|
|
|
|
|
Connection: 'keep-alive',
|
|
|
|
|
'X-Accel-Buffering': 'no',
|
|
|
|
|
});
|
|
|
|
|
res.write('retry: 3000\n\n');
|
|
|
|
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
|
|
|
|
shellStreams.add(res);
|
|
|
|
|
req.on('close', () => shellStreams.delete(res));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 15:17:52 +00:00
|
|
|
/* — named layouts — */
|
|
|
|
|
if (urlPath === '/api/layouts' || urlPath.startsWith('/api/layouts/')) {
|
|
|
|
|
handleLayoutsApi(req, res, urlPath).catch((err) => {
|
|
|
|
|
console.error('[proddash] layouts API error:', err);
|
|
|
|
|
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
|
|
|
|
else try { res.end(); } catch { /* gone */ }
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 15:16:09 +00:00
|
|
|
/* — admin API — */
|
|
|
|
|
if (urlPath.startsWith('/api/admin/')) {
|
|
|
|
|
handleAdminApi(req, res, urlPath).catch((err) => {
|
|
|
|
|
console.error('[proddash] admin API error:', err);
|
|
|
|
|
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
|
|
|
|
else try { res.end(); } catch { /* gone */ }
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 14:55:25 +00:00
|
|
|
/* — module APIs: /api/modules/<id>/… (any method; modules decide) — */
|
|
|
|
|
const apiMatch = urlPath.match(/^\/api\/modules\/([^/]+)(\/.*)?$/);
|
|
|
|
|
if (apiMatch) {
|
|
|
|
|
const id = decodeURIComponent(apiMatch[1]);
|
|
|
|
|
const subPath = apiMatch[2] || '/';
|
|
|
|
|
return dispatchModuleApi(req, res, id, subPath, search);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (urlPath.startsWith('/api/')) {
|
|
|
|
|
return sendJson(res, 404, { error: 'Unknown endpoint.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* — static — */
|
|
|
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
|
|
|
res.writeHead(405, { Allow: 'GET, HEAD' });
|
|
|
|
|
return res.end('Method not allowed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* module client assets: /modules/<id>/… */
|
|
|
|
|
const assetMatch = urlPath.match(/^\/modules\/([^/]+)(\/.*)?$/);
|
|
|
|
|
if (assetMatch) {
|
|
|
|
|
const id = decodeURIComponent(assetMatch[1]);
|
|
|
|
|
const man = manifests.get(id);
|
|
|
|
|
if (!man || !isEnabled(id)) {
|
|
|
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
|
|
|
return res.end('Not found');
|
|
|
|
|
}
|
|
|
|
|
return serveFile(res, man.dir, assetMatch[2] || '/');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
serveFile(res, PUBLIC_DIR, urlPath);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 14:50:37 +00:00
|
|
|
const server = http.createServer((req, res) => {
|
|
|
|
|
// Everything is guarded: one throw must never take the dashboard down mid-service.
|
|
|
|
|
try {
|
2026-08-30 14:55:25 +00:00
|
|
|
handleRequest(req, res);
|
2026-08-30 14:50:37 +00:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[proddash] request error:', err);
|
|
|
|
|
try {
|
|
|
|
|
if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
|
|
|
res.end('Server error');
|
|
|
|
|
} catch { /* connection already gone */ }
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Never time out long-lived SSE connections.
|
|
|
|
|
server.requestTimeout = 0;
|
|
|
|
|
|
2026-08-30 14:55:25 +00:00
|
|
|
/* ── boot ───────────────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
discoverModules();
|
|
|
|
|
mountAllModules();
|
|
|
|
|
|
2026-08-30 14:50:37 +00:00
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
|
|
|
console.log('ProdDash running');
|
|
|
|
|
console.log(' Local : http://localhost:' + PORT);
|
|
|
|
|
for (const ifaces of Object.values(os.networkInterfaces())) {
|
|
|
|
|
for (const iface of ifaces || []) {
|
|
|
|
|
if (iface.family === 'IPv4' && !iface.internal) {
|
|
|
|
|
console.log(' Network : http://' + iface.address + ':' + PORT);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-30 14:55:25 +00:00
|
|
|
const ids = [...manifests.keys()];
|
|
|
|
|
console.log(ids.length
|
|
|
|
|
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ')
|
|
|
|
|
: ' Modules : none installed');
|
2026-09-08 14:42:15 +00:00
|
|
|
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
|
2026-08-30 14:50:37 +00:00
|
|
|
});
|
2026-08-30 14:55:25 +00:00
|
|
|
|
|
|
|
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
|
|
|
process.on(signal, () => {
|
|
|
|
|
for (const id of [...mounted.keys()]) unmountModule(id);
|
|
|
|
|
server.close(() => process.exit(0));
|
|
|
|
|
setTimeout(() => process.exit(0), 1500).unref();
|
|
|
|
|
});
|
|
|
|
|
}
|