feat: ATM10-Statistikportal

Statistikportal für den All-the-Mods-10-Server (Minecraft 1.21.1, NeoForge 21.1):

- mod/: serverseitige NeoForge-Mod atmstats – Ereignisse, Live-Zähler, Servergesundheit,
  FTB-Dateien; Outbox auf der Festplatte, signierter gzip-Versand per HTTPS mit Backoff
- backend/: Fastify + PostgreSQL – idempotenter Ingest, Projektionen, Parser für SNBT,
  FTB Quests/Teams, world/stats, world/advancements und latest.log; Ranglisten, Snapshots,
  Retention, öffentliche API, SSE-Live-Updates, Admin mit argon2/CSRF, SFTP-Worker
- web/: Next.js-Dashboard – Übersicht, Ranglisten, Spielerprofile, ATM10, Server,
  Datenquellen, Verwaltung
- shared/: Protokoll v1 und API-Typen; tools/: Simulator, Log-Anonymisierer, RCON-Client
- deploy/: Docker Compose hinter Traefik, Backup; docs/: Architektur, Datenquellen, Betrieb

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lucashahmannandClaude Opus 5.5 committed 2026-10-13 09:33:25 +02:00
1 parent 56d2078e6a
commit e66f89f49e
214 files changed
+27077

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { buildApp } from '../../src/app.ts';
import { loadConfig } from '../../src/config.ts';
import { createIngestToken } from '../../src/ingest/tokens.ts';
import { TEST_SECRET } from './sign.ts';
export function testConfig() {
return loadConfig({
NODE_ENV: 'test',
DATABASE_URL: process.env.TEST_DATABASE_URL,
INGEST_HMAC_SECRET: TEST_SECRET,
LOG_LEVEL: 'silent',
});
}
export async function makeApp(pool: Pool): Promise<{ app: FastifyInstance; token: string }> {
const app = await buildApp({ pool, config: testConfig() });
const token = await createIngestToken(pool);
return { app, token };
}
+26
View File
@@ -0,0 +1,26 @@
import type { Pool } from 'pg';
import { createPool } from '../../src/db/pool.ts';
import { migrate } from '../../src/db/migrate.ts';
let pool: Pool | undefined;
export async function getTestPool(): Promise<Pool> {
if (!pool) {
pool = createPool(process.env.TEST_DATABASE_URL!);
await migrate(pool);
}
return pool;
}
export async function resetDb(p: Pool): Promise<void> {
const { rows } = await p.query<{ tablename: string }>(
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> 'schema_migrations'`,
);
if (rows.length === 0) return;
await p.query(`TRUNCATE ${rows.map((r) => `"${r.tablename}"`).join(', ')} RESTART IDENTITY CASCADE`);
}
export async function closeTestPool(): Promise<void> {
await pool?.end();
pool = undefined;
}
+16
View File
@@ -0,0 +1,16 @@
import { randomUUID } from 'node:crypto';
import type { AtmEvent, EventOf, EventType } from '@atm/shared';
export const ALICE = { uuid: '308a693c-2bd8-4d6b-8790-670aef5be601', name: 'CDOGamer' };
export const BOB = { uuid: 'c25d9ff0-dba3-48f0-b414-fad2b63473a9', name: 'Bob' };
type Payload<T extends EventType> = Omit<EventOf<T>, 'id' | 'type' | 'ts'> & { ts?: number; id?: string };
/** Test factory: ev('player_join', { ...ALICE, ts }) */
export function ev<T extends EventType>(type: T, payload: Payload<T>): AtmEvent {
const { ts, id, ...rest } = payload as Payload<T>;
return { id: id ?? randomUUID(), type, ts: ts ?? Date.now(), ...rest } as unknown as AtmEvent;
}
export const T0 = Date.UTC(2026, 9, 12, 9, 0, 0);
export const MIN = 60_000;
+34
View File
@@ -0,0 +1,34 @@
import { createHash, createHmac } from 'node:crypto';
import { gzipSync } from 'node:zlib';
export const TEST_SECRET = 'test-hmac-secret-0123456789abcdef0123';
export function signature(secret: string, ts: number, body: Buffer): string {
const bodyHash = createHash('sha256').update(body).digest('hex');
return createHmac('sha256', secret).update(`${ts}.${bodyHash}`).digest('hex');
}
export interface SignOpts {
token: string;
secret?: string;
ts?: number;
gzip?: boolean;
serverId?: string;
}
/** Builds headers + payload exactly as the mod sends them: signature covers the transmitted (gzipped) bytes. */
export function signedRequest(body: unknown, opts: SignOpts): { headers: Record<string, string>; payload: Buffer } {
const raw = Buffer.from(typeof body === 'string' ? body : JSON.stringify(body));
const gzip = opts.gzip ?? true;
const payload = gzip ? gzipSync(raw) : raw;
const ts = opts.ts ?? Date.now();
const headers: Record<string, string> = {
authorization: `Bearer ${opts.token}`,
'content-type': 'application/json',
'x-atm-server': opts.serverId ?? 'atm10',
'x-atm-timestamp': String(ts),
'x-atm-signature': signature(opts.secret ?? TEST_SECRET, ts, payload),
};
if (gzip) headers['content-encoding'] = 'gzip';
return { headers, payload };
}