#!/usr/bin/env node // @ts-check /** * Feed exporter for the CraftDeck public read-only mirror. * * Reads the live panel database (READ-ONLY) and writes a small, sanitised * SQLite "feed" file that the PUBLIC_READONLY dashboard serves from. The feed * contains ONLY: * * metrics – the sampler time-series (last 7 days) * activity – join/leave events only, as (id, ts, type, player); the * free-text `detail` column (which can hold IPs on connect/ * blocked rows) is deliberately dropped * feed_meta – one small status JSON blob (running, online names, counts) * * It NEVER copies panel_users, approved_players, settings, notifications, * schedules or ping_samples, so none of those can leak to the public app. * * The file is written to a temp path and atomically renamed into place, so * readers never observe a half-written feed. No external dependencies — only * the built-in node:sqlite module. * * Env: * SOURCE_DB path to the live panel DB (default: dev-data/app/craftdeck.db) * FEED_PATH path to write the feed to (default: dev-data/feed/feed.db) * MC_VERSION optional version string for the status blob * SERVER_TYPE optional server type string for the status blob */ import { DatabaseSync } from "node:sqlite"; import fs from "node:fs"; import path from "node:path"; const SOURCE_DB = process.env.SOURCE_DB || "dev-data/app/craftdeck.db"; const FEED_PATH = process.env.FEED_PATH || "dev-data/feed/feed.db"; const WEEK_MS = 7 * 24 * 60 * 60 * 1000; function log(msg) { console.log(`[export-feed] ${msg}`); } if (!fs.existsSync(SOURCE_DB)) { console.error(`[export-feed] source DB not found: ${SOURCE_DB}`); process.exit(1); } const src = new DatabaseSync(SOURCE_DB, { readOnly: true }); // ── derive current status from the panel DB (no Docker/RCON) ────────────── const now = Date.now(); const latest = /** @type {any} */ (src.prepare("SELECT * FROM metrics ORDER BY ts DESC LIMIT 1").get()) || null; // The sampler writes a row every 10s; a fresh row with cpu present means the // mc container was up at sample time. const fresh = latest && now - Number(latest.ts) < 60_000; const running = Boolean(fresh && latest.cpu != null); // Reconstruct who is currently online by replaying join/leave since the most // recent server start (a restart clears everyone). let onlineNames = []; if (running) { const lastStart = /** @type {any} */ ( src .prepare("SELECT MAX(ts) AS ts FROM activity WHERE type IN ('server_online')") .get() ); const sinceTs = lastStart && lastStart.ts ? Number(lastStart.ts) : now - 24 * 60 * 60 * 1000; const events = /** @type {any[]} */ ( src .prepare( "SELECT ts, type, player FROM activity WHERE ts >= ? AND type IN ('join','leave') ORDER BY ts ASC" ) .all(sinceTs) ); const set = new Set(); for (const e of events) { if (!e.player) continue; if (e.type === "join") set.add(e.player); else set.delete(e.player); } onlineNames = [...set]; } const playersOnline = running ? Number(latest?.players ?? onlineNames.length) || onlineNames.length : 0; const status = { generatedAt: now, running, playersOnline, playersMax: 0, playerNames: onlineNames, version: process.env.MC_VERSION || null, serverType: process.env.SERVER_TYPE || null, }; // ── build the feed in a temp file, then atomically swap it in ───────────── fs.mkdirSync(path.dirname(FEED_PATH), { recursive: true }); const tmp = `${FEED_PATH}.tmp-${process.pid}`; for (const p of [tmp, `${tmp}-wal`, `${tmp}-shm`, `${tmp}-journal`]) { try { fs.rmSync(p); } catch {} } const out = new DatabaseSync(tmp); out.exec("PRAGMA journal_mode = DELETE"); out.exec(` CREATE TABLE metrics ( ts INTEGER PRIMARY KEY, cpu REAL, mem_used INTEGER, mem_limit INTEGER, net_rx INTEGER, net_tx INTEGER, tps REAL, mspt REAL, players INTEGER, disk_used INTEGER, disk_free INTEGER ); CREATE TABLE activity ( id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, type TEXT NOT NULL, player TEXT ); CREATE INDEX idx_activity_ts ON activity(ts DESC); CREATE TABLE feed_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); `); const since = now - WEEK_MS; const metricRows = /** @type {any[]} */ ( src.prepare("SELECT * FROM metrics WHERE ts > ? ORDER BY ts ASC").all(since) ); const insMetric = out.prepare( `INSERT INTO metrics(ts, cpu, mem_used, mem_limit, net_rx, net_tx, tps, mspt, players, disk_used, disk_free) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ); for (const m of metricRows) { insMetric.run( m.ts, m.cpu, m.mem_used, m.mem_limit, m.net_rx, m.net_tx, m.tps, m.mspt, m.players, m.disk_used, m.disk_free ); } // Only join/leave — the `detail` column is intentionally not carried over. const activityRows = /** @type {any[]} */ ( src .prepare( "SELECT id, ts, type, player FROM activity WHERE ts > ? AND type IN ('join','leave') ORDER BY ts ASC" ) .all(since) ); const insActivity = out.prepare("INSERT INTO activity(id, ts, type, player) VALUES(?, ?, ?, ?)"); for (const a of activityRows) insActivity.run(a.id, a.ts, a.type, a.player); out.prepare("INSERT INTO feed_meta(key, value) VALUES('status', ?)").run(JSON.stringify(status)); out.close(); src.close(); // Atomic replace (same filesystem). Clean up any leftover side files. for (const p of [`${FEED_PATH}-wal`, `${FEED_PATH}-shm`, `${FEED_PATH}-journal`]) { try { fs.rmSync(p); } catch {} } fs.renameSync(tmp, FEED_PATH); log( `wrote ${FEED_PATH}: ${metricRows.length} metrics, ${activityRows.length} join/leave, ` + `running=${running}, online=${playersOnline}` );