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

+ +
+
+ + +
+

Neuer Schlüssel

+

Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.

+
+
+ + +
+

Neuen API-Schlüssel erstellen

+
+ +
+ + +
+ +
+
+ +
+

Vorhandene Schlüssel

+ +

Noch keine API-Schlüssel angelegt.

+ + + + + + + + + + + + + +
NamePräfixStatusZuletzt genutztErstellt von
uvt_ + + 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

+ +
+
+ +
+

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

+ +
+
+ +
+ + +
+

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.

+ + + +
+ Test senden +
+
+ +
+

Datenhaltung & Cleanup (DSGVO)

+

Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per cron_cleanup.php (täglich empfohlen). +
Letzter Lauf: +

+
+
+
+
+
+
+ + +
+ + + + + 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();
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • 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',