Restore source files from Google Drive backup
This commit is contained in:
1 parent
dff2b5a9c4
commit
328525b37f
27 files changed
+975
-106
No files matched your search
@@ -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
|
||||
@@ -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 \
|
||||
|
||||
Executable
+169
@@ -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}`
|
||||
);
|
||||
@@ -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 <Shell>{children}</Shell>;
|
||||
const publicMode = process.env.PUBLIC_READONLY === "1";
|
||||
return <Shell publicMode={publicMode}>{children}</Shell>;
|
||||
}
|
||||
@@ -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<string, { label: string; color: "good" | "warn" |
|
||||
};
|
||||
|
||||
export default function OverviewPage() {
|
||||
const publicMode = usePublicMode();
|
||||
const { data: server, refresh } = useApi<ServerData>("/api/server", 10000);
|
||||
const { data: metricRows, refresh: refreshMetrics } = useApi<MetricRow[]>("/api/metrics?range=1h", 30000);
|
||||
const { data: activity, refresh: refreshActivity } = useApi<ActivityRow[]>("/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<string | null>(null);
|
||||
const [, forceTick] = useState(0);
|
||||
@@ -113,34 +117,36 @@ export default function OverviewPage() {
|
||||
{running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => power("start")}
|
||||
busy={busyAction === "start"}
|
||||
disabled={running || !server?.dockerAvailable}
|
||||
>
|
||||
<Play size={13} /> Start
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => power("restart")}
|
||||
busy={busyAction === "restart"}
|
||||
disabled={!running}
|
||||
>
|
||||
<RotateCw size={13} /> Restart
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => power("stop")}
|
||||
busy={busyAction === "stop"}
|
||||
disabled={!running}
|
||||
>
|
||||
<Square size={13} /> Stop
|
||||
</Button>
|
||||
</div>
|
||||
{!publicMode && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => power("start")}
|
||||
busy={busyAction === "start"}
|
||||
disabled={running || !server?.dockerAvailable}
|
||||
>
|
||||
<Play size={13} /> Start
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => power("restart")}
|
||||
busy={busyAction === "restart"}
|
||||
disabled={!running}
|
||||
>
|
||||
<RotateCw size={13} /> Restart
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => power("stop")}
|
||||
busy={busyAction === "stop"}
|
||||
disabled={!running}
|
||||
>
|
||||
<Square size={13} /> Stop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!server?.dockerAvailable && server && (
|
||||
@@ -230,7 +236,7 @@ export default function OverviewPage() {
|
||||
)}
|
||||
|
||||
{/* admin quick controls */}
|
||||
<QuickControls running={running} />
|
||||
{!publicMode && <QuickControls running={running} />}
|
||||
|
||||
{/* world + disk */}
|
||||
<Card title="World & storage">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api, useApi, toast } from "@/lib/api";
|
||||
import { Card, Button, Badge, Tabs, EmptyState, Modal } from "@/components/ui";
|
||||
import { TimeSeriesChart, Sparkline, type Point } from "@/components/charts";
|
||||
import { fmtBytes } from "@/lib/format";
|
||||
import { usePublicMode } from "@/lib/publicmode";
|
||||
|
||||
type MetricRow = {
|
||||
bucket: number;
|
||||
@@ -66,6 +67,7 @@ const VERDICTS: Record<LagData["verdict"], { badge: "good" | "warn" | "crit" | "
|
||||
};
|
||||
|
||||
export default function PerformancePage() {
|
||||
const publicMode = usePublicMode();
|
||||
const [range, setRange] = useState("1h");
|
||||
const { data: rows } = useApi<MetricRow[]>(`/api/metrics?range=${range}`, 30000);
|
||||
const { data: lag, refresh: refreshLag } = useApi<LagData>("/api/lag", 30000);
|
||||
@@ -116,9 +118,11 @@ export default function PerformancePage() {
|
||||
<Card
|
||||
title="Lag diagnosis"
|
||||
actions={
|
||||
<Button size="sm" onClick={runHealthReport} busy={busyReport}>
|
||||
<Stethoscope size={13} /> Full health report
|
||||
</Button>
|
||||
publicMode ? undefined : (
|
||||
<Button size="sm" onClick={runHealthReport} busy={busyReport}>
|
||||
<Stethoscope size={13} /> Full health report
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Users, UserX, Wifi, Crown, Gavel, HeartPulse, MapPin, Gift } from "luci
|
||||
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||
import { Card, Badge, Button, EmptyState, Modal, Input, Select } from "@/components/ui";
|
||||
import { timeAgo, fmtDateTime } from "@/lib/format";
|
||||
import { usePublicMode } from "@/lib/publicmode";
|
||||
|
||||
type PlayerRow = {
|
||||
name: string;
|
||||
@@ -27,6 +28,7 @@ type PlayersData = { online: string[]; max: number; players: PlayerRow[] };
|
||||
type ActivityRow = { id: number; ts: number; type: string; player: string | null; detail: string | null };
|
||||
|
||||
export default function PlayersPage() {
|
||||
const publicMode = usePublicMode();
|
||||
const { data, refresh } = useApi<PlayersData>("/api/players", 15000);
|
||||
const { data: joins, refresh: refreshJoins } = useApi<ActivityRow[]>("/api/activity?limit=30", 20000);
|
||||
const [detail, setDetail] = useState<PlayerRow | null>(null);
|
||||
@@ -129,7 +131,7 @@ export default function PlayersPage() {
|
||||
<tbody>
|
||||
{players.map((p) => (
|
||||
<tr
|
||||
key={p.uuid}
|
||||
key={p.uuid || p.name}
|
||||
className="cursor-pointer border-b border-border/50 last:border-0 hover:bg-surface2"
|
||||
onClick={() => setDetail(p)}
|
||||
>
|
||||
@@ -149,7 +151,7 @@ export default function PlayersPage() {
|
||||
{p.isOnline ? <span className="text-good">online now</span> : timeAgo(p.lastSeenAt)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
{p.isOnline && (
|
||||
{!publicMode && p.isOnline && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
@@ -226,6 +228,7 @@ export default function PlayersPage() {
|
||||
</div>
|
||||
|
||||
{/* admin actions */}
|
||||
{!publicMode && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-1.5 border-t border-border pt-3">
|
||||
{detail.isOnline && (
|
||||
<>
|
||||
@@ -261,9 +264,10 @@ export default function PlayersPage() {
|
||||
<Gavel size={12} /> Ban
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* creative admin favors (online players only) */}
|
||||
{detail.isOnline && (
|
||||
{!publicMode && detail.isOnline && (
|
||||
<div className="mt-3 space-y-2 border-t border-border pt-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { db } from "@/server/db";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { env } from "@/server/env";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// The feed only ever contains join/leave rows, so the public mirror is
|
||||
// naturally limited to that; the live DB serves the full activity log.
|
||||
async function source(): Promise<DatabaseSync | null> {
|
||||
if (env.publicReadonly) return (await import("@/server/feed")).feedDb();
|
||||
return (await import("@/server/db")).db();
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const database = await source();
|
||||
if (!database) return ok([]);
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 50), 500);
|
||||
const type = req.nextUrl.searchParams.get("type");
|
||||
const rows = type
|
||||
? db().prepare("SELECT * FROM activity WHERE type = ? ORDER BY ts DESC LIMIT ?").all(type, limit)
|
||||
: db().prepare("SELECT * FROM activity ORDER BY ts DESC LIMIT ?").all(limit);
|
||||
? database
|
||||
.prepare("SELECT * FROM activity WHERE type = ? ORDER BY ts DESC LIMIT ?")
|
||||
.all(type, limit)
|
||||
: database.prepare("SELECT * FROM activity ORDER BY ts DESC LIMIT ?").all(limit);
|
||||
return ok(rows);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { db } from "@/server/db";
|
||||
import { sparkPings, listOnlinePlayers } from "@/server/mc";
|
||||
import { env } from "@/server/env";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -9,8 +8,40 @@ export const dynamic = "force-dynamic";
|
||||
* high CPU) from "a player's connection is lagging" (high ping) and renders
|
||||
* a verdict the admin can read at a glance.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Public mirror: only the server-side tick health (from the feed's metrics)
|
||||
* is derived. There are no per-player ping samples in the feed, so the
|
||||
* connection section is intentionally empty — no RCON, no ping_samples.
|
||||
*/
|
||||
async function publicLag() {
|
||||
const { feedDb } = await import("@/server/feed");
|
||||
const database = feedDb();
|
||||
const recent = database
|
||||
? (database
|
||||
.prepare("SELECT tps, mspt, cpu FROM metrics WHERE ts > ? ORDER BY ts DESC LIMIT 18")
|
||||
.all(Date.now() - 3 * 60 * 1000) as Array<{
|
||||
tps: number | null;
|
||||
mspt: number | null;
|
||||
cpu: number | null;
|
||||
}>)
|
||||
: [];
|
||||
const avg = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
||||
const tps = avg(recent.map((r) => r.tps).filter((v): v is number => v !== null));
|
||||
const mspt = avg(recent.map((r) => r.mspt).filter((v): v is number => v !== null));
|
||||
const cpu = avg(recent.map((r) => r.cpu).filter((v): v is number => v !== null));
|
||||
const serverHealthy = (tps === null || tps >= 18) && (mspt === null || mspt <= 40);
|
||||
const verdict = tps === null && mspt === null ? "no-data" : serverHealthy ? "all-good" : "server-lagging";
|
||||
return ok({ tps, mspt, cpu, serverHealthy, verdict, players: [] });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (env.publicReadonly) return handle(publicLag);
|
||||
|
||||
return handle(async () => {
|
||||
const { db } = await import("@/server/db");
|
||||
const { sparkPings, listOnlinePlayers } = await import("@/server/mc");
|
||||
|
||||
const recent = db()
|
||||
.prepare("SELECT tps, mspt, cpu FROM metrics WHERE ts > ? ORDER BY ts DESC LIMIT 18")
|
||||
.all(Date.now() - 3 * 60 * 1000) as Array<{ tps: number | null; mspt: number | null; cpu: number | null }>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { db } from "@/server/db";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { env } from "@/server/env";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -11,12 +12,20 @@ const RANGES: Record<string, { ms: number; bucketMs: number }> = {
|
||||
"7d": { ms: 7 * 24 * 60 * 60 * 1000, bucketMs: 30 * 60 * 1000 },
|
||||
};
|
||||
|
||||
// Read from the exported feed in public mode, the live panel DB otherwise.
|
||||
async function source(): Promise<DatabaseSync | null> {
|
||||
if (env.publicReadonly) return (await import("@/server/feed")).feedDb();
|
||||
return (await import("@/server/db")).db();
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const database = await source();
|
||||
if (!database) return ok([]);
|
||||
const range = RANGES[req.nextUrl.searchParams.get("range") ?? "1h"] ?? RANGES["1h"];
|
||||
const since = Date.now() - range.ms;
|
||||
// Bucket-average so long ranges stay light on the wire.
|
||||
const rows = db()
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT (ts / ?) * ? AS bucket,
|
||||
AVG(cpu) AS cpu, AVG(mem_used) AS mem_used, MAX(mem_limit) AS mem_limit,
|
||||
|
||||
@@ -1,12 +1,47 @@
|
||||
import { listOnlinePlayers } from "@/server/mc";
|
||||
import { readAllPlayerStats } from "@/server/playerstats";
|
||||
import { db } from "@/server/db";
|
||||
import { env } from "@/server/env";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Public mirror: only the online names + counts come out of the feed. No
|
||||
* per-player NBT stats, ping history, or auth status is ever exposed, and
|
||||
* neither RCON nor the world folder is touched.
|
||||
*/
|
||||
async function publicPlayers() {
|
||||
const { feedStatus } = await import("@/server/feed");
|
||||
const status = feedStatus();
|
||||
const online = status?.playerNames ?? [];
|
||||
return ok({
|
||||
online,
|
||||
max: status?.playersMax ?? 0,
|
||||
players: online.map((name) => ({
|
||||
name,
|
||||
uuid: "",
|
||||
playTimeHours: 0,
|
||||
deaths: 0,
|
||||
mobKills: 0,
|
||||
playerKills: 0,
|
||||
distanceKm: 0,
|
||||
jumps: 0,
|
||||
damageDealt: 0,
|
||||
blocksMined: 0,
|
||||
isOnline: true,
|
||||
ping: null,
|
||||
authStatus: null,
|
||||
lastSeenAt: null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (env.publicReadonly) return handle(publicPlayers);
|
||||
|
||||
return handle(async () => {
|
||||
const { listOnlinePlayers } = await import("@/server/mc");
|
||||
const { readAllPlayerStats } = await import("@/server/playerstats");
|
||||
const { db } = await import("@/server/db");
|
||||
|
||||
let online: string[] = [];
|
||||
let max = 0;
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { inspectContainer, containerEnv } from "@/server/docker";
|
||||
import { listOnlinePlayers } from "@/server/mc";
|
||||
import { db } from "@/server/db";
|
||||
import { env } from "@/server/env";
|
||||
import { getWorldSizeBytes } from "@/server/sampler";
|
||||
import { readWorldInfo } from "@/server/playerstats";
|
||||
import { readEnvFile } from "@/server/envfile";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -12,20 +6,56 @@ export const dynamic = "force-dynamic";
|
||||
// BlueMap detection (in-network probe of the mc container), cached 60 s.
|
||||
let mapCheck: { at: number; available: boolean } = { at: 0, available: false };
|
||||
|
||||
async function bluemapAvailable(running: boolean): Promise<boolean> {
|
||||
async function bluemapAvailable(mcContainer: string, running: boolean): Promise<boolean> {
|
||||
if (!running) return false;
|
||||
if (Date.now() - mapCheck.at < 60_000) return mapCheck.available;
|
||||
let available = false;
|
||||
try {
|
||||
const res = await fetch(`http://${env.mcContainer}:8100/`, { signal: AbortSignal.timeout(1500) });
|
||||
const res = await fetch(`http://${mcContainer}:8100/`, { signal: AbortSignal.timeout(1500) });
|
||||
available = res.ok;
|
||||
} catch {}
|
||||
mapCheck = { at: Date.now(), available };
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public mirror: status comes entirely from the exported feed. No Docker,
|
||||
* no RCON, no world-folder access — none of those modules are imported.
|
||||
*/
|
||||
async function publicStatus() {
|
||||
const { feedStatus, feedLatestMetrics } = await import("@/server/feed");
|
||||
const status = feedStatus();
|
||||
const latest = feedLatestMetrics();
|
||||
const running = status?.running ?? false;
|
||||
return ok({
|
||||
dockerAvailable: true,
|
||||
container: { exists: true, running, status: running ? "running" : "stopped" },
|
||||
players: {
|
||||
online: status?.playersOnline ?? 0,
|
||||
max: status?.playersMax ?? 0,
|
||||
names: status?.playerNames ?? [],
|
||||
},
|
||||
version: status?.version ?? null,
|
||||
serverType: status?.serverType ?? null,
|
||||
metrics: latest,
|
||||
pendingApprovals: 0,
|
||||
map: { available: false, port: 8100 },
|
||||
world: { seed: null, sizeBytes: null, dimensions: [], levelName: null },
|
||||
uptimeMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (env.publicReadonly) return handle(publicStatus);
|
||||
|
||||
return handle(async () => {
|
||||
const { inspectContainer, containerEnv } = await import("@/server/docker");
|
||||
const { listOnlinePlayers } = await import("@/server/mc");
|
||||
const { getWorldSizeBytes } = await import("@/server/sampler");
|
||||
const { readWorldInfo } = await import("@/server/playerstats");
|
||||
const { readEnvFile } = await import("@/server/envfile");
|
||||
const { db } = await import("@/server/db");
|
||||
|
||||
let container;
|
||||
let dockerAvailable = true;
|
||||
try {
|
||||
@@ -66,7 +96,7 @@ export async function GET() {
|
||||
metrics: latest ?? null,
|
||||
pendingApprovals: pendingCount,
|
||||
map: {
|
||||
available: await bluemapAvailable(container.running),
|
||||
available: await bluemapAvailable(env.mcContainer, container.running),
|
||||
port: Number(readEnvFile()["MAP_PORT"]) || 8100,
|
||||
},
|
||||
world: readWorldInfo(getWorldSizeBytes()),
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { bus } from "@/server/bus";
|
||||
import { env } from "@/server/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Multiplexed live event stream (SSE) for the UI: sampler metrics ticks,
|
||||
* parsed game events, and new notifications.
|
||||
* Multiplexed live event stream (SSE) for the UI.
|
||||
*
|
||||
* Admin mode fans out sampler metrics ticks, parsed game events, and new
|
||||
* notifications. In PUBLIC_READONLY mode the stream is restricted to
|
||||
* "metrics" and join/leave "mc-event" frames only — notifications (which
|
||||
* carry admin-facing titles/bodies) and any other game-event types are never
|
||||
* forwarded. The feed watcher only ever emits those two kinds anyway, but
|
||||
* the filter below is the enforced boundary.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const encoder = new TextEncoder();
|
||||
const isPublic = env.publicReadonly;
|
||||
let closed = false;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
@@ -22,11 +30,24 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
};
|
||||
const onMetrics = (m: unknown) => send("metrics", m);
|
||||
const onEvent = (e: unknown) => send("mc-event", e);
|
||||
const onNotification = (n: unknown) => send("notification", n);
|
||||
const onEvent = (e: unknown) => {
|
||||
if (isPublic) {
|
||||
// Only join/leave may leave the public stream — drop connect (has
|
||||
// IPs), blocked, register-hint, server_* and anything else.
|
||||
const type = (e as { type?: string } | null)?.type;
|
||||
if (type !== "join" && type !== "leave") return;
|
||||
send("mc-event", { ts: (e as { ts?: number }).ts, type, player: (e as { player?: string }).player });
|
||||
return;
|
||||
}
|
||||
send("mc-event", e);
|
||||
};
|
||||
bus().on("metrics", onMetrics);
|
||||
bus().on("mc-event", onEvent);
|
||||
bus().on("notification", onNotification);
|
||||
|
||||
// Notifications are admin-only content; never subscribe in public mode.
|
||||
const onNotification = isPublic ? null : (n: unknown) => send("notification", n);
|
||||
if (onNotification) bus().on("notification", onNotification);
|
||||
|
||||
const keepalive = setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
@@ -39,7 +60,7 @@ export async function GET(req: NextRequest) {
|
||||
closed = true;
|
||||
bus().off("metrics", onMetrics);
|
||||
bus().off("mc-event", onEvent);
|
||||
bus().off("notification", onNotification);
|
||||
if (onNotification) bus().off("notification", onNotification);
|
||||
clearInterval(keepalive);
|
||||
try {
|
||||
controller.close();
|
||||
|
||||
@@ -19,10 +19,12 @@ import {
|
||||
Bell,
|
||||
LogOut,
|
||||
CircleDot,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||
import { Badge, Toaster } from "@/components/ui";
|
||||
import { timeAgo } from "@/lib/format";
|
||||
import { PublicModeProvider } from "@/lib/publicmode";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Overview", icon: Gauge },
|
||||
@@ -39,6 +41,10 @@ const NAV = [
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
// The only surfaces the public mirror exposes (must mirror the middleware
|
||||
// allowlist in src/proxy.ts).
|
||||
const PUBLIC_NAV_HREFS = new Set(["/", "/players", "/performance"]);
|
||||
|
||||
type ServerSummary = {
|
||||
container: { running: boolean; status: string };
|
||||
players: { online: number; max: number };
|
||||
@@ -54,14 +60,19 @@ type NotificationRow = {
|
||||
read: number;
|
||||
};
|
||||
|
||||
export function Shell({ children }: { children: React.ReactNode }) {
|
||||
export function Shell({ children, publicMode = false }: { children: React.ReactNode; publicMode?: boolean }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { data: server, refresh } = useApi<ServerSummary>("/api/server", 15000);
|
||||
const { data: me } = useApi<{ username: string; mustChange: boolean }>("/api/auth/me");
|
||||
// /api/auth/me is blocked (404) in public mode — don't request it.
|
||||
const { data: me } = useApi<{ username: string; mustChange: boolean }>("/api/auth/me", 0, {
|
||||
enabled: !publicMode,
|
||||
});
|
||||
const [running, setRunning] = useState<boolean | null>(null);
|
||||
const [players, setPlayers] = useState<number | null>(null);
|
||||
|
||||
const nav = publicMode ? NAV.filter((n) => PUBLIC_NAV_HREFS.has(n.href)) : NAV;
|
||||
|
||||
useSSE("/api/stream/events", {
|
||||
metrics: (m) => {
|
||||
const d = m as { running: boolean; players: number | null };
|
||||
@@ -98,11 +109,11 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-sm font-bold leading-none">CraftDeck</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted">server panel</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted">{publicMode ? "public mirror" : "server panel"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-4">
|
||||
{NAV.map((item) => {
|
||||
{nav.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
@@ -125,7 +136,15 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||
})}
|
||||
</nav>
|
||||
<div className="border-t border-border p-3 text-[11px] text-muted">
|
||||
Signed in as <span className="text-ink2">{me?.username ?? "…"}</span>
|
||||
{publicMode ? (
|
||||
<span className="flex items-center gap-1.5 text-ink2">
|
||||
<Eye size={12} /> Read-only public mirror
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
Signed in as <span className="text-ink2">{me?.username ?? "…"}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -148,17 +167,25 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell bump={unreadBump} />
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-xs text-ink2 hover:text-ink"
|
||||
>
|
||||
<LogOut size={13} /> Log out
|
||||
</button>
|
||||
{publicMode ? (
|
||||
<span className="flex items-center gap-1.5 rounded-full border border-accent-deep/40 bg-accent/10 px-2.5 py-1 text-xs font-medium text-accent">
|
||||
<Eye size={12} /> Read-only public mirror
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<NotificationBell bump={unreadBump} />
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-xs text-ink2 hover:text-ink"
|
||||
>
|
||||
<LogOut size={13} /> Log out
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{me?.mustChange && pathname !== "/settings" && (
|
||||
{!publicMode && me?.mustChange && pathname !== "/settings" && (
|
||||
<div className="border-b border-warn/30 bg-warn/10 px-6 py-2 text-xs text-warn">
|
||||
You are using the initial admin password —{" "}
|
||||
<Link href="/settings" className="underline">
|
||||
@@ -168,7 +195,9 @@ export function Shell({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
<main className="flex-1 p-6">
|
||||
<PublicModeProvider value={publicMode}>{children}</PublicModeProvider>
|
||||
</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
{/* refresh server summary when the tab regains focus */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { boot } = await import("./server/boot");
|
||||
boot();
|
||||
await boot();
|
||||
}
|
||||
}
|
||||
@@ -33,14 +33,20 @@ export async function api<T = unknown>(
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
/** Poll a GET endpoint; refresh() for manual refetch. */
|
||||
export function useApi<T>(path: string, intervalMs = 0) {
|
||||
/**
|
||||
* Poll a GET endpoint; refresh() for manual refetch. Pass { enabled: false }
|
||||
* to skip fetching entirely (e.g. an endpoint that 404s in public mode) —
|
||||
* data stays null and no request is made.
|
||||
*/
|
||||
export function useApi<T>(path: string, intervalMs = 0, opts: { enabled?: boolean } = {}) {
|
||||
const enabled = opts.enabled ?? true;
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
const d = await api<T>(path);
|
||||
if (!alive.current) return;
|
||||
@@ -52,9 +58,13 @@ export function useApi<T>(path: string, intervalMs = 0) {
|
||||
} finally {
|
||||
if (alive.current) setLoading(false);
|
||||
}
|
||||
}, [path]);
|
||||
}, [path, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
alive.current = true;
|
||||
setLoading(true);
|
||||
void refresh();
|
||||
@@ -63,7 +73,7 @@ export function useApi<T>(path: string, intervalMs = 0) {
|
||||
alive.current = false;
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [refresh, intervalMs]);
|
||||
}, [refresh, intervalMs, enabled]);
|
||||
|
||||
return { data, error, loading, refresh };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/**
|
||||
* Whether the panel is running as an anonymous read-only public mirror.
|
||||
* The value is resolved on the server (from PUBLIC_READONLY) in the panel
|
||||
* layout and handed to the client tree here, so components can hide admin
|
||||
* controls. This is a UX affordance only — the real enforcement is the
|
||||
* middleware allowlist in src/proxy.ts, which 404s every admin surface.
|
||||
*/
|
||||
const PublicModeContext = createContext(false);
|
||||
|
||||
export function PublicModeProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <PublicModeContext.Provider value={value}>{children}</PublicModeContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePublicMode(): boolean {
|
||||
return useContext(PublicModeContext);
|
||||
}
|
||||
@@ -3,7 +3,54 @@ import { SESSION_COOKIE, verifySessionToken } from "@/server/session";
|
||||
|
||||
const PUBLIC_PATHS = new Set(["/login", "/api/auth/login", "/api/health"]);
|
||||
|
||||
/**
|
||||
* PUBLIC_READONLY allowlist. When the flag is set the panel is an anonymous,
|
||||
* read-only mirror: there is no login and nothing can mutate the server, so
|
||||
* we default-deny (404) every path and explicitly permit only the handful of
|
||||
* GET surfaces that read from the exported feed.
|
||||
*
|
||||
* Deny-by-404 (not 401/403) so the public deployment never even hints that
|
||||
* an admin surface exists.
|
||||
*/
|
||||
const PUBLIC_ALLOWED_PAGES = new Set(["/", "/players", "/performance"]);
|
||||
const PUBLIC_ALLOWED_APIS = new Set([
|
||||
"/api/health", // liveness probe
|
||||
"/api/server", // overview status (from feed)
|
||||
"/api/metrics", // time-series history (from feed)
|
||||
"/api/activity", // join/leave feed (from feed)
|
||||
"/api/players", // online names + counts (from feed)
|
||||
"/api/lag", // performance verdict (from feed)
|
||||
"/api/stream/events", // SSE: metrics + join/leave only (from feed)
|
||||
]);
|
||||
|
||||
function publicReadonly(): boolean {
|
||||
return process.env.PUBLIC_READONLY === "1";
|
||||
}
|
||||
|
||||
function notFound(): NextResponse {
|
||||
// A bare 404 — no body that could reveal the admin app underneath.
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
function handlePublic(req: NextRequest): NextResponse {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// Only GET/HEAD can ever be served; every mutation surface is denied.
|
||||
if (req.method !== "GET" && req.method !== "HEAD") return notFound();
|
||||
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return PUBLIC_ALLOWED_APIS.has(pathname) ? NextResponse.next() : notFound();
|
||||
}
|
||||
|
||||
// App Router RSC navigations reuse the page pathname, so matching the
|
||||
// pathname covers both full loads and client transitions.
|
||||
return PUBLIC_ALLOWED_PAGES.has(pathname) ? NextResponse.next() : notFound();
|
||||
}
|
||||
|
||||
export async function proxy(req: NextRequest) {
|
||||
if (publicReadonly()) return handlePublic(req);
|
||||
|
||||
// ── Normal LAN admin mode (unchanged) ────────────────────────────────
|
||||
const { pathname } = req.nextUrl;
|
||||
if (PUBLIC_PATHS.has(pathname)) return NextResponse.next();
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DockerError } from "./docker";
|
||||
import { RconError } from "./rcon";
|
||||
import { DockerError, RconError } from "./errors";
|
||||
|
||||
/**
|
||||
* Small helpers for route handlers. Authentication is enforced globally in
|
||||
|
||||
@@ -1,30 +1,45 @@
|
||||
import fs from "node:fs";
|
||||
import { db } from "./db";
|
||||
import { env } from "./env";
|
||||
import { hashPassword, generatePassword } from "./passwords";
|
||||
import { startLogWatcher } from "./logwatch";
|
||||
import { startSampler } from "./sampler";
|
||||
import { startAuthWatcher } from "./authflow";
|
||||
import { startScheduler } from "./scheduler";
|
||||
|
||||
/**
|
||||
* One-time process bootstrap, invoked from instrumentation.ts when the
|
||||
* Next.js server starts. Guarded against dev-mode HMR re-execution.
|
||||
*
|
||||
* The admin subsystems (sampler, log watcher, auth watcher, scheduler) all
|
||||
* talk to Docker/RCON, so in PUBLIC_READONLY mode they are never imported —
|
||||
* the dynamic imports below keep docker.ts / rcon.ts / mc.ts / sampler.ts
|
||||
* out of the public deployment's module graph entirely. The public mirror
|
||||
* only starts the feed watcher, which reads the exported SQLite file.
|
||||
*/
|
||||
|
||||
const g = globalThis as unknown as { __craftdeckBooted?: boolean };
|
||||
|
||||
export function boot() {
|
||||
export async function boot() {
|
||||
if (g.__craftdeckBooted) return;
|
||||
g.__craftdeckBooted = true;
|
||||
|
||||
if (env.publicReadonly) {
|
||||
const { startFeedWatcher } = await import("./feed");
|
||||
startFeedWatcher();
|
||||
console.log(`[craftdeck] PUBLIC read-only mirror started (feed: ${env.feedPath})`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const dir of [env.appDataDir, env.mcDataDir, env.backupsDir]) {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
seedAdminUser();
|
||||
await seedAdminUser();
|
||||
|
||||
const [{ startLogWatcher }, { startSampler }, { startAuthWatcher }, { startScheduler }] =
|
||||
await Promise.all([
|
||||
import("./logwatch"),
|
||||
import("./sampler"),
|
||||
import("./authflow"),
|
||||
import("./scheduler"),
|
||||
]);
|
||||
startLogWatcher();
|
||||
startSampler();
|
||||
startAuthWatcher();
|
||||
@@ -33,7 +48,9 @@ export function boot() {
|
||||
console.log(`[craftdeck] panel services started (data: ${env.appDataDir})`);
|
||||
}
|
||||
|
||||
function seedAdminUser() {
|
||||
async function seedAdminUser() {
|
||||
const { db } = await import("./db");
|
||||
const { hashPassword, generatePassword } = await import("./passwords");
|
||||
const count = (db().prepare("SELECT COUNT(*) AS n FROM panel_users").get() as { n: number }).n;
|
||||
if (count > 0) return;
|
||||
let password = env.initialAdminPassword;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import http from "node:http";
|
||||
import { env } from "./env";
|
||||
import { DockerError } from "./errors";
|
||||
|
||||
/**
|
||||
* Minimal Docker Engine API client over the unix socket (no dependencies).
|
||||
@@ -7,15 +8,9 @@ import { env } from "./env";
|
||||
* unavailable" / "container missing" states instead of crashing.
|
||||
*/
|
||||
|
||||
export class DockerError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public kind: "unavailable" | "not_found" | "api" = "api",
|
||||
public statusCode?: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
// Re-exported for existing callers; the class itself lives in errors.ts so
|
||||
// that read-only routes can recognise it without importing this module.
|
||||
export { DockerError };
|
||||
|
||||
type ReqOptions = {
|
||||
method?: string;
|
||||
|
||||
@@ -14,6 +14,10 @@ function str(key: string, fallback: string): string {
|
||||
|
||||
export const env = {
|
||||
dev,
|
||||
// Public read-only mirror mode (see docs/public-mode.md).
|
||||
publicReadonly: process.env.PUBLIC_READONLY === "1",
|
||||
feedPath: path.resolve(str("FEED_PATH", dev ? "./dev-data/feed/feed.db" : "/feed/feed.db")),
|
||||
|
||||
sessionSecret: str("PANEL_SESSION_SECRET", dev ? "craftdeck-dev-secret-do-not-use" : ""),
|
||||
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Infrastructure error types, factored into a dependency-free leaf module.
|
||||
*
|
||||
* These live apart from docker.ts / rcon.ts on purpose: server/api.ts (which
|
||||
* every route imports for ok()/handle()) needs to *recognise* these errors,
|
||||
* but must not drag the Docker-socket or RCON client code into the import
|
||||
* graph of read-only routes. In PUBLIC_READONLY mode nothing may reach
|
||||
* docker.ts or rcon.ts, so those modules are only pulled in by the specific
|
||||
* admin code paths that actually talk to the server.
|
||||
*/
|
||||
|
||||
export class DockerError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public kind: "unavailable" | "not_found" | "api" = "api",
|
||||
public statusCode?: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class RconError extends Error {
|
||||
constructor(message: string, public kind: "offline" | "auth" | "timeout" = "offline") {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import fs from "node:fs";
|
||||
import { env } from "./env";
|
||||
import { bus } from "./bus";
|
||||
|
||||
/**
|
||||
* Public read-only data source. In PUBLIC_READONLY mode every byte the app
|
||||
* serves comes from a single pre-exported SQLite file (the "feed") written
|
||||
* by scripts/export-feed.mjs on the LAN box. The feed contains ONLY:
|
||||
*
|
||||
* metrics – the sampler time-series (7-day window)
|
||||
* activity – join/leave events, (ts, type, player) — no detail column,
|
||||
* so connect/blocked rows with IP addresses can never appear
|
||||
* feed_meta – a small status JSON (running, player names, version…)
|
||||
*
|
||||
* panel_users, approved_players, settings, notifications, schedules and
|
||||
* ping_samples are never exported, so they cannot leak from here.
|
||||
*
|
||||
* The exporter replaces the file atomically (write tmp + rename), so we
|
||||
* stat() before each access and reopen when the inode/mtime changed.
|
||||
*/
|
||||
|
||||
export type FeedStatus = {
|
||||
generatedAt: number;
|
||||
running: boolean;
|
||||
playersOnline: number;
|
||||
playersMax: number;
|
||||
playerNames: string[];
|
||||
version: string | null;
|
||||
serverType: string | null;
|
||||
};
|
||||
|
||||
type FeedHandle = { db: DatabaseSync; ino: number; mtimeMs: number };
|
||||
|
||||
const g = globalThis as unknown as {
|
||||
__craftdeckFeed?: FeedHandle | null;
|
||||
__craftdeckFeedWatch?: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
export function feedDb(): DatabaseSync | null {
|
||||
let st: fs.Stats;
|
||||
try {
|
||||
st = fs.statSync(env.feedPath);
|
||||
} catch {
|
||||
// feed not written yet — every reader degrades to "no data"
|
||||
if (g.__craftdeckFeed) {
|
||||
try {
|
||||
g.__craftdeckFeed.db.close();
|
||||
} catch {}
|
||||
g.__craftdeckFeed = null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const cur = g.__craftdeckFeed;
|
||||
if (cur && cur.ino === st.ino && cur.mtimeMs === st.mtimeMs) return cur.db;
|
||||
if (cur) {
|
||||
try {
|
||||
cur.db.close();
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const db = new DatabaseSync(env.feedPath, { readOnly: true });
|
||||
g.__craftdeckFeed = { db, ino: st.ino, mtimeMs: st.mtimeMs };
|
||||
return db;
|
||||
} catch {
|
||||
g.__craftdeckFeed = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function feedStatus(): FeedStatus | null {
|
||||
const db = feedDb();
|
||||
if (!db) return null;
|
||||
try {
|
||||
const row = db.prepare("SELECT value FROM feed_meta WHERE key = 'status'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
const s = JSON.parse(row.value) as FeedStatus;
|
||||
// A stale feed (exporter or LAN box down) must not present a live server.
|
||||
if (Date.now() - s.generatedAt > 5 * 60 * 1000) {
|
||||
return { ...s, running: false, playersOnline: 0, playerNames: [] };
|
||||
}
|
||||
return s;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function feedLatestMetrics(): Record<string, number | null> | null {
|
||||
const db = feedDb();
|
||||
if (!db) return null;
|
||||
try {
|
||||
return (
|
||||
(db.prepare("SELECT * FROM metrics ORDER BY ts DESC LIMIT 1").get() as
|
||||
| Record<string, number | null>
|
||||
| undefined) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the feed and re-emits fresh rows on the in-process bus, so the SSE
|
||||
* endpoint keeps pushing live-ish updates (bounded by the export interval)
|
||||
* without any RCON/Docker access. Only "metrics" and join/leave "mc-event"
|
||||
* events are ever emitted here — there is nothing else in the feed.
|
||||
*/
|
||||
export function startFeedWatcher() {
|
||||
if (g.__craftdeckFeedWatch) return;
|
||||
let lastMetricsTs = Date.now();
|
||||
let lastActivityTs = Date.now();
|
||||
|
||||
const tick = () => {
|
||||
const db = feedDb();
|
||||
if (!db) return;
|
||||
const status = feedStatus();
|
||||
try {
|
||||
const m = db
|
||||
.prepare("SELECT * FROM metrics WHERE ts > ? ORDER BY ts DESC LIMIT 1")
|
||||
.get(lastMetricsTs) as Record<string, number | null> | undefined;
|
||||
if (m) {
|
||||
lastMetricsTs = Number(m.ts);
|
||||
bus().emit("metrics", {
|
||||
ts: m.ts,
|
||||
running: status?.running ?? false,
|
||||
cpu: m.cpu,
|
||||
memUsed: m.mem_used,
|
||||
memLimit: m.mem_limit,
|
||||
tps: m.tps,
|
||||
mspt: m.mspt,
|
||||
players: m.players,
|
||||
diskFree: m.disk_free,
|
||||
});
|
||||
}
|
||||
const events = db
|
||||
.prepare(
|
||||
"SELECT ts, type, player FROM activity WHERE ts > ? AND type IN ('join','leave') ORDER BY ts ASC LIMIT 50"
|
||||
)
|
||||
.all(lastActivityTs) as Array<{ ts: number; type: string; player: string | null }>;
|
||||
for (const e of events) {
|
||||
lastActivityTs = e.ts;
|
||||
bus().emit("mc-event", { ts: e.ts, type: e.type, player: e.player ?? undefined });
|
||||
}
|
||||
} catch {
|
||||
// torn read while the exporter swaps the file — next tick recovers
|
||||
}
|
||||
};
|
||||
|
||||
g.__craftdeckFeedWatch = setInterval(tick, 10_000);
|
||||
tick();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import net from "node:net";
|
||||
import { env } from "./env";
|
||||
import { RconError } from "./errors";
|
||||
|
||||
/**
|
||||
* Minimal Source-RCON client (the protocol Minecraft uses). One persistent
|
||||
@@ -11,11 +12,9 @@ import { env } from "./env";
|
||||
const TYPE_AUTH = 3;
|
||||
const TYPE_COMMAND = 2;
|
||||
|
||||
export class RconError extends Error {
|
||||
constructor(message: string, public kind: "offline" | "auth" | "timeout" = "offline") {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
// Re-exported for existing callers; the class itself lives in errors.ts so
|
||||
// that read-only routes can recognise it without importing this module.
|
||||
export { RconError };
|
||||
|
||||
function encodePacket(id: number, type: number, body: string): Buffer {
|
||||
const bodyBuf = Buffer.from(body, "utf8");
|
||||
|
||||
Reference in new issue
Block a user