diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..a04fb03
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+.git
+.github
+docs
+*.md
+config.php
+updater/storage/.version
+updater/storage/.maintenance
+updater/storage/.update-staging
+updater/storage/updater-settings.json
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..5791447
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,34 @@
+name: CI
+
+on:
+ push:
+ branches: [ "**" ]
+ pull_request:
+
+jobs:
+ lint:
+ name: PHP Lint
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php: [ "7.4", "8.2" ]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP ${{ matrix.php }}
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: pdo, pdo_mysql, curl, mbstring, json
+ coverage: none
+
+ - name: Syntax check all PHP files
+ run: |
+ set -e
+ find . -name '*.git' -prune -o -name '*.php' -print | while read -r f; do
+ php -l "$f"
+ done
+
+ - name: Validate JSON language/migration assets
+ run: |
+ php -r 'foreach (glob("lang/*.php") as $f) { $a = require $f; if (!is_array($a)) { fwrite(STDERR, "Bad lang file: $f\n"); exit(1);} } echo "lang OK\n";'
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..c090cfc
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,29 @@
+# UniFi Voucher Management System – Container-Image
+FROM php:8.2-apache
+
+# PHP-Extensions
+RUN docker-php-ext-install pdo pdo_mysql \
+ && a2enmod rewrite headers
+
+# Empfohlene PHP-Einstellungen
+RUN { \
+ echo 'display_errors=0'; \
+ echo 'log_errors=1'; \
+ echo 'expose_php=0'; \
+ echo 'upload_max_filesize=8M'; \
+ echo 'post_max_size=8M'; \
+ } > /usr/local/etc/php/conf.d/zz-voucher.ini
+
+WORKDIR /var/www/html
+COPY . /var/www/html
+
+# Laufzeit-Verzeichnis des Updaters beschreibbar machen
+RUN mkdir -p /var/www/html/updater/storage \
+ && chown -R www-data:www-data /var/www/html
+
+COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
+RUN chmod +x /usr/local/bin/entrypoint.sh
+
+EXPOSE 80
+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+CMD ["apache2-foreground"]
diff --git a/admin/api_keys.php b/admin/api_keys.php
new file mode 100644
index 0000000..ae531ea
--- /dev/null
+++ b/admin/api_keys.php
@@ -0,0 +1,155 @@
+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 {
+ $k = ApiKey::generate();
+ $db->execute(
+ "INSERT INTO api_keys (name, key_prefix, key_hash, created_by) VALUES (?, ?, ?, ?)",
+ [$name, $k['prefix'], $k['hash'], $_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 | Status | Zuletzt genutzt | Erstellt von | |
+
+
+ | = htmlspecialchars($k['name']) ?> |
+ uvt_= htmlspecialchars($k['key_prefix']) ?>… |
+ = $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_…"
+
+
+
+
+
+
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/integrations.php b/admin/integrations.php
new file mode 100644
index 0000000..359f52d
--- /dev/null
+++ b/admin/integrations.php
@@ -0,0 +1,118 @@
+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('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).';
+}
+
+$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) ?>
+
+
+
+
+
+
+
diff --git a/api/bootstrap.php b/api/bootstrap.php
new file mode 100644
index 0000000..33c85c8
--- /dev/null
+++ b/api/bootstrap.php
@@ -0,0 +1,39 @@
+ 'database_unavailable'], 503);
+}
+
+$apiKeyRow = ApiKey::verify(ApiKey::fromRequest(), $db);
+if (!$apiKeyRow) {
+ api_json(['error' => 'unauthorized', 'message' => 'Gültiger API-Schlüssel erforderlich (Authorization: Bearer …)'], 401);
+}
diff --git a/api/sites.php b/api/sites.php
new file mode 100644
index 0000000..7bc0ceb
--- /dev/null
+++ b/api/sites.php
@@ -0,0 +1,14 @@
+ 'method_not_allowed'], 405);
+}
+
+$sites = $db->fetchAll("SELECT id, name, site_id FROM sites WHERE is_active = 1 ORDER BY name");
+api_json(['sites' => array_map(function ($s) {
+ return ['id' => (int)$s['id'], 'name' => $s['name'], 'site_id' => $s['site_id']];
+}, $sites)]);
diff --git a/api/vouchers.php b/api/vouchers.php
new file mode 100644
index 0000000..e8f29c6
--- /dev/null
+++ b/api/vouchers.php
@@ -0,0 +1,89 @@
+ – Voucher einer Site auflisten (aus DB)
+ * POST /api/vouchers.php – Voucher erstellen
+ * Body (JSON): {
+ * "site_id": 1, "name": "API Gast", "max_uses": 1,
+ * "expire_minutes": 480,
+ * "qos": { "down": 10000, "up": 2000, "quota_mb": 500 } (optional)
+ * }
+ */
+require_once __DIR__ . '/bootstrap.php';
+require_once __DIR__ . '/../includes/Notifier.php';
+
+$method = $_SERVER['REQUEST_METHOD'];
+
+if ($method === 'GET') {
+ $siteId = (int)($_GET['site_id'] ?? 0);
+ if ($siteId <= 0) {
+ api_json(['error' => 'invalid_request', 'message' => 'site_id erforderlich'], 400);
+ }
+ $rows = $db->fetchAll(
+ "SELECT voucher_code, voucher_name, max_uses, expire_minutes, status, used_count, created_at, expires_at
+ FROM vouchers WHERE site_id = ? ORDER BY created_at DESC LIMIT 200",
+ [$siteId]
+ );
+ api_json(['vouchers' => $rows]);
+}
+
+if ($method === 'POST') {
+ $body = api_body();
+ $siteId = (int)($body['site_id'] ?? 0);
+ $name = trim((string)($body['name'] ?? ''));
+ $maxUses = (int)($body['max_uses'] ?? 1);
+ $expireMinutes = (int)($body['expire_minutes'] ?? 480);
+
+ if ($siteId <= 0) api_json(['error' => 'invalid_request', 'message' => 'site_id erforderlich'], 400);
+ if ($name === '') api_json(['error' => 'invalid_request', 'message' => 'name erforderlich'], 400);
+ if ($maxUses < 1) $maxUses = 1;
+ if ($expireMinutes < 1) $expireMinutes = 480;
+
+ $site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
+ if (!$site) {
+ api_json(['error' => 'not_found', 'message' => 'Site nicht gefunden'], 404);
+ }
+
+ $qosIn = is_array($body['qos'] ?? null) ? $body['qos'] : [];
+ $qos = [
+ 'down' => max(0, (int)($qosIn['down'] ?? 0)),
+ 'up' => max(0, (int)($qosIn['up'] ?? 0)),
+ 'quota_mb' => max(0, (int)($qosIn['quota_mb'] ?? 0)),
+ ];
+
+ try {
+ $fullName = date('Y-m-d') . '_' . $name;
+ $controller = new UniFiController(
+ $site['unifi_controller_url'],
+ $site['unifi_username'],
+ Crypto::decrypt($site['unifi_password']),
+ $site['site_id']
+ );
+ $voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
+ if (!is_array($voucher) || empty($voucher['formatted_code'])) {
+ api_json(['error' => 'upstream_error', 'message' => 'UniFi lieferte keinen gültigen Voucher'], 502);
+ }
+
+ $db->execute(
+ "INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
+ VALUES (?, NULL, ?, ?, ?, ?, ?)",
+ [$siteId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
+ );
+
+ Notifier::voucherCreated(1, $site['name'], 'API: ' . $apiKeyRow['name']);
+
+ api_json([
+ 'success' => true,
+ 'code' => $voucher['code'],
+ 'formatted_code' => $voucher['formatted_code'],
+ 'site' => $site['name'],
+ 'max_uses' => $maxUses,
+ 'expire_minutes' => $expireMinutes,
+ ], 201);
+ } catch (Exception $e) {
+ api_json(['error' => 'server_error', 'message' => $e->getMessage()], 500);
+ }
+}
+
+api_json(['error' => 'method_not_allowed'], 405);
diff --git a/cron_cleanup.php b/cron_cleanup.php
new file mode 100644
index 0000000..7e958a4
--- /dev/null
+++ b/cron_cleanup.php
@@ -0,0 +1,117 @@
+ $v) echo " - $k: $v" . PHP_EOL;
+ }
+ } else {
+ echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
+ }
+}
+
+try {
+ $db = Database::getInstance();
+} catch (Exception $e) {
+ out(['success' => false, 'message' => 'DB-Fehler: ' . $e->getMessage()], $isCli);
+ exit;
+}
+
+// Token prüfen (gleicher Token wie cron_sync)
+$cronToken = $db->getSetting('cron_token', '');
+$provided = $isCli ? ($argv[1] ?? '') : ($_GET['token'] ?? '');
+if (empty($cronToken)) {
+ out(['success' => false, 'message' => 'Kein Cron-Token konfiguriert.'], $isCli);
+ exit;
+}
+if (!hash_equals($cronToken, (string)$provided)) {
+ out(['success' => false, 'message' => 'Ungültiger Token'], $isCli);
+ exit;
+}
+
+$expiredDays = (int)$db->getSetting('cleanup_expired_days', 0);
+$auditDays = (int)$db->getSetting('cleanup_audit_days', 0);
+$loginDays = (int)$db->getSetting('cleanup_login_days', 30);
+
+$deleted = [];
+
+try {
+ // Abgelaufene Voucher aus der DB entfernen (nur lokale Historie)
+ if ($expiredDays > 0) {
+ $stmt = $db->query(
+ "DELETE FROM vouchers WHERE status = 'expired'
+ AND expires_at IS NOT NULL AND expires_at < DATE_SUB(NOW(), INTERVAL ? DAY)",
+ [$expiredDays]
+ );
+ $deleted['vouchers_expired'] = $stmt->rowCount();
+ }
+
+ // Audit-Log nach Aufbewahrungsfrist löschen
+ if ($auditDays > 0) {
+ try {
+ $stmt = $db->query(
+ "DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)",
+ [$auditDays]
+ );
+ $deleted['audit_log'] = $stmt->rowCount();
+ } catch (Exception $e) { /* Tabelle evtl. nicht vorhanden */ }
+ }
+
+ // Alte Login-Versuche entfernen
+ if ($loginDays > 0) {
+ try {
+ $stmt = $db->query(
+ "DELETE FROM login_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL ? DAY)",
+ [$loginDays]
+ );
+ $deleted['login_attempts'] = $stmt->rowCount();
+ } catch (Exception $e) { /* ignore */ }
+ }
+
+ // Abgelaufene / benutzte Passwort-Reset-Tokens immer entfernen
+ try {
+ $stmt = $db->query(
+ "DELETE FROM password_reset_tokens WHERE used = 1 OR expires_at < NOW()"
+ );
+ $deleted['reset_tokens'] = $stmt->rowCount();
+ } catch (Exception $e) { /* Tabelle evtl. nicht vorhanden */ }
+
+ $db->query(
+ "INSERT INTO settings (setting_key, setting_value) VALUES ('last_cleanup', NOW())
+ ON DUPLICATE KEY UPDATE setting_value = NOW()"
+ );
+
+ out([
+ 'success' => true,
+ 'message' => 'Cleanup abgeschlossen',
+ 'deleted' => $deleted,
+ 'timestamp' => date('Y-m-d H:i:s'),
+ ], $isCli);
+} catch (Exception $e) {
+ out(['success' => false, 'message' => 'Fehler: ' . $e->getMessage(), 'deleted' => $deleted], $isCli);
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..876fdc3
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,38 @@
+services:
+ app:
+ build: .
+ ports:
+ - "8080:80"
+ environment:
+ DB_HOST: db
+ DB_NAME: unifi_voucher
+ DB_USER: unifi_voucher
+ DB_PASS: change_me
+ # Dauerhaft setzen! Sonst koennen verschluesselte Werte nach Neustart
+ # nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));"
+ APP_KEY: ""
+ TZ: Europe/Berlin
+ depends_on:
+ db:
+ condition: service_healthy
+ restart: unless-stopped
+
+ db:
+ image: mariadb:11
+ environment:
+ MARIADB_DATABASE: unifi_voucher
+ MARIADB_USER: unifi_voucher
+ MARIADB_PASSWORD: change_me
+ MARIADB_ROOT_PASSWORD: change_me_root
+ volumes:
+ - db_data:/var/lib/mysql
+ - ./database.sql:/docker-entrypoint-initdb.d/01_schema.sql:ro
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ restart: unless-stopped
+
+volumes:
+ db_data:
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100644
index 0000000..457cec9
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+set -e
+
+CONFIG=/var/www/html/config.php
+
+# config.php aus Umgebungsvariablen erzeugen, falls noch nicht vorhanden und
+# DB-Variablen gesetzt sind. Sonst kann der Web-Installer (install.php) genutzt
+# werden. APP_KEY wird einmalig generiert und sollte als ENV persistiert werden.
+if [ ! -f "$CONFIG" ] && [ -n "$DB_HOST" ] && [ -n "$DB_NAME" ]; then
+ if [ -z "$APP_KEY" ]; then
+ APP_KEY=$(php -r 'echo base64_encode(random_bytes(32));')
+ echo "[entrypoint] WARN: kein APP_KEY gesetzt – generiere einmaligen Schluessel."
+ echo "[entrypoint] Fuer dauerhaften Betrieb APP_KEY als ENV setzen: $APP_KEY"
+ fi
+ cat > "$CONFIG" <
+ * 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;
+ }
+}
diff --git a/includes/Notifier.php b/includes/Notifier.php
new file mode 100644
index 0000000..f05f6cf
--- /dev/null
+++ b/includes/Notifier.php
@@ -0,0 +1,53 @@
+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]
+ );
+ }
+}
diff --git a/includes/admin_nav.php b/includes/admin_nav.php
index 8b0bdda..a3588c2 100644
--- a/includes/admin_nav.php
+++ b/includes/admin_nav.php
@@ -113,9 +113,18 @@ $lang = I18n::getLanguage();
= __('nav_settings') ?>
+
+ = __('nav_api_keys') ?>
+
= __('nav_security') ?>
+
+ = __('nav_integrations') ?>
+
+
+ = __('nav_backup') ?>
+
= __('nav_update') ?>
diff --git a/index.php b/index.php
index 94411b5..d5743be 100644
--- a/index.php
+++ b/index.php
@@ -16,6 +16,7 @@ require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/UniFiController.php';
require_once __DIR__ . '/includes/Mailer.php';
+require_once __DIR__ . '/includes/Notifier.php';
require_once __DIR__ . '/includes/I18n.php';
$auth = new Auth();
@@ -158,6 +159,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
$voucherData = doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos);
$voucherCode = $voucherData['code'];
$voucherCreated = true;
+ Notifier::voucherCreated(1, $site['name'], $_SESSION['user_name'] ?? null);
if ($sendEmail && !empty($recipientEmail)) {
$mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses);
@@ -207,6 +209,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
$bulkVouchers[] = doCreateVoucher($db, $site, $voucherName . '_' . ($i + 1), $maxUses, $expireMinutes, $userId, $qos);
}
+ Notifier::voucherCreated($bulkCount, $site['name'], $_SESSION['user_name'] ?? null);
$bulkCreated = true;
$success = str_replace('{count}', $bulkCount, __('bulk_success'));
} catch (Exception $e) {
diff --git a/lang/de.php b/lang/de.php
index 2b69524..406cc9c 100644
--- a/lang/de.php
+++ b/lang/de.php
@@ -8,7 +8,10 @@ return [
'nav_templates' => 'Voucher-Profile',
'nav_audit_log' => 'Audit-Log',
'nav_settings' => 'Einstellungen',
+ 'nav_api_keys' => 'API-Schlüssel',
'nav_security' => 'Sicherheit (2FA)',
+ 'nav_integrations' => 'Integration & Wartung',
+ 'nav_backup' => 'Backup & Restore',
'nav_update' => 'System-Update',
'nav_back' => 'Zurück zur Startseite',
'nav_administration'=> 'Administration',
diff --git a/lang/en.php b/lang/en.php
index 561fd45..55ba900 100644
--- a/lang/en.php
+++ b/lang/en.php
@@ -8,7 +8,10 @@ return [
'nav_templates' => 'Voucher Profiles',
'nav_audit_log' => 'Audit Log',
'nav_settings' => 'Settings',
+ 'nav_api_keys' => 'API Keys',
'nav_security' => 'Security (2FA)',
+ 'nav_integrations' => 'Integration & Maintenance',
+ 'nav_backup' => 'Backup & Restore',
'nav_update' => 'System Update',
'nav_back' => 'Back to Home',
'nav_administration'=> 'Administration',