Add protected unlock-code management

Add admin UI for generating and listing unlock codes, including redemption status and notes.
This commit is contained in:
Emporex committed 2026-10-09 22:33:58 +02:00
1 parent aa6d7667b5
commit 3e6132ee9a
1 file changed
+90 -3
+90 -3
View File
@@ -37,6 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['username'], $_POST['p
if ($userOk && $passwordOk) { if ($userOk && $passwordOk) {
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['authenticated'] = true; $_SESSION['authenticated'] = true;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: ./'); header('Location: ./');
exit; exit;
} }
@@ -94,6 +95,51 @@ if (empty($_SESSION['authenticated'])):
exit; exit;
endif; endif;
if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$promoFlash = $_SESSION['promo_flash'] ?? null;
unset($_SESSION['promo_flash']);
$promoError = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['promo_action'])) {
$providedCsrf = (string) ($_POST['csrf_token'] ?? '');
if (!hash_equals((string) $_SESSION['csrf_token'], $providedCsrf)) {
$promoError = 'Die Sitzung ist abgelaufen. Bitte lade die Seite neu und versuche es erneut.';
} else {
$reward = (string) ($_POST['reward'] ?? '');
$maxUses = filter_var($_POST['max_uses'] ?? '1', FILTER_VALIDATE_INT);
$note = trim((string) ($_POST['note'] ?? ''));
$noteLength = function_exists('mb_strlen') ? mb_strlen($note, 'UTF-8') : strlen($note);
$httpKey = trim((string) ($config['nakama_http_key'] ?? ''));
$rpcBase = preg_replace('~/private_stats$~', '/create_unlock_code', (string) ($config['nakama_rpc_url'] ?? ''));
if (!in_array($reward, ['all_towers', 'all_maps', 'all'], true)) {
$promoError = 'Bitte wähle einen gültigen Freischaltumfang.';
} elseif ($maxUses === false || $maxUses < 1 || $maxUses > 50000) {
$promoError = 'Die Anzahl der Einlösungen muss zwischen 1 und 50.000 liegen.';
} elseif ($noteLength > 160) {
$promoError = 'Die Bemerkung darf höchstens 160 Zeichen lang sein.';
} elseif ($httpKey === '' || !is_string($rpcBase) || $rpcBase === (string) ($config['nakama_rpc_url'] ?? '')) {
$promoError = 'Die interne Codeverwaltung ist noch nicht mit Nakama verbunden.';
} else {
$url = $rpcBase . '?http_key=' . rawurlencode($httpKey) . '&unwrap';
$response = fetchJson($url, 'POST', json_encode(['reward' => $reward, 'max_uses' => $maxUses, 'note' => $note], JSON_THROW_ON_ERROR));
if (is_array($response) && isset($response['payload']) && is_string($response['payload'])) {
$decodedPayload = json_decode($response['payload'], true);
if (is_array($decodedPayload)) $response = $decodedPayload;
}
if (is_array($response) && !empty($response['accepted']) && !empty($response['code'])) {
$_SESSION['promo_flash'] = [
'code' => (string) $response['code'],
'reward' => (string) ($response['reward'] ?? $reward),
'max_uses' => (int) ($response['max_uses'] ?? $maxUses),
'note' => (string) ($response['note'] ?? $note),
];
header('Location: ./');
exit;
}
$promoError = 'Der Server konnte keinen Code erstellen. Prüfe die Verbindung und versuche es erneut.';
}
}
}
$releases = fetchJson((string) $config['gitea_releases_api']) ?? []; $releases = fetchJson((string) $config['gitea_releases_api']) ?? [];
$downloadTotal = 0; $downloadTotal = 0;
$latestDownloads = 0; $latestDownloads = 0;
@@ -131,7 +177,48 @@ $players = (int) ($playerStats['total_players'] ?? 0);
$today = gmdate('Y-m-d'); $today = gmdate('Y-m-d');
$activeToday = (int) (($playerStats['daily_active'] ?? [])[$today] ?? 0); $activeToday = (int) (($playerStats['daily_active'] ?? [])[$today] ?? 0);
$updatedAt = isset($playerStats['updated_at']) ? date('d.m.Y H:i', intdiv((int) $playerStats['updated_at'], 1000)) : 'Noch keine Daten'; $updatedAt = isset($playerStats['updated_at']) ? date('d.m.Y H:i', intdiv((int) $playerStats['updated_at'], 1000)) : 'Noch keine Daten';
$promoCodes = null;
$promoListError = '';
if ($httpKey !== '') {
$listRpcUrl = preg_replace('~/private_stats$~', '/list_unlock_codes', (string) ($config['nakama_rpc_url'] ?? ''));
if (is_string($listRpcUrl) && $listRpcUrl !== (string) ($config['nakama_rpc_url'] ?? '')) {
$listUrl = $listRpcUrl . '?http_key=' . rawurlencode($httpKey) . '&unwrap';
$listResponse = fetchJson($listUrl, 'POST', '{}');
if (is_array($listResponse) && isset($listResponse['payload']) && is_string($listResponse['payload'])) {
$decodedPayload = json_decode($listResponse['payload'], true);
if (is_array($decodedPayload)) $listResponse = $decodedPayload;
}
if (is_array($listResponse) && isset($listResponse['codes']) && is_array($listResponse['codes'])) {
$promoCodes = $listResponse['codes'];
} else {
$promoListError = 'Die Code-Übersicht konnte gerade nicht geladen werden.';
}
} else {
$promoListError = 'Die interne Codeverwaltung ist noch nicht mit Nakama verbunden.';
}
} else {
$promoListError = 'Die interne Codeverwaltung ist noch nicht mit Nakama verbunden.';
}
?> ?>
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow,noarchive"><meta http-equiv="refresh" content="300"><title>Void Bastion – Interne Statistik</title><style> <!doctype html>
:root{color-scheme:dark;--bg:#06121e;--panel:#0a2030;--line:#26596a;--cyan:#5cecff;--muted:#92aebb;font-family:Inter,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 85% 0,#12384b 0,transparent 35%),var(--bg);color:#edfaff;min-height:100vh}.shell{width:min(1120px,calc(100% - 32px));margin:auto;padding:48px 0}header{display:flex;justify-content:space-between;align-items:start;gap:24px;margin-bottom:34px}h1{margin:0;font-size:clamp(2rem,5vw,4rem);letter-spacing:-.04em}p{color:var(--muted)}a{color:var(--cyan)}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.card{padding:24px;border:1px solid var(--line);background:linear-gradient(145deg,#0d2c3d,#071722)}.card strong{display:block;color:var(--cyan);font-size:clamp(2rem,4vw,3.5rem);line-height:1}.card span{display:block;margin-top:10px;color:#b9ced7}.panel{margin-top:18px;padding:24px;border:1px solid var(--line);background:#081b28}.warning{color:#ffbd7b}.meta{font-size:.9rem}@media(max-width:800px){.grid{grid-template-columns:1fr 1fr}header{display:block}}@media(max-width:480px){.grid{grid-template-columns:1fr}} <html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow,noarchive"><meta http-equiv="refresh" content="300"><title>Void Bastion – Interne Statistik</title><style>
</style></head><body><main class="shell"><header><div><p>GERGAMES · NICHT ÖFFENTLICH</p><h1>Void Bastion Statistik</h1></div><a href="?logout=1">Abmelden</a></header><section class="grid"><article class="card"><strong><?= $downloadTotal ?></strong><span>Downloads insgesamt</span></article><article class="card"><strong><?= $latestDownloads ?></strong><span>Downloads aktuelles Release</span></article><article class="card"><strong><?= $players ?></strong><span>Registrierte Piloten</span></article><article class="card"><strong><?= $activeToday ?></strong><span>Heute aktive Piloten</span></article><article class="card"><strong><?= $platformDownloads['windows'] ?></strong><span>Windows-Downloads</span></article><article class="card"><strong><?= $platformDownloads['android'] ?></strong><span>Android-Downloads</span></article></section><section class="panel"><h2><?= e($latestRelease) ?></h2><p>Die Downloadwerte stammen direkt aus den Gitea-Release-Anhängen und werden alle fünf Minuten neu geladen.</p><?php if ($playerStats === null): ?><p class="warning">Die private Nakama-Statistik ist noch nicht mit dem HTTP-Schlüssel verbunden.</p><?php else: ?><p>Letzte Spieleraktualisierung: <?= e($updatedAt) ?></p><?php endif; ?><p class="meta">Diese Seite ist nicht verlinkt, von Suchmaschinen ausgeschlossen und verlangt eine Anmeldung. Es werden ausschließlich zusammengefasste Werte dargestellt.</p></section></main></body></html> :root{color-scheme:dark;--bg:#06121e;--panel:#0a2030;--line:#26596a;--cyan:#5cecff;--muted:#92aebb;font-family:Inter,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 85% 0,#12384b 0,transparent 35%),var(--bg);color:#edfaff;min-height:100vh}.shell{width:min(1120px,calc(100% - 32px));margin:auto;padding:48px 0}header{display:flex;justify-content:space-between;align-items:start;gap:24px;margin-bottom:34px}h1{margin:0;font-size:clamp(2rem,5vw,4rem);letter-spacing:-.04em}p{color:var(--muted)}a{color:var(--cyan)}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.card{padding:24px;border:1px solid var(--line);background:linear-gradient(145deg,#0d2c3d,#071722)}.card strong{display:block;color:var(--cyan);font-size:clamp(2rem,4vw,3.5rem);line-height:1}.card span{display:block;margin-top:10px;color:#b9ced7}.panel{margin-top:18px;padding:24px;border:1px solid var(--line);background:#081b28}.warning{color:#ffbd7b}.meta{font-size:.9rem}.form-grid{display:grid;grid-template-columns:minmax(200px,1fr) 180px minmax(220px,1fr) 200px;gap:14px;align-items:end}.field label{display:block;margin-bottom:7px;color:#b9ced7}.field select,.field input,.panel button{width:100%;min-height:46px;padding:9px 12px;border:1px solid #316f80;background:#071722;color:#edfaff;font:inherit}.panel button{background:#14576a;color:#fff;font-weight:700;cursor:pointer}.result{padding:14px;border:1px solid #247c66;background:#0a3028;color:#a5ffdc}.result code{display:block;margin:8px 0;font-size:1.3rem;letter-spacing:.12em;overflow-wrap:anywhere;color:#fff}.error{color:#ffac99}.meta{font-size:.9rem}.table-wrap{overflow-x:auto}.codes{width:100%;border-collapse:collapse;margin-top:16px;min-width:780px}.codes th,.codes td{text-align:left;padding:11px;border-bottom:1px solid #214553;vertical-align:top}.codes th{color:#9fd7e7;font-size:.85rem}.codes code{font-size:.95rem;overflow-wrap:anywhere}.status{display:inline-block;padding:4px 9px;border:1px solid #326;white-space:nowrap}.status-open{color:#a5ffdc;border-color:#247c66;background:#0a3028}.status-partial{color:#ffd88b;border-color:#84672f;background:#332911}.status-redeemed{color:#b6c7d1;border-color:#43515a;background:#1b2830}.note-cell{min-width:180px;white-space:pre-wrap;overflow-wrap:anywhere}@media(max-width:800px){.grid{grid-template-columns:1fr 1fr}header{display:block}.form-grid{grid-template-columns:1fr 1fr}}@media(max-width:480px){.grid,.form-grid{grid-template-columns:1fr}}
</style></head><body><main class="shell"><header><div><p>GERGAMES · NICHT ÖFFENTLICH</p><h1>Void Bastion Statistik</h1></div><a href="?logout=1">Abmelden</a></header>
<section class="grid"><article class="card"><strong><?= $downloadTotal ?></strong><span>Downloads insgesamt</span></article><article class="card"><strong><?= $latestDownloads ?></strong><span>Downloads aktuelles Release</span></article><article class="card"><strong><?= $players ?></strong><span>Registrierte Piloten</span></article><article class="card"><strong><?= $activeToday ?></strong><span>Heute aktive Piloten</span></article><article class="card"><strong><?= $platformDownloads['windows'] ?></strong><span>Windows-Downloads</span></article><article class="card"><strong><?= $platformDownloads['android'] ?></strong><span>Android-Downloads</span></article></section>
<section class="panel"><h2><?= e($latestRelease) ?></h2><p>Die Downloadwerte stammen direkt aus den Gitea-Release-Anhängen und werden alle fünf Minuten neu geladen.</p><?php if ($playerStats === null): ?><p class="warning">Die private Nakama-Statistik ist noch nicht mit dem HTTP-Schlüssel verbunden.</p><?php else: ?><p>Letzte Spieleraktualisierung: <?= e($updatedAt) ?></p><?php endif; ?><p class="meta">Diese Seite ist nicht verlinkt, von Suchmaschinen ausgeschlossen und verlangt eine Anmeldung. Es werden ausschließlich zusammengefasste Werte dargestellt.</p></section>
<section class="panel"><h2>Freischaltcodes erstellen</h2><p>Codes und Bemerkungen werden ausschließlich in der geschützten Serverablage verwaltet. Der Klartext ist nur in diesem angemeldeten Bereich sichtbar.</p>
<?php if (is_array($promoFlash)): ?><div class="result"><strong>Code erstellt:</strong><code><?= e((string) $promoFlash['code']) ?></code><span><?= e((string) $promoFlash['reward']) ?> · offen · maximal <?= (int) $promoFlash['max_uses'] ?> Einlösungen<?php if (($promoFlash['note'] ?? '') !== ''): ?> · <?= e((string) $promoFlash['note']) ?><?php endif; ?></span></div><?php endif; ?>
<?php if ($promoError !== ''): ?><p class="error"><?= e($promoError) ?></p><?php endif; ?>
<form method="post" class="form-grid" autocomplete="off"><input type="hidden" name="promo_action" value="create"><input type="hidden" name="csrf_token" value="<?= e((string) $_SESSION['csrf_token']) ?>"><div class="field"><label for="reward">Freischaltung</label><select id="reward" name="reward"><option value="all_towers">Alle Türme</option><option value="all_maps">Alle Karten</option><option value="all">Alle Türme und Karten</option></select></div><div class="field"><label for="max_uses">Einlösungen</label><input id="max_uses" name="max_uses" type="number" min="1" max="50000" value="1" required></div><div class="field"><label for="note">Bemerkung (optional, max. 160 Zeichen)</label><input id="note" name="note" type="text" maxlength="160" placeholder="z. B. weitergegeben an …"></div><button type="submit">Code erzeugen</button></form>
<?php if ($promoListError !== ''): ?><p class="warning"><?= e($promoListError) ?></p><?php elseif (is_array($promoCodes)): ?><h3>Code-Übersicht</h3><?php if (!$promoCodes): ?><p>Noch keine Freischaltcodes vorhanden.</p><?php else: ?><div class="table-wrap"><table class="codes"><thead><tr><th>Code</th><th>Freischaltung</th><th>Status</th><th>Einlösungen</th><th>Erstellt</th><th>Bemerkung</th></tr></thead><tbody><?php foreach ($promoCodes as $promoCode): ?><?php
$uses = max(0, (int) ($promoCode['uses'] ?? 0));
$maxUses = max(1, (int) ($promoCode['max_uses'] ?? 1));
$status = $uses >= $maxUses ? 'redeemed' : ($uses > 0 ? 'partial' : 'open');
$statusLabel = $status === 'redeemed' ? 'Eingelöst' : ($status === 'partial' ? 'Teilweise eingelöst' : 'Offen');
$createdAt = (int) ($promoCode['created_at'] ?? 0);
$createdLabel = $createdAt > 0 ? date('d.m.Y H:i', intdiv($createdAt, 1000)) : '—';
$codeLabel = (string) ($promoCode['code'] ?? '');
if ($codeLabel === '') $codeLabel = 'Alter Code – Klartext nicht gespeichert';
?><tr><td><code><?= e($codeLabel) ?></code></td><td><?= e((string) ($promoCode['reward'] ?? '')) ?></td><td><span class="status status-<?= e($status) ?>"><?= e($statusLabel) ?></span></td><td><?= $uses ?> / <?= $maxUses ?></td><td><?= e($createdLabel) ?></td><td class="note-cell"><?= e((string) (($promoCode['note'] ?? '') !== '' ? $promoCode['note'] : '—')) ?></td></tr><?php endforeach; ?></tbody></table></div><?php endif; ?><?php endif; ?></section>
</main></body></html>