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

View File

@@ -3,14 +3,25 @@
// der verfuegbaren Fuesse/Deko bereit und verwaltet eine persistente Rangliste.
import express from 'express';
import { dirname, join } from 'node:path';
import multer from 'multer';
import { dirname, join, basename, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, readdirSync } from 'node:fs';
import { existsSync, readdirSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { readLeaderboard, buildBoards, addScore } from './leaderboardStore.js';
import { readLeaderboard, buildBoards, addScore, clearAll, clearToday, deletePlayer } from './leaderboardStore.js';
import { getMode } from './modeStore.js';
import { isBanned } from './banStore.js';
import { isBanned, banPlayer, unbanPlayer, readBans } from './banStore.js';
import { getConfig, setConfig, objectCount } from './configStore.js';
import { logGame, aggregatePlayers } from './playerLog.js';
import {
verifyPassword,
createSession,
validSession,
destroySession,
SESSION_COOKIE,
SESSION_MAX_AGE,
} from './adminAuth.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
@@ -38,8 +49,15 @@ function listImages(dir, urlPrefix) {
.map((f) => ({ id: f.replace(/\.[^.]+$/, ''), name: prettyName(f), url: `${urlPrefix}/${f}` }));
}
const feet = listImages(join(LIBRARY_DIR, 'feet'), '/library/feet');
const props = listImages(join(LIBRARY_DIR, 'props'), '/library/props');
let feet = listImages(join(LIBRARY_DIR, 'feet'), '/library/feet');
let props = listImages(join(LIBRARY_DIR, 'props'), '/library/props');
// Nach Upload/Loeschen die Bild-Listen neu einlesen (kein Serverneustart noetig).
function rescanLibrary() {
feet = listImages(join(LIBRARY_DIR, 'feet'), '/library/feet');
props = listImages(join(LIBRARY_DIR, 'props'), '/library/props');
return { feet: feet.length, props: props.length };
}
if (feet.length < 2) {
console.warn(`[server] Achtung: nur ${feet.length} Fuss/Fuesse in library/feet — brauche mind. 2.`);
@@ -48,6 +66,7 @@ if (feet.length < 2) {
}
const app = express();
app.set('trust proxy', true); // korrekte Client-IP hinter Reverse-Proxy/Cloudflare
app.use(express.json());
// Caching komplett verhindern (Browser UND Proxys wie Cloudflare). Sonst laden bei
@@ -100,16 +119,10 @@ function shuffleArr(a) {
}
const pickRand = (arr) => arr[Math.floor(Math.random() * arr.length)];
// Objekt-Anzahl (muss zur Client-Formel passen)
function objectCount(score) {
let count = 10 + Math.floor(score * 1.5);
if (score >= 15) count += Math.floor((score - 15) / 3) * 5;
return Math.min(count, 100);
}
// Neue Runde: 1 Ziel-Fuss + Ablenker (andere Fuesse + etwas Deko), gemischt.
// Objekt-Anzahl kommt aus der (live editierbaren) Config.
function makeRound(hits) {
const count = objectCount(hits);
const count = objectCount(getConfig(), hits);
const target = pickRand(feet);
const otherFeet = feet.filter((f) => f.url !== target.url);
const items = [{ id: randomUUID(), url: target.url, isTarget: true }];
@@ -185,11 +198,26 @@ app.get('/api/status', (req, res) => {
res.json({ allowed: m.mode === 'public' || slug === m.path });
});
// Client-IP hinter Reverse-Proxy/Cloudflare ermitteln
function clientIp(req) {
return (
req.headers['cf-connecting-ip'] ||
(req.headers['x-forwarded-for'] || '').split(',')[0].trim() ||
req.ip ||
''
);
}
// Ranglisten: Allzeit-Top-5 + Heute-Top-10
app.get('/api/leaderboard', (req, res) => {
res.json(buildBoards(readLeaderboard()));
});
// Spiel-Parameter fuer den Client (live editierbar ueber das Admin-Panel)
app.get('/api/config', (req, res) => {
res.json(getConfig());
});
// Score eintragen -> Score kommt aus der SERVER-Session (Anticheat), nicht vom Client.
app.post('/api/score', (req, res) => {
const { name, sessionId } = req.body || {};
@@ -203,7 +231,133 @@ app.post('/api/score', (req, res) => {
const cleanName = String(name || 'Anonym').trim().slice(0, 20) || 'Anonym';
if (isBanned(cleanName)) return res.status(403).json({ error: 'Name gesperrt.' });
res.json(addScore(cleanName, score));
const boards = addScore(cleanName, score);
// Tracking (nur approximativ; fuer Moderation im Admin-Panel)
try {
logGame({
name: cleanName,
score,
ip: clientIp(req),
ua: req.headers['user-agent'] || '',
cfCountry: req.headers['cf-ipcountry'] || '',
});
} catch {}
res.json(boards);
});
// ================= Admin-Panel =================
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
const ALLOWED_UPLOAD_EXT = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif'];
function parseCookies(req) {
const out = {};
const h = req.headers.cookie;
if (!h) return out;
for (const part of h.split(';')) {
const i = part.indexOf('=');
if (i > -1) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
}
return out;
}
const isAuthed = (req) => validSession(parseCookies(req)[SESSION_COOKIE]);
function requireAdmin(req, res, next) {
if (isAuthed(req)) return next();
res.status(401).json({ error: 'Nicht angemeldet.' });
}
// Login-Rate-Limit pro IP (5 Versuche/Minute)
const loginHits = new Map();
function loginAllowed(ip) {
const now = Date.now();
const e = loginHits.get(ip);
if (!e || now > e.resetAt) {
loginHits.set(ip, { count: 1, resetAt: now + 60000 });
return true;
}
e.count += 1;
return e.count <= 5;
}
app.post('/api/admin/login', (req, res) => {
if (!loginAllowed(clientIp(req))) return res.status(429).json({ error: 'Zu viele Versuche. Kurz warten.' });
if (!verifyPassword(req.body && req.body.password)) return res.status(401).json({ error: 'Falsches Passwort.' });
const token = createSession();
const secure = req.secure || req.headers['x-forwarded-proto'] === 'https';
res.setHeader(
'Set-Cookie',
`${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(SESSION_MAX_AGE / 1000)}${secure ? '; Secure' : ''}`
);
res.json({ ok: true });
});
app.post('/api/admin/logout', (req, res) => {
destroySession(parseCookies(req)[SESSION_COOKIE]);
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; HttpOnly; Path=/; Max-Age=0`);
res.json({ ok: true });
});
app.get('/api/admin/me', requireAdmin, (req, res) => res.json({ ok: true }));
app.get('/api/admin/players', requireAdmin, (req, res) => {
res.json({ players: aggregatePlayers(), bans: readBans() });
});
app.post('/api/admin/ban', requireAdmin, (req, res) => {
const name = req.body && req.body.name;
if (!name) return res.status(400).json({ error: 'Name fehlt.' });
banPlayer(name);
const removed = deletePlayer(name);
res.json({ ok: true, removed });
});
app.post('/api/admin/unban', requireAdmin, (req, res) => {
unbanPlayer(req.body && req.body.name);
res.json({ ok: true });
});
app.post('/api/admin/reset', requireAdmin, (req, res) => {
const n = (req.body && req.body.scope) === 'all' ? clearAll() : clearToday();
res.json({ ok: true, removed: n });
});
app.get('/api/admin/config', requireAdmin, (req, res) => res.json(getConfig()));
app.post('/api/admin/config', requireAdmin, (req, res) => res.json(setConfig(req.body || {})));
app.get('/api/admin/images', requireAdmin, (req, res) => res.json({ feet, props }));
app.post('/api/admin/images/:kind', requireAdmin, upload.single('image'), (req, res) => {
const kind = req.params.kind === 'props' ? 'props' : req.params.kind === 'feet' ? 'feet' : null;
if (!kind) return res.status(400).json({ error: 'Ungueltige Kategorie.' });
if (!req.file) return res.status(400).json({ error: 'Keine Datei.' });
const ext = extname(req.file.originalname).toLowerCase();
if (!ALLOWED_UPLOAD_EXT.includes(ext)) return res.status(400).json({ error: 'Dateityp nicht erlaubt.' });
const base =
basename(req.file.originalname, extname(req.file.originalname))
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40) || 'bild';
const dir = join(LIBRARY_DIR, kind);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, base + ext), req.file.buffer);
rescanLibrary();
res.json({ ok: true, file: base + ext });
});
app.delete('/api/admin/images/:kind/:file', requireAdmin, (req, res) => {
const kind = req.params.kind === 'props' ? 'props' : req.params.kind === 'feet' ? 'feet' : null;
if (!kind) return res.status(400).json({ error: 'Ungueltige Kategorie.' });
const file = basename(req.params.file); // kein Pfad-Ausbruch
const p = join(LIBRARY_DIR, kind, file);
if (!existsSync(p)) return res.status(404).json({ error: 'Datei nicht gefunden.' });
try {
unlinkSync(p);
} catch {
return res.status(500).json({ error: 'Loeschen fehlgeschlagen.' });
}
rescanLibrary();
res.json({ ok: true });
});
// ---- Mode-Gates (Live: getMode() liest pro Request die Datei) ----
@@ -212,6 +366,9 @@ function sendHtml(res, file) {
res.sendFile(join(PUBLIC_DIR, file));
}
// Admin-Seite: immer erreichbar (Auth-Check macht die API); vor dem /:slug-Gate.
app.get('/admin', (req, res) => sendHtml(res, 'admin.html'));
// Startseite: public -> Spiel, private -> "nicht erlaubt"
app.get('/', (req, res) => {
sendHtml(res, getMode().mode === 'private' ? 'blocked.html' : 'index.html');