- 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>
190 lines
8.9 KiB
JavaScript
190 lines
8.9 KiB
JavaScript
// Admin-Panel-Logik. Auth laeuft ueber Cookie-Session; alle /api/admin/* sind geschuetzt.
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
async function api(path, opts = {}) {
|
|
const res = await fetch(path, opts);
|
|
let data = null;
|
|
try { data = await res.json(); } catch {}
|
|
return { ok: res.ok, status: res.status, data };
|
|
}
|
|
function toast(msg) {
|
|
const t = $('toast');
|
|
t.textContent = msg;
|
|
t.classList.add('show');
|
|
clearTimeout(toast._t);
|
|
toast._t = setTimeout(() => t.classList.remove('show'), 1800);
|
|
}
|
|
const esc = (s) => String(s == null ? '' : s);
|
|
function fmt(iso) {
|
|
try { return new Date(iso).toLocaleString('de-DE'); } catch { return iso; }
|
|
}
|
|
|
|
// ---------- Login ----------
|
|
function showLogin() {
|
|
$('login').classList.remove('hidden');
|
|
$('panel').classList.add('hidden');
|
|
$('pw').focus();
|
|
}
|
|
function showPanel() {
|
|
$('login').classList.add('hidden');
|
|
$('panel').classList.remove('hidden');
|
|
loadPlayers();
|
|
}
|
|
async function doLogin() {
|
|
const password = $('pw').value;
|
|
const { ok, status } = await api('/api/admin/login', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }),
|
|
});
|
|
if (ok) { $('pw').value = ''; $('login-err').textContent = ''; showPanel(); }
|
|
else $('login-err').textContent = status === 429 ? 'Zu viele Versuche — kurz warten.' : 'Falsches Passwort.';
|
|
}
|
|
$('login-btn').addEventListener('click', doLogin);
|
|
$('pw').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
|
$('logout-btn').addEventListener('click', async () => { await api('/api/admin/logout', { method: 'POST' }); showLogin(); });
|
|
|
|
// ---------- Tabs ----------
|
|
document.querySelectorAll('.tabs button').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.tabs button').forEach((b) => b.classList.remove('active'));
|
|
document.querySelectorAll('section.tab').forEach((s) => s.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
const tab = btn.dataset.tab;
|
|
$('tab-' + tab).classList.add('active');
|
|
if (tab === 'players') loadPlayers();
|
|
if (tab === 'settings') loadSettings();
|
|
if (tab === 'images') loadImages();
|
|
});
|
|
});
|
|
|
|
// ---------- Spieler ----------
|
|
async function loadPlayers() {
|
|
const { ok, data } = await api('/api/admin/players');
|
|
if (!ok) return showLogin();
|
|
const bans = new Set((data.bans || []).map((b) => String(b).toLowerCase()));
|
|
const players = data.players || [];
|
|
if (!players.length) { $('players-wrap').innerHTML = '<p class="muted">Noch keine Spieler erfasst.</p>'; return; }
|
|
|
|
const rows = players.map((p) => {
|
|
const banned = bans.has(String(p.name).toLowerCase());
|
|
const chips = (arr) => (arr || []).map((x) => `<span class="chip"></span>`).join('');
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML =
|
|
`<td><b class="pname"></b>${banned ? ' <span class="chip" style="color:#ff9bb2">gebannt</span>' : ''}</td>` +
|
|
`<td>${p.bestScore}</td><td>${p.games}</td><td>${fmt(p.lastAt)}</td>` +
|
|
`<td class="ips chips"></td><td class="locs chips"></td><td class="devs chips"></td><td class="brs chips"></td>` +
|
|
`<td><button class="${banned ? '' : 'danger'} act">${banned ? 'Entbannen' : 'Bannen'}</button></td>`;
|
|
tr.querySelector('.pname').textContent = p.name; // XSS-safe
|
|
const fill = (sel, arr) => { const c = tr.querySelector(sel); (arr || []).forEach((x) => { const s = document.createElement('span'); s.className = 'chip'; s.textContent = x; c.appendChild(s); }); };
|
|
fill('.ips', p.ips); fill('.locs', p.locations); fill('.devs', p.devices); fill('.brs', p.browsers);
|
|
tr.querySelector('.act').addEventListener('click', () => (banned ? unban(p.name) : ban(p.name)));
|
|
return tr;
|
|
});
|
|
|
|
const table = document.createElement('table');
|
|
table.innerHTML = '<thead><tr><th>Name</th><th>Best</th><th>Spiele</th><th>Zuletzt</th><th>IP(s)</th><th>Ort</th><th>Gerät</th><th>Browser</th><th></th></tr></thead>';
|
|
const tbody = document.createElement('tbody');
|
|
rows.forEach((r) => tbody.appendChild(r));
|
|
table.appendChild(tbody);
|
|
$('players-wrap').innerHTML = '';
|
|
$('players-wrap').appendChild(table);
|
|
}
|
|
async function ban(name) {
|
|
if (!confirm(`"${name}" bannen? Bestehende Einträge werden entfernt.`)) return;
|
|
await api('/api/admin/ban', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
|
|
toast('Gebannt: ' + name); loadPlayers();
|
|
}
|
|
async function unban(name) {
|
|
await api('/api/admin/unban', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
|
|
toast('Entbannt: ' + name); loadPlayers();
|
|
}
|
|
|
|
// ---------- Einstellungen ----------
|
|
const CFG_LABELS = {
|
|
startTime: 'Startzeit (s)', maxTime: 'Max-Zeit (s)', bonus: 'Bonus pro Treffer (s)', penalty: 'Strafe pro Fehlklick (s)',
|
|
countBase: 'Objekte: Basis', countPerScore: 'Objekte: pro Score', stepStart: 'Stufen ab Score', stepEvery: 'Stufe alle N Punkte', stepAdd: 'Objekte pro Stufe', countCap: 'Objekte max.',
|
|
stageScatter: 'Ab Score: verstreut', stageGroups: 'Ab Score: Gruppen', stageIndividual: 'Ab Score: einzeln',
|
|
speedGroupsBase: 'Tempo Gruppen (Basis)', speedGroupsPer: 'Tempo Gruppen (pro Score)', speedGroupsMax: 'Tempo Gruppen (max)',
|
|
speedIndivBase: 'Tempo einzeln (Basis)', speedIndivPer: 'Tempo einzeln (pro Score)', speedIndivMax: 'Tempo einzeln (max)',
|
|
};
|
|
async function loadSettings() {
|
|
const { ok, data } = await api('/api/admin/config');
|
|
if (!ok) return showLogin();
|
|
const grid = $('settings-grid');
|
|
grid.innerHTML = '';
|
|
Object.keys(data).forEach((key) => {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'field';
|
|
const label = document.createElement('label');
|
|
label.textContent = CFG_LABELS[key] || key;
|
|
const input = document.createElement('input');
|
|
input.type = 'number'; input.step = 'any'; input.dataset.key = key; input.value = data[key];
|
|
wrap.append(label, input);
|
|
grid.appendChild(wrap);
|
|
});
|
|
}
|
|
$('cfg-save').addEventListener('click', async () => {
|
|
const body = {};
|
|
document.querySelectorAll('#settings-grid input').forEach((i) => (body[i.dataset.key] = Number(i.value)));
|
|
const { ok } = await api('/api/admin/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
$('cfg-status').textContent = ok ? '✓ gespeichert' : 'Fehler';
|
|
if (ok) toast('Einstellungen gespeichert');
|
|
setTimeout(() => ($('cfg-status').textContent = ''), 2500);
|
|
});
|
|
|
|
// ---------- Bilder ----------
|
|
async function loadImages() {
|
|
const { ok, data } = await api('/api/admin/images');
|
|
if (!ok) return showLogin();
|
|
renderGallery('feet-gallery', 'feet', data.feet || []);
|
|
renderGallery('props-gallery', 'props', data.props || []);
|
|
}
|
|
function renderGallery(elId, kind, list) {
|
|
const el = $(elId);
|
|
el.innerHTML = '';
|
|
if (!list.length) { el.innerHTML = '<p class="muted">Keine Bilder.</p>'; return; }
|
|
list.forEach((img) => {
|
|
const file = img.url.split('/').pop();
|
|
const div = document.createElement('div');
|
|
div.className = 'thumb';
|
|
const im = document.createElement('img'); im.src = img.url; im.alt = img.name;
|
|
const fn = document.createElement('div'); fn.className = 'fn'; fn.textContent = file;
|
|
const del = document.createElement('button'); del.className = 'danger'; del.textContent = 'Löschen'; del.style.width = '100%';
|
|
del.addEventListener('click', () => deleteImage(kind, file));
|
|
div.append(im, fn, del);
|
|
el.appendChild(div);
|
|
});
|
|
}
|
|
async function deleteImage(kind, file) {
|
|
if (!confirm(`"${file}" löschen?`)) return;
|
|
const { ok } = await api(`/api/admin/images/${kind}/${encodeURIComponent(file)}`, { method: 'DELETE' });
|
|
if (ok) { toast('Gelöscht'); loadImages(); } else toast('Löschen fehlgeschlagen');
|
|
}
|
|
document.querySelectorAll('button[data-up]').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
const kind = btn.dataset.up;
|
|
const input = $(kind + '-file');
|
|
if (!input.files || !input.files[0]) return toast('Keine Datei gewählt');
|
|
const fd = new FormData();
|
|
fd.append('image', input.files[0]);
|
|
const res = await fetch(`/api/admin/images/${kind}`, { method: 'POST', body: fd });
|
|
if (res.ok) { toast('Hochgeladen'); input.value = ''; loadImages(); }
|
|
else { const d = await res.json().catch(() => ({})); toast(d.error || 'Upload fehlgeschlagen'); }
|
|
});
|
|
});
|
|
|
|
// ---------- Rangliste ----------
|
|
document.querySelectorAll('button[data-reset]').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
const scope = btn.dataset.reset;
|
|
if (!confirm(scope === 'all' ? 'WIRKLICH die komplette Rangliste leeren?' : 'Heutige Rangliste leeren?')) return;
|
|
const { ok, data } = await api('/api/admin/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope }) });
|
|
toast(ok ? `Geleert (${data.removed} Einträge)` : 'Fehler');
|
|
});
|
|
});
|
|
|
|
// ---------- Init ----------
|
|
(async () => {
|
|
const { ok } = await api('/api/admin/me');
|
|
if (ok) showPanel(); else showLogin();
|
|
})();
|