Files
Void-Bastion-Website/intern/statistik/index.php
T

260 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
header('X-Robots-Tag: noindex, nofollow, noarchive', true);
header('Cache-Control: no-store, private', true);
header('Referrer-Policy: no-referrer', true);
header('X-Content-Type-Options: nosniff', true);
session_name('void_bastion_internal_stats');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/intern/statistik/',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
$configPath = __DIR__ . '/stats-config.local.php';
if (!is_file($configPath)) {
http_response_code(503);
exit('Die interne Statistik ist noch nicht konfiguriert.');
}
$config = require $configPath;
if (isset($_GET['logout'])) {
$_SESSION = [];
session_destroy();
header('Location: ./');
exit;
}
$loginError = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['username'], $_POST['password'])) {
$userOk = hash_equals((string) $config['username'], (string) $_POST['username']);
$passwordOk = hash_equals((string) $config['password_sha256'], hash('sha256', (string) $_POST['password']));
if ($userOk && $passwordOk) {
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: ./');
exit;
}
usleep(350000);
$loginError = 'Anmeldung fehlgeschlagen.';
}
function fetchJson(string $url, string $method = 'GET', ?string $body = null): ?array
{
if (function_exists('curl_init')) {
$handle = curl_init($url);
$options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_TIMEOUT => 8,
CURLOPT_USERAGENT => 'GerGames-Internal-Stats/1.0',
CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json'],
CURLOPT_CUSTOMREQUEST => $method,
];
if ($body !== null) {
$options[CURLOPT_POSTFIELDS] = $body;
}
curl_setopt_array($handle, $options);
$response = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
if (!is_string($response) || $status < 200 || $status >= 300) return null;
} else {
$context = stream_context_create(['http' => [
'method' => $method,
'timeout' => 8,
'ignore_errors' => true,
'header' => "Accept: application/json\r\nContent-Type: application/json\r\nUser-Agent: GerGames-Internal-Stats/1.0\r\n",
'content' => $body ?? '',
]]);
$response = @file_get_contents($url, false, $context);
if (!is_string($response)) return null;
}
$decoded = json_decode($response, true);
return is_array($decoded) ? $decoded : null;
}
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
if (empty($_SESSION['authenticated'])):
?>
<!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"><title>GerGames – Interne Statistik</title><style>
:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif;background:#06121e;color:#eaf8fc}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 50% 0,#12394d 0,transparent 48%),#06121e}.box{width:min(100%,420px);padding:32px;border:1px solid #2d7182;background:#091d2a;box-shadow:0 24px 80px #0008}h1{margin:0 0 8px;font-size:1.7rem}p{color:#9db6c1}label{display:block;margin:18px 0 6px;color:#bcd5df}input,button{width:100%;min-height:48px;border:1px solid #316f80;background:#071722;color:#fff;padding:10px 12px;font:inherit}button{margin-top:22px;background:#5cecff;color:#031119;font-weight:800;cursor:pointer}.error{color:#ffac99}
</style></head><body><main class="box"><h1>Interne Statistik</h1><p>Geschützter GerGames-Bereich</p><?php if ($loginError): ?><p class="error"><?= e($loginError) ?></p><?php endif; ?><form method="post" autocomplete="off"><label for="username">Benutzername</label><input id="username" name="username" required autocomplete="username"><label for="password">Passwort</label><input id="password" name="password" type="password" required autocomplete="current-password"><button type="submit">Anmelden</button></form></main></body></html>
<?php
exit;
endif;
if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$promoFlash = $_SESSION['promo_flash'] ?? null;
unset($_SESSION['promo_flash']);
$promoDeleteFlash = (string) ($_SESSION['promo_delete_flash'] ?? '');
unset($_SESSION['promo_delete_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 {
$action = (string) $_POST['promo_action'];
if ($action === 'delete') {
$codeKey = strtolower(trim((string) ($_POST['code_key'] ?? '')));
$httpKey = trim((string) ($config['nakama_http_key'] ?? ''));
$rpcBase = preg_replace('~/private_stats$~', '/delete_unlock_code', (string) ($config['nakama_rpc_url'] ?? ''));
if (!preg_match('/^[a-f0-9]{64}$/D', $codeKey)) {
$promoError = 'Ungültiger Code-Eintrag.';
} 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(['code_key' => $codeKey], 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'])) {
$_SESSION['promo_delete_flash'] = 'Der nicht eingelöste Code wurde gelöscht.';
header('Location: ./');
exit;
}
$reason = is_array($response) ? (string) ($response['error'] ?? '') : '';
$promoError = $reason === 'already_used'
? 'Dieser Code wurde inzwischen eingelöst und kann nicht gelöscht werden.'
: ($reason === 'not_found' ? 'Der Code ist bereits gelöscht oder nicht mehr vorhanden.' : 'Der Code konnte nicht gelöscht werden. Bitte später erneut versuchen.');
}
} elseif ($action === 'create') {
$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.';
}
} else {
$promoError = 'Unbekannte Code-Aktion.';
}
}
}
$releases = fetchJson((string) $config['gitea_releases_api']) ?? [];
$downloadTotal = 0;
$latestDownloads = 0;
$latestRelease = 'Noch kein Release';
$platformDownloads = ['windows' => 0, 'android' => 0];
foreach ($releases as $releaseIndex => $release) {
if (!is_array($release)) continue;
if ($releaseIndex === 0) $latestRelease = (string) ($release['name'] ?? $release['tag_name'] ?? 'Aktuelles Release');
foreach (($release['assets'] ?? []) as $asset) {
if (!is_array($asset)) continue;
$count = max(0, (int) ($asset['download_count'] ?? 0));
$downloadTotal += $count;
if ($releaseIndex === 0) $latestDownloads += $count;
$name = strtolower((string) ($asset['name'] ?? ''));
if (substr($name, -4) === '.exe') $platformDownloads['windows'] += $count;
if (substr($name, -4) === '.apk') $platformDownloads['android'] += $count;
}
}
$playerStats = null;
$httpKey = trim((string) ($config['nakama_http_key'] ?? ''));
if ($httpKey !== '') {
$statsUrl = (string) $config['nakama_rpc_url'] . '?http_key=' . rawurlencode($httpKey) . '&unwrap';
$rpcResponse = fetchJson($statsUrl, 'POST', '{}');
if (is_array($rpcResponse)) {
$playerStats = $rpcResponse;
if (isset($rpcResponse['payload']) && is_string($rpcResponse['payload'])) {
$payload = json_decode($rpcResponse['payload'], true);
if (is_array($payload)) $playerStats = $payload;
}
}
}
$players = (int) ($playerStats['total_players'] ?? 0);
$today = gmdate('Y-m-d');
$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';
$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>
: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}}
.delete-form{margin:0}.panel .delete-form button{width:auto;min-height:36px;padding:6px 10px;background:#602c38;border-color:#ad5963;font-size:.9rem;white-space:nowrap}
</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 ($promoDeleteFlash !== ''): ?><div class="result"><?= e($promoDeleteFlash) ?></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><th>Aktion</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';
$codeKey = strtolower((string) ($promoCode['code_key'] ?? ''));
?><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><td><?php if ($status === 'open' && preg_match('/^[a-f0-9]{64}$/D', $codeKey)): ?><form method="post" class="delete-form" onsubmit="return confirm('Diesen noch nicht eingelösten Code endgültig löschen?')"><input type="hidden" name="promo_action" value="delete"><input type="hidden" name="csrf_token" value="<?= e((string) $_SESSION['csrf_token']) ?>"><input type="hidden" name="code_key" value="<?= e($codeKey) ?>"><button type="submit">Löschen</button></form><?php else: ?>—<?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div><?php endif; ?><?php endif; ?></section>
</main></body></html>