"}
- [x] Passwort-Reset
- [x] Audit-Log
- [x] Auto-Updater mit DB-Migrationen
-- [ ] Erweiterte Reporting-Funktionen
-- [ ] Docker-Container
+- [x] REST-API mit API-Schlüsseln
+- [x] 2FA (TOTP), Webhooks, Bandbreitenlimits
+- [x] Docker-Container
+- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
+- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan)
---
-**Version 2.1.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
+**Version 2.4.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
diff --git a/admin/api_keys.php b/admin/api_keys.php
new file mode 100644
index 0000000..a2bbbfb
--- /dev/null
+++ b/admin/api_keys.php
@@ -0,0 +1,171 @@
+requireAdmin();
+I18n::init();
+
+$db = Database::getInstance();
+$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
+
+$error = '';
+$success = '';
+$newKey = '';
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) {
+ if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = __('error_csrf');
+ } else {
+ $name = trim($_POST['name'] ?? '');
+ if ($name === '') {
+ $error = __('error_name_req');
+ } else {
+ $scope = ($_POST['scope'] ?? 'write') === 'read' ? 'read' : 'write';
+ $rate = max(0, (int)($_POST['rate_limit'] ?? 0));
+ $k = ApiKey::generate();
+ $db->execute(
+ "INSERT INTO api_keys (name, key_prefix, key_hash, scope, rate_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)",
+ [$name, $k['prefix'], $k['hash'], $scope, $rate, $_SESSION['user_id']]
+ );
+ $auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt");
+ $newKey = $k['plain'];
+ $success = 'API-Schlüssel erstellt. Bitte JETZT kopieren – er wird nur einmal angezeigt!';
+ }
+ }
+}
+
+if (isset($_GET['toggle']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
+ $row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]);
+ if ($row) {
+ $db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]);
+ $success = 'Status aktualisiert.';
+ }
+}
+
+if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
+ $db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]);
+ $auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht');
+ $success = 'API-Schlüssel gelöscht.';
+}
+
+$keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC");
+$csrf = $auth->getCsrfToken();
+$currentPage = 'api_keys';
+$adminBase = '';
+?>
+
+
+
+
+
+API-Schlüssel – = htmlspecialchars($appTitle) ?>
+
+
+
+
+🔑 API-Schlüssel
+
+= htmlspecialchars($error) ?>
+= htmlspecialchars($success) ?>
+
+
+
+
Neuer Schlüssel
+
Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.
+
= htmlspecialchars($newKey) ?>
+
+
+
+
+
Neuen API-Schlüssel erstellen
+
+
+
+
+
Vorhandene Schlüssel
+
+
Noch keine API-Schlüssel angelegt.
+
+
+ Name Präfix Scope Limit Status Zuletzt genutzt Erstellt von
+
+
+ = htmlspecialchars($k['name']) ?>
+ uvt_= htmlspecialchars($k['key_prefix']) ?>…
+ = ($k['scope'] ?? 'write') === 'read' ? 'nur Lesen' : 'Lesen+Erstellen' ?>
+ = (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?>
+ = $k['is_active'] ? 'aktiv' : 'gesperrt' ?>
+ = $k['last_used_at'] ? htmlspecialchars($k['last_used_at']) : '–' ?>
+ = htmlspecialchars($k['creator'] ?? '–') ?>
+
+ = $k['is_active'] ? 'Sperren' : 'Aktivieren' ?>
+ Löschen
+
+
+
+
+
+
+
+
+
Verwendung
+
Authentifizierung per Header Authorization: Bearer <key> oder X-API-Key: <key>.
+
# Voucher erstellen
+curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
+ -H "Authorization: Bearer uvt_…" \
+ -H "Content-Type: application/json" \
+ -d '{"site_id":1,"name":"API Gast","max_uses":1,"expire_minutes":480}'
+
+# Sites auflisten
+curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"
+
OpenAPI-Spezifikation (Import in Postman/Swagger): /api/openapi.php
+
+
+
+
+
+
diff --git a/admin/backup.php b/admin/backup.php
new file mode 100644
index 0000000..0faf942
--- /dev/null
+++ b/admin/backup.php
@@ -0,0 +1,159 @@
+requireAdmin();
+I18n::init();
+
+$db = Database::getInstance();
+$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
+
+$error = '';
+$success = '';
+
+// Export: JSON-Download
+if (isset($_GET['export']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
+ $export = [
+ 'meta' => [
+ 'app' => 'unifi-voucher-tool',
+ 'version' => '2.2.0',
+ 'exported_at'=> date('c'),
+ 'note' => 'Site-Passwörter sind mit dem APP_KEY dieser Installation verschlüsselt.',
+ ],
+ 'settings' => $db->fetchAll("SELECT setting_key, setting_value FROM settings"),
+ 'sites' => $db->fetchAll("SELECT name, site_id, unifi_controller_url, unifi_username, unifi_password, is_active, public_access FROM sites"),
+ 'voucher_templates' => $db->fetchAll("SELECT name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, is_active FROM voucher_templates"),
+ ];
+ $auth->writeAuditLog($_SESSION['user_id'], 'config_export', 'config', null, 'Konfiguration exportiert');
+ header('Content-Type: application/json');
+ header('Content-Disposition: attachment; filename="voucher-config-' . date('Y-m-d') . '.json"');
+ echo json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+}
+
+// Import
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) {
+ if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = __('error_csrf');
+ } elseif (empty($_FILES['backup']['tmp_name'])) {
+ $error = 'Bitte eine Backup-Datei auswählen.';
+ } else {
+ $raw = file_get_contents($_FILES['backup']['tmp_name']);
+ $data = json_decode($raw, true);
+ if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') {
+ $error = 'Ungültige oder fremde Backup-Datei.';
+ } else {
+ $importSites = isset($_POST['import_sites']);
+ $importTemplates = isset($_POST['import_templates']);
+ $importSettings = isset($_POST['import_settings']);
+ $counts = ['settings' => 0, 'sites' => 0, 'templates' => 0];
+
+ try {
+ if ($importSettings && !empty($data['settings'])) {
+ foreach ($data['settings'] as $s) {
+ // Cron-Token NICHT überschreiben (Sicherheit der Ziel-Installation)
+ if (($s['setting_key'] ?? '') === 'cron_token') continue;
+ $db->setSetting($s['setting_key'], $s['setting_value']);
+ $counts['settings']++;
+ }
+ }
+ if ($importSites && !empty($data['sites'])) {
+ foreach ($data['sites'] as $s) {
+ $exists = $db->fetchOne("SELECT id FROM sites WHERE name = ? AND site_id = ?", [$s['name'], $s['site_id']]);
+ if ($exists) {
+ $db->query(
+ "UPDATE sites SET unifi_controller_url=?, unifi_username=?, unifi_password=?, is_active=?, public_access=? WHERE id=?",
+ [$s['unifi_controller_url'], $s['unifi_username'], $s['unifi_password'], (int)$s['is_active'], (int)$s['public_access'], $exists['id']]
+ );
+ } else {
+ $db->query(
+ "INSERT INTO sites (name, site_id, unifi_controller_url, unifi_username, unifi_password, is_active, public_access) VALUES (?,?,?,?,?,?,?)",
+ [$s['name'], $s['site_id'], $s['unifi_controller_url'], $s['unifi_username'], $s['unifi_password'], (int)$s['is_active'], (int)$s['public_access']]
+ );
+ }
+ $counts['sites']++;
+ }
+ }
+ if ($importTemplates && !empty($data['voucher_templates'])) {
+ foreach ($data['voucher_templates'] as $t) {
+ $exists = $db->fetchOne("SELECT id FROM voucher_templates WHERE name = ?", [$t['name']]);
+ if (!$exists) {
+ $db->query(
+ "INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, is_active) VALUES (?,?,?,?,?,?,?,?)",
+ [$t['name'], (int)$t['max_uses'], (int)$t['expire_minutes'], $t['description'] ?? null,
+ $t['qos_rate_max_down'] ?? null, $t['qos_rate_max_up'] ?? null, $t['qos_usage_quota'] ?? null, (int)($t['is_active'] ?? 1)]
+ );
+ $counts['templates']++;
+ }
+ }
+ }
+ $auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert');
+ $success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile.";
+ } catch (Exception $e) {
+ $error = 'Import-Fehler: ' . $e->getMessage();
+ }
+ }
+ }
+}
+
+$csrf = $auth->getCsrfToken();
+$currentPage = 'backup';
+$adminBase = '';
+?>
+
+
+
+
+
+Backup & Restore – = htmlspecialchars($appTitle) ?>
+
+
+
+
+💾 Backup & Restore
+
+= htmlspecialchars($error) ?>
+= htmlspecialchars($success) ?>
+
+
+
Export
+
Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem APP_KEY dieser Installation verschlüsselt – ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.
+
Konfiguration exportieren
+
+
+
+
Import / Restore
+
Vorhandene Sites werden anhand von Name + Site-ID aktualisiert, neue hinzugefügt. Profile werden nur angelegt, wenn der Name noch nicht existiert. Der Cron-Token wird nie überschrieben.
+
+
+
+
+
+
+
diff --git a/admin/import.php b/admin/import.php
new file mode 100644
index 0000000..dfb9725
--- /dev/null
+++ b/admin/import.php
@@ -0,0 +1,148 @@
+requireAdmin();
+I18n::init();
+
+$db = Database::getInstance();
+$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
+$defaultExpire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
+$defaultMaxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
+
+$error = '';
+$success = '';
+$results = [];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['do_import'])) {
+ if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = __('error_csrf');
+ } else {
+ try {
+ $siteId = (int)($_POST['site_id'] ?? 0);
+ $site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]);
+ if (!$site) throw new Exception('Site nicht gefunden');
+
+ // CSV-Quelle: Datei bevorzugt, sonst Textarea
+ $raw = '';
+ if (!empty($_FILES['csv']['tmp_name'])) {
+ $raw = file_get_contents($_FILES['csv']['tmp_name']);
+ } else {
+ $raw = (string)($_POST['csv_text'] ?? '');
+ }
+ $lines = preg_split('/\r\n|\r|\n/', trim($raw));
+ if (count($lines) > 200) throw new Exception('Maximal 200 Zeilen pro Import.');
+
+ $controller = new UniFiController(
+ $site['unifi_controller_url'], $site['unifi_username'],
+ Crypto::decrypt($site['unifi_password']), $site['site_id']
+ );
+
+ $created = 0;
+ foreach ($lines as $i => $line) {
+ $line = trim($line);
+ if ($line === '') continue;
+ $cols = str_getcsv($line);
+ $name = trim((string)($cols[0] ?? ''));
+ if ($name === '' || strtolower($name) === 'name') continue; // Header/leer überspringen
+ $maxUses = isset($cols[1]) && $cols[1] !== '' ? max(1, (int)$cols[1]) : $defaultMaxUses;
+ $expire = isset($cols[2]) && $cols[2] !== '' ? max(1, (int)$cols[2]) : $defaultExpire;
+ try {
+ $v = $controller->createVoucher(date('Y-m-d') . '_' . $name, $maxUses, $expire);
+ if (!is_array($v) || empty($v['formatted_code'])) throw new Exception('ungültige Antwort');
+ $db->execute(
+ "INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)",
+ [$siteId, $_SESSION['user_id'], $v['code'], date('Y-m-d') . '_' . $name, $maxUses, $expire, $v['unifi_id'] ?? null]
+ );
+ $results[] = ['name' => $name, 'code' => $v['formatted_code'], 'ok' => true];
+ $created++;
+ } catch (Exception $e) {
+ $results[] = ['name' => $name, 'code' => $e->getMessage(), 'ok' => false];
+ }
+ }
+ if ($created > 0) {
+ Notifier::voucherCreated($created, $site['name'], $_SESSION['user_name'] ?? null);
+ $auth->writeAuditLog($_SESSION['user_id'], 'voucher_import', 'site', $siteId, "$created Voucher importiert");
+ }
+ $success = "$created Voucher erstellt.";
+ } catch (Exception $e) {
+ $error = $e->getMessage();
+ }
+ }
+}
+
+$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
+$csrf = $auth->getCsrfToken();
+$currentPage = 'import';
+$adminBase = '';
+?>
+
+
+
+
+
+CSV-Import – = htmlspecialchars($appTitle) ?>
+
+
+
+
+📥 Voucher-Import (CSV)
+
+= htmlspecialchars($error) ?>
+= htmlspecialchars($success) ?>
+
+
+
Mehrere Voucher erstellen
+
Eine Zeile pro Voucher: Name,MaxGeräte,Minuten – MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:
+ Gast Müller,1,480 · Konferenzraum A,5,240 · Tagespass
+
+
+
+
+
+
Ergebnis
+
Name Code / Fehler Status
+
+ = htmlspecialchars($r['name']) ?> = htmlspecialchars($r['code']) ?>= $r['ok'] ? '✅' : '❌' ?>
+
+
+
+
+
+
+
+
+
diff --git a/admin/integrations.php b/admin/integrations.php
new file mode 100644
index 0000000..6f6d1bd
--- /dev/null
+++ b/admin/integrations.php
@@ -0,0 +1,204 @@
+requireAdmin();
+I18n::init();
+
+$db = Database::getInstance();
+$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
+
+$error = '';
+$success = '';
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
+ if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = __('error_csrf');
+ } else {
+ $db->setSetting('enforce_2fa_admins', isset($_POST['enforce_2fa_admins']) ? '1' : '0');
+ $db->setSetting('session_driver', ($_POST['session_driver'] ?? 'php') === 'db' ? 'db' : 'php');
+ $cm = in_array($_POST['captcha_mode'] ?? 'off', ['off','math','hcaptcha'], true) ? $_POST['captcha_mode'] : 'off';
+ $db->setSetting('captcha_mode', $cm);
+ $db->setSetting('captcha_site_key', trim($_POST['captcha_site_key'] ?? ''));
+ if (!empty($_POST['captcha_secret'])) { $db->setSetting('captcha_secret', trim($_POST['captcha_secret'])); }
+ $db->setSetting('sms_enabled', isset($_POST['sms_enabled']) ? '1' : '0');
+ $db->setSetting('twilio_sid', trim($_POST['twilio_sid'] ?? ''));
+ $db->setSetting('twilio_from', trim($_POST['twilio_from'] ?? ''));
+ if (!empty($_POST['twilio_token'])) { $db->setSetting('twilio_token', trim($_POST['twilio_token'])); }
+ $db->setSetting('oidc_enabled', isset($_POST['oidc_enabled']) ? '1' : '0');
+ $db->setSetting('oidc_name', trim($_POST['oidc_name'] ?? 'SSO'));
+ $db->setSetting('oidc_client_id', trim($_POST['oidc_client_id'] ?? ''));
+ $db->setSetting('oidc_auth_url', trim($_POST['oidc_auth_url'] ?? ''));
+ $db->setSetting('oidc_token_url', trim($_POST['oidc_token_url'] ?? ''));
+ $db->setSetting('oidc_userinfo_url', trim($_POST['oidc_userinfo_url'] ?? ''));
+ $db->setSetting('oidc_scopes', trim($_POST['oidc_scopes'] ?? 'openid profile email'));
+ if (!empty($_POST['oidc_client_secret'])) { $db->setSetting('oidc_client_secret', trim($_POST['oidc_client_secret'])); }
+ $db->setSetting('user_daily_voucher_limit', max(0, (int)($_POST['user_daily_voucher_limit'] ?? 0)));
+ $db->setSetting('trusted_proxy', trim($_POST['trusted_proxy'] ?? ''));
+ $db->setSetting('webhook_enabled', isset($_POST['webhook_enabled']) ? '1' : '0');
+ $db->setSetting('webhook_url', trim($_POST['webhook_url'] ?? ''));
+ $db->setSetting('cleanup_expired_days', max(0, (int)($_POST['cleanup_expired_days'] ?? 0)));
+ $db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0)));
+ $db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30)));
+ $auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert');
+ $success = 'Einstellungen gespeichert.';
+ }
+}
+
+if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
+ Notifier::send('✅ Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']);
+ $success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).';
+}
+
+$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
+$sessionDriver = $db->getSetting('session_driver', 'php');
+$captchaMode = $db->getSetting('captcha_mode', 'off');
+$captchaSiteKey = $db->getSetting('captcha_site_key', '');
+$captchaSecretSet = $db->getSetting('captcha_secret', '') !== '';
+$smsEnabled = $db->getSetting('sms_enabled', '0') === '1';
+$twilioSid = $db->getSetting('twilio_sid', '');
+$twilioFrom = $db->getSetting('twilio_from', '');
+$twilioTokenSet = $db->getSetting('twilio_token', '') !== '';
+$oidcEnabled = $db->getSetting('oidc_enabled', '0') === '1';
+$oidcName = $db->getSetting('oidc_name', 'SSO');
+$oidcClientId = $db->getSetting('oidc_client_id', '');
+$oidcAuthUrl = $db->getSetting('oidc_auth_url', '');
+$oidcTokenUrl = $db->getSetting('oidc_token_url', '');
+$oidcUserinfoUrl = $db->getSetting('oidc_userinfo_url', '');
+$oidcScopes = $db->getSetting('oidc_scopes', 'openid profile email');
+$oidcSecretSet = $db->getSetting('oidc_client_secret', '') !== '';
+$dailyLimit = (int)$db->getSetting('user_daily_voucher_limit', 0);
+$trustedProxy = $db->getSetting('trusted_proxy', '');
+$webhookEnabled = $db->getSetting('webhook_enabled', '0') === '1';
+$webhookUrl = $db->getSetting('webhook_url', '');
+$cleanupExpired = (int)$db->getSetting('cleanup_expired_days', 0);
+$cleanupAudit = (int)$db->getSetting('cleanup_audit_days', 0);
+$cleanupLogin = (int)$db->getSetting('cleanup_login_days', 30);
+$lastCleanup = $db->getSetting('last_cleanup', '');
+$csrf = $auth->getCsrfToken();
+$currentPage = 'integrations';
+$adminBase = '';
+?>
+
+
+
+
+
+Integration & Wartung – = htmlspecialchars($appTitle) ?>
+
+
+
+
+🔧 Integration & Wartung
+
+= htmlspecialchars($error) ?>
+= htmlspecialchars($success) ?>
+
+
+
+
+
+
Sicherheitsrichtlinie
+
Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.
+
> 2FA für Administratoren verpflichtend
+
Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)
+
+
Session-Speicher
+
+ >PHP-Standard (Dateien)
+ >Datenbank (ermöglicht „überall abmelden")
+
+
Captcha im öffentlichen Modus
+
+ >Aus
+ >Rechenaufgabe (ohne externen Dienst)
+ >hCaptcha
+
+
+
+
+
+
Reverse-Proxy
+
IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus X-Forwarded-For für Rate-Limit & Audit verwendet.
+
+
+
+
+
Webhook-Benachrichtigungen
+
Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.
+
> Webhook aktiv
+
Webhook-URL
+
+
+
+
+
+
SMS-Versand (Twilio)
+
Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.
+
> SMS-Versand aktiv
+
+
+
+
+
+
+
Datenhaltung & Cleanup (DSGVO)
+
Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per cron_cleanup.php (täglich empfohlen).
+ Letzter Lauf: = htmlspecialchars($lastCleanup) ?>
+
+
+
+
+Speichern
+
+
+
+
+
+
diff --git a/admin/reports.php b/admin/reports.php
new file mode 100644
index 0000000..e43b056
--- /dev/null
+++ b/admin/reports.php
@@ -0,0 +1,174 @@
+requireAdmin();
+I18n::init();
+
+$db = Database::getInstance();
+$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
+
+// Zeitraum (Tage)
+$days = max(1, min(365, (int)($_GET['days'] ?? 30)));
+
+// CSV-Export
+if (isset($_GET['export'])) {
+ $auth->requireAdmin();
+ header('Content-Type: text/csv; charset=utf-8');
+ $fn = 'report-' . $_GET['export'] . '-' . date('Y-m-d') . '.csv';
+ header('Content-Disposition: attachment; filename="' . $fn . '"');
+ $out = fopen('php://output', 'w');
+ fprintf($out, "\xEF\xBB\xBF"); // UTF-8 BOM für Excel
+
+ if ($_GET['export'] === 'per_site') {
+ fputcsv($out, ['Site', 'Gesamt', 'Gültig', 'Verwendet', 'Abgelaufen']);
+ $rows = $db->fetchAll(
+ "SELECT s.name,
+ COUNT(v.id) total,
+ SUM(v.status='valid') valid,
+ SUM(v.status='used') used,
+ SUM(v.status='expired') expired
+ FROM sites s LEFT JOIN vouchers v ON v.site_id=s.id
+ GROUP BY s.id ORDER BY total DESC"
+ );
+ foreach ($rows as $r) fputcsv($out, [$r['name'], (int)$r['total'], (int)$r['valid'], (int)$r['used'], (int)$r['expired']]);
+ } elseif ($_GET['export'] === 'per_user') {
+ fputcsv($out, ['Benutzer', 'E-Mail', 'Voucher erstellt']);
+ $rows = $db->fetchAll(
+ "SELECT u.name, u.email, COUNT(v.id) c FROM users u
+ LEFT JOIN vouchers v ON v.user_id=u.id GROUP BY u.id ORDER BY c DESC"
+ );
+ foreach ($rows as $r) fputcsv($out, [$r['name'], $r['email'], (int)$r['c']]);
+ } else { // daily
+ fputcsv($out, ['Datum', 'Erstellte Voucher']);
+ $rows = $db->fetchAll(
+ "SELECT DATE(created_at) d, COUNT(*) c FROM vouchers
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
+ GROUP BY DATE(created_at) ORDER BY d", [$days]
+ );
+ foreach ($rows as $r) fputcsv($out, [$r['d'], (int)$r['c']]);
+ }
+ fclose($out);
+ exit;
+}
+
+// Kennzahlen
+$totals = $db->fetchOne(
+ "SELECT COUNT(*) total,
+ SUM(status='valid') valid, SUM(status='used') used, SUM(status='expired') expired
+ FROM vouchers"
+);
+$inPeriod = (int)($db->fetchOne(
+ "SELECT COUNT(*) c FROM vouchers WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)", [$days]
+)['c'] ?? 0);
+
+$perSite = $db->fetchAll(
+ "SELECT s.name,
+ COUNT(v.id) total, SUM(v.status='valid') valid, SUM(v.status='used') used, SUM(v.status='expired') expired
+ FROM sites s LEFT JOIN vouchers v ON v.site_id=s.id GROUP BY s.id ORDER BY total DESC"
+);
+$perUser = $db->fetchAll(
+ "SELECT u.name, COUNT(v.id) c FROM users u LEFT JOIN vouchers v ON v.user_id=u.id
+ GROUP BY u.id HAVING c > 0 ORDER BY c DESC LIMIT 10"
+);
+$daily = $db->fetchAll(
+ "SELECT DATE(created_at) d, COUNT(*) c FROM vouchers
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
+ GROUP BY DATE(created_at) ORDER BY d", [$days]
+);
+$chartLabels = array_map(fn($r) => date('d.m', strtotime($r['d'])), $daily);
+$chartData = array_map(fn($r) => (int)$r['c'], $daily);
+
+$csrf = $auth->getCsrfToken();
+$currentPage = 'reports';
+$adminBase = '';
+?>
+
+
+
+
+
+Reporting – = htmlspecialchars($appTitle) ?>
+
+
+
+
+
+📊 Reporting
+
+
+
+
+
= (int)$totals['total'] ?>
Vouchers gesamt
+
= (int)$totals['valid'] ?>
Gültig
+
= (int)$totals['used'] ?>
Verwendet
+
= $inPeriod ?>
In = $days ?> Tagen erstellt
+
+
+
+
Erstellte Voucher (= $days ?> Tage)
+
+
+
+
+
Pro Site
+
Site Gesamt Gültig Verwendet Abgelaufen
+
+ = htmlspecialchars($r['name']) ?> = (int)$r['total'] ?> = (int)$r['valid'] ?> = (int)$r['used'] ?> = (int)$r['expired'] ?>
+
+
+
+
+
+
Top-Nutzer
+
Benutzer Voucher erstellt
+
+ = htmlspecialchars($r['name'] ?? '–') ?> = (int)$r['c'] ?>
+
+ Keine Daten
+
+
+
+
+
+
+
+
diff --git a/admin/security.php b/admin/security.php
new file mode 100644
index 0000000..b1934d0
--- /dev/null
+++ b/admin/security.php
@@ -0,0 +1,194 @@
+requireLogin();
+
+$db = Database::getInstance();
+$user = $auth->getCurrentUser();
+$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
+
+$error = '';
+$success = '';
+$backupCodes = []; // nur direkt nach Erzeugung gefüllt
+$hasPassword = !empty($user['password_hash']);
+$totpEnabled = !empty($user['totp_enabled']);
+$setupRequired = isset($_GET['setup_required']);
+
+// 2FA aktivieren (Code bestaetigen)
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
+ if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = 'Ungültiges Sicherheits-Token';
+ } else {
+ $secret = $_SESSION['totp_setup_secret'] ?? '';
+ $code = trim($_POST['code'] ?? '');
+ if ($secret === '') {
+ $error = 'Setup abgelaufen, bitte erneut starten.';
+ } elseif (!Totp::verify($secret, $code)) {
+ $error = 'Code ungültig. Bitte erneut versuchen.';
+ } else {
+ $backupCodes = $auth->enableTotp($user['id'], $secret);
+ unset($_SESSION['totp_setup_secret']);
+ $totpEnabled = true;
+ $user = $auth->getCurrentUser();
+ $success = 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!';
+ }
+ }
+}
+
+// Überall abmelden (andere Sessions beenden)
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout_others'])) {
+ if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = 'Ungültiges Sicherheits-Token';
+ } else {
+ $auth->logoutOtherSessions();
+ $success = 'Alle anderen Sitzungen wurden beendet.';
+ }
+}
+
+// Recovery-Codes neu erzeugen
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) {
+ if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = 'Ungültiges Sicherheits-Token';
+ } elseif (!empty($user['totp_enabled'])) {
+ $backupCodes = $auth->regenerateBackupCodes($user['id']);
+ $user = $auth->getCurrentUser();
+ $success = 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.';
+ }
+}
+
+// 2FA deaktivieren
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) {
+ if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = 'Ungültiges Sicherheits-Token';
+ } else {
+ $auth->disableTotp($user['id']);
+ $totpEnabled = false;
+ $success = 'Zwei-Faktor-Authentifizierung wurde deaktiviert.';
+ }
+}
+
+// Für die Setup-Ansicht ein Secret erzeugen (in Session halten bis bestätigt)
+$setupSecret = '';
+$otpUri = '';
+if (!$totpEnabled && $hasPassword) {
+ $setupSecret = $_SESSION['totp_setup_secret'] ?? Totp::generateSecret();
+ $_SESSION['totp_setup_secret'] = $setupSecret;
+ $otpUri = Totp::provisioningUri($setupSecret, $user['email'], $appTitle);
+}
+$csrf = $auth->getCsrfToken();
+$dbSessions = $db->getSetting('session_driver', 'php') === 'db';
+$activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
+?>
+
+
+
+
+
+Zwei-Faktor-Authentifizierung – = htmlspecialchars($appTitle) ?>
+
+
+
+
+
+
+
+
🔐 Zwei-Faktor-Authentifizierung
+
Konto: = htmlspecialchars($user['email']) ?>
+
+
+
Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.
+
+
= htmlspecialchars($error) ?>
+
= htmlspecialchars($success) ?>
+
+
+
+
🔑 Recovery-Codes
+
Bewahren Sie diese sicher auf. Jeder Code funktioniert einmal , falls Sie keinen Zugriff auf Ihre App haben.
+
+ = htmlspecialchars($c) ?>
+
+
+
+
+
+
● Nicht verfügbar
+
Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.
+
+
● Aktiv
+
Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.
+ Verbleibende Recovery-Codes: = (int)$auth->backupCodesRemaining($user) ?>
+
+
+ Recovery-Codes neu erzeugen
+
+
+
+ 2FA deaktivieren
+
+
+
● Inaktiv
+
+ Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator …)
+ QR-Code scannen oder Secret manuell eingeben
+ Den angezeigten 6-stelligen Code unten eingeben
+
+
+
= htmlspecialchars($setupSecret) ?>
+
+
+ 6-stelliger Code
+
+ 2FA aktivieren
+
+
+
+
+
+
+
Aktive Sitzungen: = (int)$activeSessions ?>
+
+
+ Auf allen anderen Geräten abmelden
+
+
+
+
← Zurück
+
+
+
diff --git a/admin/templates.php b/admin/templates.php
index b34da6f..7fae89d 100644
--- a/admin/templates.php
+++ b/admin/templates.php
@@ -30,13 +30,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_template'])) {
$expireMin = (int)($_POST['expire_minutes'] ?? 480);
$description = trim($_POST['description'] ?? '');
+ $qosDown = max(0, (int)($_POST['qos_rate_max_down'] ?? 0)) ?: null;
+ $qosUp = max(0, (int)($_POST['qos_rate_max_up'] ?? 0)) ?: null;
+ $qosQuota = max(0, (int)($_POST['qos_usage_quota'] ?? 0)) ?: null;
+
if (empty($name)) throw new Exception(__('error_name_req'));
if ($maxUses < 1) $maxUses = 1;
if ($expireMin < 1) $expireMin = 60;
$db->execute(
- "INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, created_by) VALUES (?, ?, ?, ?, ?)",
- [$name, $maxUses, $expireMin, $description, $_SESSION['user_id']]
+ "INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ [$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']]
);
flashSet(__('templates_added'));
header('Location: templates.php');
@@ -60,11 +64,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) {
$description = trim($_POST['description'] ?? '');
$isActive = isset($_POST['is_active']) ? 1 : 0;
+ $qosDown = max(0, (int)($_POST['qos_rate_max_down'] ?? 0)) ?: null;
+ $qosUp = max(0, (int)($_POST['qos_rate_max_up'] ?? 0)) ?: null;
+ $qosQuota = max(0, (int)($_POST['qos_usage_quota'] ?? 0)) ?: null;
+
if (empty($name)) throw new Exception(__('error_name_req'));
$db->execute(
- "UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, is_active=? WHERE id=?",
- [$name, $maxUses, $expireMin, $description, $isActive, $id]
+ "UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, qos_rate_max_down=?, qos_rate_max_up=?, qos_usage_quota=?, is_active=? WHERE id=?",
+ [$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id]
);
flashSet(__('templates_updated'));
header('Location: templates.php');
@@ -209,7 +217,7 @@ $adminBase = '';
-
@@ -255,6 +263,11 @@ $adminBase = '';
= __('templates_desc') ?>
+
= __('btn_save') ?>
= __('btn_cancel') ?>
@@ -287,6 +300,11 @@ $adminBase = '';
= __('templates_desc') ?>
+
= __('status_active') ?>
@@ -304,13 +322,16 @@ $adminBase = '';
+
@@ -564,6 +625,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
= htmlspecialchars($tpl['name']) ?> –
= (int)$tpl['max_uses'] ?> = __('label_devices') ?>,
@@ -581,6 +645,10 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
+
+
+
+ = $captchaHtml ?>
= __('voucher_name_label') ?>
@@ -627,6 +695,19 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
+
+
+
+
= __('voucher_create_btn') ?>
@@ -640,6 +721,10 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
+
+
+
+ = $captchaHtml ?>