diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore index daf787e..5e620fc 100644 --- a/dashboard/.dockerignore +++ b/dashboard/.dockerignore @@ -2,3 +2,6 @@ node_modules .next Dockerfile .dockerignore +# Local dev fixtures — MUST NOT ship in images. dev-data/app/craftdeck.db +# contains a real scrypt admin password hash (see docs/public-mode.md). +dev-data diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile index 94221c5..bece815 100644 --- a/dashboard/Dockerfile +++ b/dashboard/Dockerfile @@ -46,6 +46,9 @@ ENV NODE_ENV=production \ COPY --from=build /app/.next/standalone ./ COPY --from=build /app/.next/static ./.next/static COPY --from=build /app/public ./public +# Feed exporter for the public read-only mirror (uses only Node built-ins; +# run via the feed-export sidecar in docker-compose.yml). +COPY --from=build /app/scripts ./scripts EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ diff --git a/dashboard/scripts/export-feed.mjs b/dashboard/scripts/export-feed.mjs new file mode 100755 index 0000000..d37e146 --- /dev/null +++ b/dashboard/scripts/export-feed.mjs @@ -0,0 +1,169 @@ +#!/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}` +); diff --git a/dashboard/src/app/(panel)/layout.tsx b/dashboard/src/app/(panel)/layout.tsx index 4ce0025..1012499 100644 --- a/dashboard/src/app/(panel)/layout.tsx +++ b/dashboard/src/app/(panel)/layout.tsx @@ -1,5 +1,10 @@ import { Shell } from "@/components/shell"; +// Resolve PUBLIC_READONLY at request time (not build time) so the same image +// serves either mode depending on its runtime environment. +export const dynamic = "force-dynamic"; + export default function PanelLayout({ children }: { children: React.ReactNode }) { - return {children}; + const publicMode = process.env.PUBLIC_READONLY === "1"; + return {children}; } diff --git a/dashboard/src/app/(panel)/page.tsx b/dashboard/src/app/(panel)/page.tsx index 68959f4..681f9f3 100644 --- a/dashboard/src/app/(panel)/page.tsx +++ b/dashboard/src/app/(panel)/page.tsx @@ -8,6 +8,7 @@ import { Card, StatTile, Button, Badge, EmptyState } from "@/components/ui"; import { Sparkline, UsageBar, type Point } from "@/components/charts"; import { QuickControls } from "@/components/quickcontrols"; import { fmtBytes, fmtDuration, timeAgo, fmtCountdown } from "@/lib/format"; +import { usePublicMode } from "@/lib/publicmode"; type ServerData = { dockerAvailable: boolean; @@ -64,12 +65,15 @@ const ACTIVITY_LABELS: Record("/api/server", 10000); const { data: metricRows, refresh: refreshMetrics } = useApi("/api/metrics?range=1h", 30000); const { data: activity, refresh: refreshActivity } = useApi("/api/activity?limit=14", 20000); + // /api/approved is admin-only (404 in public mode) — don't request it. const { data: approvedData, refresh: refreshApproved } = useApi<{ approved: ApprovedRow[] }>( "/api/approved", - 15000 + 15000, + { enabled: !publicMode } ); const [busyAction, setBusyAction] = useState(null); const [, forceTick] = useState(0); @@ -113,34 +117,36 @@ export default function OverviewPage() { {running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}>}