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:
122
server/playerLog.js
Normal file
122
server/playerLog.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// Spieler-Log: pro Score-Eintrag ein Datensatz mit IP, grobem Ort, Geraet & Browser.
|
||||
// Persistiert in data/players.json (append, gedeckelt). Nur approximativ (kein Login).
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import UAParser from 'ua-parser-js';
|
||||
import geoip from 'geoip-lite';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = join(__dirname, '..', 'data');
|
||||
const PLAYERS_FILE = join(DATA_DIR, 'players.json');
|
||||
const MAX_RECORDS = 5000;
|
||||
|
||||
function readAll() {
|
||||
try {
|
||||
if (!existsSync(PLAYERS_FILE)) return [];
|
||||
const data = JSON.parse(readFileSync(PLAYERS_FILE, 'utf8'));
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(list) {
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
writeFileSync(PLAYERS_FILE, JSON.stringify(list, null, 2));
|
||||
}
|
||||
|
||||
// User-Agent -> { device, browser, os }
|
||||
function parseUA(ua) {
|
||||
try {
|
||||
const r = new UAParser(ua || '').getResult();
|
||||
return {
|
||||
device: r.device && r.device.type ? cap(r.device.type) : 'Desktop',
|
||||
browser: [r.browser.name, r.browser.version].filter(Boolean).join(' ') || 'unbekannt',
|
||||
os: [r.os.name, r.os.version].filter(Boolean).join(' ') || 'unbekannt',
|
||||
};
|
||||
} catch {
|
||||
return { device: '?', browser: '?', os: '?' };
|
||||
}
|
||||
}
|
||||
|
||||
// IP -> { country, city } (offline, grob)
|
||||
function geoLookup(ip, cfCountry) {
|
||||
try {
|
||||
const g = geoip.lookup(cleanIp(ip));
|
||||
if (g) return { country: g.country || cfCountry || '', city: g.city || '' };
|
||||
} catch {}
|
||||
return { country: cfCountry || '', city: '' };
|
||||
}
|
||||
|
||||
function cleanIp(ip) {
|
||||
return String(ip || '').replace(/^::ffff:/, '');
|
||||
}
|
||||
const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||
|
||||
// Einen Spiel-Abschluss protokollieren.
|
||||
export function logGame({ name, score, ip, ua, cfCountry }) {
|
||||
const { device, browser, os } = parseUA(ua);
|
||||
const { country, city } = geoLookup(ip, cfCountry);
|
||||
const list = readAll();
|
||||
list.push({
|
||||
name: String(name || 'Anonym'),
|
||||
score: Number(score) || 0,
|
||||
at: new Date().toISOString(),
|
||||
ip: cleanIp(ip),
|
||||
device,
|
||||
browser,
|
||||
os,
|
||||
country,
|
||||
city,
|
||||
});
|
||||
writeAll(list.slice(-MAX_RECORDS));
|
||||
}
|
||||
|
||||
// Aggregiert die Log-Eintraege pro Spielername (case-insensitiv).
|
||||
export function aggregatePlayers() {
|
||||
const byName = new Map();
|
||||
for (const r of readAll()) {
|
||||
const key = String(r.name || '').trim().toLowerCase();
|
||||
if (!byName.has(key)) {
|
||||
byName.set(key, {
|
||||
name: r.name,
|
||||
games: 0,
|
||||
bestScore: 0,
|
||||
firstAt: r.at,
|
||||
lastAt: r.at,
|
||||
ips: new Set(),
|
||||
locations: new Set(),
|
||||
devices: new Set(),
|
||||
browsers: new Set(),
|
||||
oses: new Set(),
|
||||
});
|
||||
}
|
||||
const p = byName.get(key);
|
||||
p.games += 1;
|
||||
p.bestScore = Math.max(p.bestScore, r.score || 0);
|
||||
if (r.at < p.firstAt) p.firstAt = r.at;
|
||||
if (r.at > p.lastAt) p.lastAt = r.at;
|
||||
if (r.ip) p.ips.add(r.ip);
|
||||
const loc = [r.city, r.country].filter(Boolean).join(', ');
|
||||
if (loc) p.locations.add(loc);
|
||||
if (r.device) p.devices.add(r.device);
|
||||
if (r.browser) p.browsers.add(r.browser);
|
||||
if (r.os) p.oses.add(r.os);
|
||||
}
|
||||
return [...byName.values()]
|
||||
.map((p) => ({
|
||||
name: p.name,
|
||||
games: p.games,
|
||||
bestScore: p.bestScore,
|
||||
firstAt: p.firstAt,
|
||||
lastAt: p.lastAt,
|
||||
ips: [...p.ips],
|
||||
locations: [...p.locations],
|
||||
devices: [...p.devices],
|
||||
browsers: [...p.browsers],
|
||||
oses: [...p.oses],
|
||||
}))
|
||||
.sort((a, b) => new Date(b.lastAt) - new Date(a.lastAt));
|
||||
}
|
||||
Reference in New Issue
Block a user