260 lines
20 KiB
PHP
260 lines
20 KiB
PHP
<?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>
|