Admin-Panel (/admin): Auth, Spieler-Tracking, Live-Tuning, Bild-Verwaltung

- Passwortgeschütztes Panel unter /admin (Auto-Passwort 20 Zeichen, per CLI abrufbar:
  `node server/admin.js password` / `password reset`), In-Memory-Sessions + Cookie,
  Login-Rate-Limit.
- Spieler-Tracking: pro Score IP (CF/XFF/req.ip), Gerät/Browser (ua-parser-js),
  grober Ort (geoip-lite, offline) -> data/players.json; Aggregation pro Name.
- Live-Tuning: configStore (data/config.json) für Timer + Schwierigkeitskurve; Server
  (makeRound) und Client (/api/config) lesen daraus -> Änderungen ohne Neustart.
- Bild-Verwaltung: Upload/Löschen von Füßen & Deko (multer) + rescanLibrary().
- Panel-Aktionen: Spieler bannen/entbannen, Rangliste heute/alles leeren.
- Fix: .item object-fit:contain -> nicht-quadratische Bilder werden nicht mehr verzerrt.
- trust proxy für korrekte Client-IP hinter Reverse-Proxy/Cloudflare.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-05 22:19:52 +02:00
parent 51909deb9f
commit 5018ab753a
12 changed files with 1357 additions and 92 deletions

98
server/adminAuth.js Normal file
View File

@@ -0,0 +1,98 @@
// Admin-Authentifizierung: automatisch generiertes Passwort (20 Zeichen), persistent in
// data/admin.json (Klartext, chmod 600 -> per CLI abrufbar). Sessions liegen im Speicher.
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = join(__dirname, '..', 'data');
const ADMIN_FILE = join(DATA_DIR, 'admin.json');
const LOWER = 'abcdefghijkmnopqrstuvwxyz';
const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const DIGITS = '23456789';
const SPECIAL = '!@#$%&*()-_=+?';
const ALL = LOWER + UPPER + DIGITS + SPECIAL;
const PW_LEN = 20;
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage
const sessions = new Map(); // token -> expiresAt
function generatePassword() {
// je 1 Zeichen aus jeder Klasse garantieren, Rest zufaellig, dann mischen
const pick = (set) => set[randomInt(set.length)];
const chars = [pick(LOWER), pick(UPPER), pick(DIGITS), pick(SPECIAL)];
while (chars.length < PW_LEN) chars.push(pick(ALL));
for (let i = chars.length - 1; i > 0; i--) {
const j = randomInt(i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
}
function write(data) {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
writeFileSync(ADMIN_FILE, JSON.stringify(data, null, 2));
try {
chmodSync(ADMIN_FILE, 0o600);
} catch {}
}
// Liest die Admin-Daten; legt beim ersten Mal Passwort + Secret an.
function ensure() {
if (existsSync(ADMIN_FILE)) {
try {
const data = JSON.parse(readFileSync(ADMIN_FILE, 'utf8'));
if (data && data.password) return data;
} catch {}
}
const data = { password: generatePassword(), secret: randomBytes(32).toString('hex') };
write(data);
return data;
}
export function getPassword() {
return ensure().password;
}
export function resetPassword() {
const data = ensure();
data.password = generatePassword();
write(data);
return data.password;
}
export function verifyPassword(input) {
const real = ensure().password;
const a = Buffer.from(String(input || ''));
const b = Buffer.from(real);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
// --- Sessions (In-Memory) ---
export function createSession() {
const token = randomBytes(32).toString('hex');
sessions.set(token, Date.now() + SESSION_TTL_MS);
return token;
}
export function validSession(token) {
if (!token) return false;
const exp = sessions.get(token);
if (!exp) return false;
if (Date.now() > exp) {
sessions.delete(token);
return false;
}
return true;
}
export function destroySession(token) {
if (token) sessions.delete(token);
}
export const SESSION_COOKIE = 'admin_session';
export const SESSION_MAX_AGE = SESSION_TTL_MS;