- 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>
123 lines
3.6 KiB
JavaScript
123 lines
3.6 KiB
JavaScript
// 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));
|
|
}
|