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:
1 parent
56d2078e6a
commit
e66f89f49e
214 files changed
+27077
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user