Erweiterte Features (2/2): Cleanup/DSGVO, Webhooks, REST-API, Backup, CI, Docker

- Auto-Cleanup/DSGVO: cron_cleanup.php (token-geschützt) löscht abgelaufene
  Voucher, Audit-Log, Login-Versuche und Reset-Tokens nach konfigurierbaren
  Fristen
- Webhooks: includes/Notifier.php (Slack/Teams/generisch), Auslösung bei
  Voucher-Erstellung (Web + API)
- REST-API: includes/ApiKey.php (SHA-256, Präfix-Lookup), api/bootstrap.php,
  api/vouchers.php (GET/POST), api/sites.php; admin/api_keys.php zur Verwaltung
- Config-Backup/Restore: admin/backup.php (JSON Export/Import, Cron-Token
  geschützt)
- admin/integrations.php: Trusted-Proxy, Webhook, Cleanup-Fristen
- CI: .github/workflows/ci.yml (php -l auf 7.4 & 8.2, lang-Validierung)
- Docker: Dockerfile, docker-compose.yml (MariaDB), entrypoint (config aus ENV),
  .dockerignore
- Nav + i18n (DE/EN) für alle neuen Admin-Seiten
This commit is contained in:
Claude 2026-06-05 19:45:28 +00:00
parent eec28f77b8
commit 9463486225
No known key found for this signature in database
18 changed files with 972 additions and 0 deletions

71
includes/ApiKey.php Normal file
View file

@ -0,0 +1,71 @@
<?php
/**
* ApiKey Erzeugung & Verifizierung von API-Schlüsseln für die REST-API.
*
* Schlüsselformat: uvt_<prefix(8)><secret(32)>
* Gespeichert wird nur der SHA-256-Hash plus ein indexierbares Präfix der
* Klartext-Schlüssel wird dem Admin genau einmal bei der Erstellung gezeigt.
*/
class ApiKey {
/** Neuen Schlüssel erzeugen. Gibt plain/prefix/hash zurück. */
public static function generate() {
$prefix = bin2hex(random_bytes(4)); // 8 Zeichen
$secret = bin2hex(random_bytes(16)); // 32 Zeichen
$plain = 'uvt_' . $prefix . $secret;
return [
'plain' => $plain,
'prefix' => $prefix,
'hash' => hash('sha256', $plain),
];
}
/**
* Verifiziert einen Klartext-Schlüssel gegen die DB. Aktualisiert
* last_used_at und gibt den Key-Datensatz zurück oder false.
*/
public static function verify($plain, $db) {
if (!is_string($plain) || strpos($plain, 'uvt_') !== 0 || strlen($plain) < 44) {
return false;
}
$prefix = substr($plain, 4, 8);
$hash = hash('sha256', $plain);
try {
$row = $db->fetchOne(
"SELECT * FROM api_keys WHERE key_prefix = ? AND is_active = 1",
[$prefix]
);
} catch (\Exception $e) {
return false;
}
if (!$row || !hash_equals($row['key_hash'], $hash)) {
return false;
}
try {
$db->query("UPDATE api_keys SET last_used_at = NOW() WHERE id = ?", [$row['id']]);
} catch (\Exception $e) { /* ignore */ }
return $row;
}
/** Liest den Schlüssel aus dem Request (Authorization: Bearer / X-API-Key). */
public static function fromRequest() {
$headers = [];
if (function_exists('getallheaders')) {
foreach (getallheaders() as $k => $v) {
$headers[strtolower($k)] = $v;
}
}
if (!empty($headers['authorization']) && preg_match('/Bearer\s+(\S+)/i', $headers['authorization'], $m)) {
return $m[1];
}
if (!empty($headers['x-api-key'])) {
return trim($headers['x-api-key']);
}
if (!empty($_SERVER['HTTP_X_API_KEY'])) {
return trim($_SERVER['HTTP_X_API_KEY']);
}
if (!empty($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/Bearer\s+(\S+)/i', $_SERVER['HTTP_AUTHORIZATION'], $m)) {
return $m[1];
}
return null;
}
}

53
includes/Notifier.php Normal file
View file

@ -0,0 +1,53 @@
<?php
/**
* Notifier sendet Ereignis-Benachrichtigungen an einen konfigurierten
* Webhook (Slack / Microsoft Teams / generischer JSON-Endpunkt).
*
* Gesteuert über Einstellungen:
* webhook_enabled (0/1), webhook_url
*
* Sendet ein Slack/Teams-kompatibles { "text": "..." }-Payload plus ein
* strukturiertes "event"-Feld. Fehler werden still ignoriert eine
* Benachrichtigung darf nie den eigentlichen Vorgang blockieren.
*/
class Notifier {
/** Generisches Event senden. */
public static function send($text, array $data = []) {
try {
$db = Database::getInstance();
if ((string)$db->getSetting('webhook_enabled', '0') !== '1') {
return;
}
$url = trim((string)$db->getSetting('webhook_url', ''));
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
return;
}
} catch (\Exception $e) {
return;
}
$payload = json_encode(array_merge(['text' => $text], $data ? ['event' => $data] : []));
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
]);
@curl_exec($ch);
curl_close($ch);
}
/** Bequemer Helfer für erstellte Voucher. */
public static function voucherCreated($count, $siteName, $byUser = null) {
$who = $byUser ? " von {$byUser}" : '';
$what = $count > 1 ? "{$count} Voucher" : 'Ein Voucher';
self::send(
"🎫 {$what} für \"{$siteName}\"{$who} erstellt.",
['type' => 'voucher_created', 'count' => $count, 'site' => $siteName, 'user' => $byUser]
);
}
}

View file

@ -113,9 +113,18 @@ $lang = I18n::getLanguage();
<li><a href="<?= $adminBase ?? '' ?>settings.php" class="<?= $currentPage === 'settings' ? 'active' : '' ?>">
<i class="fas fa-cog"></i> <?= __('nav_settings') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>api_keys.php" class="<?= $currentPage === 'api_keys' ? 'active' : '' ?>">
<i class="fas fa-key"></i> <?= __('nav_api_keys') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>security.php" class="<?= $currentPage === 'security' ? 'active' : '' ?>">
<i class="fas fa-user-shield"></i> <?= __('nav_security') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>integrations.php" class="<?= $currentPage === 'integrations' ? 'active' : '' ?>">
<i class="fas fa-plug"></i> <?= __('nav_integrations') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>backup.php" class="<?= $currentPage === 'backup' ? 'active' : '' ?>">
<i class="fas fa-database"></i> <?= __('nav_backup') ?>
</a></li>
<li><a href="<?= $adminBase ?? '' ?>update.php" class="<?= $currentPage === 'update' ? 'active' : '' ?>">
<i class="fas fa-sync-alt"></i> <?= __('nav_update') ?>
</a></li>