diff --git a/Readme.md b/Readme.md index ee8d96d..a64a944 100644 --- a/Readme.md +++ b/Readme.md @@ -1,429 +1,162 @@ -# UniFi Voucher Management System - -Ein professionelles, webbasiertes System zur Verwaltung von WLAN-Vouchers für UniFi Controller mit Multi-Site-Unterstützung, Benutzerverwaltung und Microsoft 365 Integration. - -## ✨ Features - -### Kern-Funktionen -- 🎫 **Voucher-Erstellung**: Einfache Erstellung von zeitbegrenzten WLAN-Zugangscodes -- 🏢 **Multi-Site-Support**: Verwaltung mehrerer UniFi Sites/Standorte -- 👥 **Benutzerverwaltung**: Granulare Zugriffskontrolle auf Site-Ebene -- 🔐 **Authentifizierung**: Lokale Accounts und Microsoft 365 OAuth -- 📊 **Admin-Dashboard**: Übersichtliche Statistiken und Historie -- 🌐 **Öffentlicher Zugriff**: Optional ohne Login nutzbar -- 🎨 **Modernes Design**: Responsives, helles und professionelles UI - -### Sicherheit -- CSRF-Schutz für alle Formulare -- Password-Hashing mit bcrypt -- Session-Management mit konfigurierbaren Timeouts -- SQL-Injection-Schutz durch Prepared Statements -- Rollenbasierte Zugriffskontrolle (Admin/User) - -## 📋 Anforderungen - -### Server-Anforderungen -- PHP 7.4 oder höher -- MySQL 5.7+ oder MariaDB 10.2+ -- Apache/Nginx Webserver -- PHP-Extensions: - - PDO - - PDO_MySQL - - cURL - - mbstring - - JSON - -### UniFi Controller -- UniFi Network Application 7.0+ mit UniFi OS (z.B. UDM, UDR, UniFi OS Server) -- API-Zugriff aktiviert -- Lokaler Admin-Account oder dedizierter API-User - -## 🚀 Installation - -### Schritt 1: Dateien hochladen -```bash -# Repository klonen oder ZIP herunterladen -git clone https://github.com/friloo/unifi-voucher-tool.git -cd unifi-voucher-tool - -# Dateien auf den Webserver hochladen -# Stellen Sie sicher, dass der Webserver-User Schreibrechte hat -``` - -### Schritt 2: Ordnerstruktur - -``` -/ -├── config.php (wird vom Installer erstellt) -├── install.php -├── index.php -├── login.php -├── logout.php -├── database.sql -├── .htaccess (wird vom Installer erstellt) -├── includes/ -│ ├── Database.php -│ ├── Auth.php -│ └── UniFiController.php -└── admin/ - ├── index.php - ├── sites.php - ├── users.php - ├── vouchers.php - └── settings.php -``` - -### Schritt 3: Installation durchführen - -1. Öffnen Sie `http://ihre-domain.de/install.php` im Browser -2. Folgen Sie dem 5-Schritte-Installations-Assistenten: - -#### Schritt 1: Datenbank-Konfiguration -- Datenbank-Host (meist `localhost`) -- Datenbankname (z.B. `unifi_voucher`) -- Datenbank-Benutzer -- Datenbank-Passwort - -#### Schritt 2: Administrator-Account -- Name -- E-Mail-Adresse -- Passwort (min. 8 Zeichen) - -#### Schritt 3: Allgemeine Einstellungen -- Anwendungs-Titel -- Logo-URL (optional) -- Anleitung für Benutzer -- Öffentlicher Zugriff aktivieren (optional) - -#### Schritt 4: Microsoft 365 Integration (optional) -- Client ID -- Client Secret -- Tenant ID - -#### Schritt 5: Installation abschließen - -Nach erfolgreicher Installation wird automatisch: -- Die Datenbank erstellt und initialisiert -- Die `config.php` Datei generiert -- Die `.htaccess` für URL-Rewriting erstellt -- Der Admin-Account angelegt - -### Schritt 4: Installation sichern - -Nach erfolgreicher Installation: -```bash -# install.php umbenennen oder löschen -mv install.php install.php.bak - -# Oder komplett entfernen -rm install.php -``` - -## 🎯 Erste Schritte - -### 1. Als Administrator anmelden -- Öffnen Sie `http://ihre-domain.de/login.php` -- Melden Sie sich mit Ihren Admin-Zugangsdaten an - -### 2. Sites konfigurieren -1. Navigieren Sie zu **Administration** → **Sites verwalten** -2. Klicken Sie auf **Neue Site hinzufügen** -3. Geben Sie folgende Daten ein: - - **Name**: Anzeigename (z.B. "Hauptgebäude") - - **Site ID**: UniFi Site ID (z.B. "default") - - **Controller URL**: URL Ihres UniFi Controllers (z.B. "https://unifi.example.com:11443") - - **Benutzername**: UniFi Admin-Username - - **Passwort**: UniFi Admin-Passwort - - **Öffentlicher Zugriff**: Aktivieren für Login-freie Nutzung - -4. Klicken Sie auf **Verbindung testen**, um die Einstellungen zu überprüfen -5. Speichern Sie die Site - -### 3. Benutzer anlegen -1. Navigieren Sie zu **Administration** → **Benutzer verwalten** -2. Klicken Sie auf **Neuer Benutzer** -3. Geben Sie die Benutzerdaten ein: - - Name - - E-Mail - - Passwort - - Admin-Rechte (optional) -4. Wählen Sie die Sites aus, auf die der Benutzer Zugriff haben soll -5. Speichern Sie den Benutzer - -### 4. Vouchers erstellen -1. Gehen Sie zur Startseite -2. Wählen Sie eine Site aus -3. Geben Sie einen Voucher-Namen ein -4. Legen Sie die Anzahl der Geräte fest (1-10) -5. Klicken Sie auf **Voucher erstellen** -6. Der Code wird sofort angezeigt und ist 8 Stunden gültig - -## 🔧 Konfiguration - -### config.php -Die Datei wird automatisch erstellt, kann aber manuell angepasst werden: - -```php - - Order Allow,Deny - Deny from all - - - - Order Allow,Deny - Deny from all - -``` - -### Datenbank-Benutzer -Erstellen Sie einen dedizierten Datenbankbenutzer nur für diese Anwendung: - -```sql -CREATE USER 'unifi_voucher'@'localhost' IDENTIFIED BY 'sicheres_passwort'; -GRANT SELECT, INSERT, UPDATE, DELETE ON unifi_voucher.* TO 'unifi_voucher'@'localhost'; -FLUSH PRIVILEGES; -``` - -### HTTPS erzwingen -```apache -# In .htaccess hinzufügen -RewriteEngine On -RewriteCond %{HTTPS} off -RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] -``` - -### Regelmäßige Updates -- PHP und MySQL aktuell halten -- Sicherheitspatches zeitnah einspielen -- Passwörter regelmäßig ändern - -## 📚 Verwendung - -### Für Endbenutzer - -**Voucher erstellen:** -1. Startseite öffnen (Login optional je nach Konfiguration) -2. Voucher-Name eingeben -3. Anzahl Geräte wählen -4. Standort auswählen -5. Code erstellen und notieren - -**Code verwenden:** -1. Mit dem WLAN verbinden -2. Browser öffnet automatisch Anmeldeseite -3. Voucher-Code eingeben -4. Zugang für 8 Stunden - -### Für Administratoren - -**Sites verwalten:** -- Neue Standorte hinzufügen -- Verbindungen testen -- Sites deaktivieren -- Zugangsdaten aktualisieren - -**Benutzer verwalten:** -- Neue Benutzer anlegen -- Berechtigungen zuweisen -- Sites-Zugriff konfigurieren -- Admin-Rechte vergeben - -**Historie einsehen:** -- Alle erstellten Vouchers -- Filterfunktionen nach Site/Benutzer/Datum -- Export-Funktion (optional) - -## 🐛 Problembehandlung - -### Häufige Probleme - -**Login funktioniert nicht:** -- Prüfen Sie die Datenbankverbindung -- Stellen Sie sicher, dass Sessions funktionieren -- Überprüfen Sie die PHP-Session-Konfiguration - -**UniFi-Verbindung schlägt fehl:** -- Testen Sie die Controller-URL im Browser -- Prüfen Sie Benutzername und Passwort -- Stellen Sie sicher, dass cURL aktiviert ist -- Prüfen Sie SSL-Zertifikate (CURLOPT_SSL_VERIFYPEER) - -**UniFi OS: Verbindung schlägt fehl (HTTP 404 oder 401):** -- Stellen Sie sicher, dass Sie Port 11443 verwenden (nicht 8443) -- UniFi OS erfordert den Pfad `/proxy/network/api/s/{site}/...` für alle API-Aufrufe -- Der Login-Endpunkt lautet `/api/auth/login` (nicht `/api/login`) -- Ältere UniFi Network Controller ohne UniFi OS werden ab Version 2.1.0 nicht mehr unterstützt -- Bei anhaltenden 401-Fehlern: Prüfen Sie, ob der UniFi-Account lokale API-Rechte besitzt - -**Voucher werden nicht erstellt:** -- Überprüfen Sie die UniFi Controller Logs -- Prüfen Sie API-Berechtigungen -- Stellen Sie sicher, dass die Site-ID korrekt ist - -**Microsoft 365 Login funktioniert nicht:** -- Prüfen Sie die Redirect URI -- Überprüfen Sie Client ID und Secret -- Stellen Sie sicher, dass API-Berechtigungen erteilt wurden - -### Debugging aktivieren - -In `config.php` hinzufügen: -```php -error_reporting(E_ALL); -ini_set('display_errors', 1); -ini_set('log_errors', 1); -ini_set('error_log', '/pfad/zu/error.log'); -``` - -## 🔄 Update/Migration - -### Von der alten Version migrieren -Das System ist eine komplette Neuentwicklung. Migration erfordert: - -1. **Daten-Export** aus dem alten System (falls vorhanden) -2. **Neue Installation** gemäß dieser Anleitung durchführen -3. **Sites manuell neu anlegen** -4. **Benutzer neu erstellen** - -### Updates einspielen -```bash -# Backup erstellen -mysqldump -u username -p database_name > backup.sql -cp -r /var/www/html/voucher /backup/voucher-$(date +%Y%m%d) - -# Neue Dateien hochladen (config.php nicht überschreiben!) -# Datenbank-Updates ausführen falls vorhanden -``` - -## 📝 API-Dokumentation - -### UniFi OS API Endpoints - -> **Hinweis:** Ab Version 2.1.0 verwendet dieses Tool die UniFi OS API (Port 11443). -> Ältere Installationen mit dem klassischen UniFi Network Controller (Port 8443) müssen -> auf UniFi OS migrieren oder weiterhin Version 2.0.x verwenden. - -**Login:** -``` -POST /api/auth/login -Body: {"username": "admin", "password": "password"} -Response-Header: X-CSRF-Token: -``` - -> Der `X-CSRF-Token`-Wert aus dem Login-Response-Header wird automatisch extrahiert und -> bei allen nachfolgenden POST-Anfragen als `X-CSRF-Token`-Header mitgesendet. - -**Voucher erstellen:** -``` -POST /proxy/network/api/s/{site_id}/cmd/hotspot -Headers: X-CSRF-Token: -Body: { - "cmd": "create-voucher", - "expire": 480, - "n": 1, - "note": "Voucher Name", - "quota": 1 -} -``` - -**Vouchers abrufen:** -``` -GET /proxy/network/api/s/{site_id}/stat/voucher -``` - -**Voucher löschen:** -``` -POST /proxy/network/api/s/{site_id}/cmd/hotspot -Headers: X-CSRF-Token: -Body: {"cmd": "delete-voucher", "_id": ""} -``` - -## 🤝 Mitwirken - -Contributions sind willkommen! Bitte: - -1. Forken Sie das Repository -2. Erstellen Sie einen Feature-Branch (`git checkout -b feature/AmazingFeature`) -3. Committen Sie Ihre Änderungen (`git commit -m 'Add some AmazingFeature'`) -4. Pushen Sie den Branch (`git push origin feature/AmazingFeature`) -5. Öffnen Sie einen Pull Request - -## 📄 Lizenz - -Dieses Projekt steht unter der MIT-Lizenz. Siehe `LICENSE` Datei für Details. - -## 👨‍💻 Autor - -**Friederich Loheide** - -## 🙏 Danksagungen - -- UniFi Controller API Dokumentation -- Microsoft Graph API -- Bootstrap und FontAwesome Icons - -## 📞 Support - -Bei Fragen oder Problemen: -- Erstellen Sie ein Issue auf GitHub -- E-Mail an support@example.com - -## 🗺️ Roadmap - -Geplante Features: -- [ ] Voucher-Templates -- [ ] Bulk-Voucher-Erstellung -- [ ] QR-Code-Generierung -- [ ] SMS-Versand von Codes -- [ ] Erweiterte Reporting-Funktionen -- [ ] REST API für externe Integration -- [ ] Docker-Container -- [ ] Mehrsprachigkeit - ---- - -**Version:** 2.1.0 -**Letztes Update:** April 2026 \ No newline at end of file +# UniFi Voucher Management System + +Webbasiertes System zur Verwaltung von WLAN-Vouchers für UniFi OS mit Multi-Site-Unterstützung, Benutzerverwaltung und Microsoft 365 Integration. + +## Features + +- **Voucher-Erstellung** mit QR-Code-Anzeige und E-Mail-Versand +- **Multi-Site-Support** – mehrere UniFi-Standorte verwalten +- **Benutzerverwaltung** mit granularer Site-Zugriffskontrolle +- **Authentifizierung** via lokale Accounts oder Microsoft 365 OAuth +- **CSV-Export** aller Vouchers pro Site +- **Admin-Dashboard** mit Live-Statistiken und Sync-Funktion +- **Öffentlicher Zugriff** – optional ohne Login nutzbar +- CSRF-Schutz, bcrypt-Passwörter, Prepared Statements, Login-Rate-Limiting + +## Anforderungen + +- PHP 7.4+, MySQL 5.7+ / MariaDB 10.2+, Apache/Nginx +- PHP-Extensions: PDO, PDO_MySQL, cURL, mbstring, JSON +- **UniFi Network Application 7.0+ mit UniFi OS** (z.B. UDM, UDR, UniFi OS Server) + +## Installation + +```bash +git clone https://github.com/friloo/unifi-voucher-tool.git +cd unifi-voucher-tool +``` + +1. Dateien auf den Webserver hochladen +2. `http://ihre-domain.de/install.php` öffnen +3. Den 5-Schritte-Assistenten durchlaufen: + - **Schritt 1:** Datenbank-Verbindungsdaten + - **Schritt 2:** Administrator-Account (Name, E-Mail, Passwort) + - **Schritt 3:** Allgemeine Einstellungen (Titel, Logo, öffentlicher Zugriff) + - **Schritt 4:** Microsoft 365 Integration (optional) + - **Schritt 5:** Installation abschließen +4. `install.php` nach erfolgreicher Installation löschen + +## Sites konfigurieren + +1. **Administration → Sites verwalten → Neue Site hinzufügen** +2. Felder ausfüllen: + - **Name:** Anzeigename (z.B. „Hauptgebäude") + - **Site ID:** UniFi Site ID (meist `default`) + - **Controller URL:** `https://unifi.example.com:11443` + - **Benutzername / Passwort:** UniFi Admin-Zugangsdaten +3. **Verbindung testen** klicken, dann speichern + +## Voucher erstellen + +1. Startseite öffnen (Login je nach Konfiguration optional) +2. Voucher-Name, Anzahl Geräte und Standort wählen +3. **Voucher erstellen** – Code und QR-Code werden sofort angezeigt +4. Code per E-Mail senden oder ausdrucken + +## Konfiguration + +### config.php +Wird automatisch durch den Installer erstellt: +```php + + Order Allow,Deny + Deny from all + +``` + +```sql +-- Dedizierter Datenbank-Benutzer +CREATE USER 'unifi_voucher'@'localhost' IDENTIFIED BY 'sicheres_passwort'; +GRANT SELECT, INSERT, UPDATE, DELETE ON unifi_voucher.* TO 'unifi_voucher'@'localhost'; +``` + +## Problembehandlung + +**Login funktioniert nicht:** +- Datenbankverbindung und PHP-Session-Konfiguration prüfen + +**UniFi-Verbindung schlägt fehl:** +- Controller-URL im Browser testen +- Port 11443 für UniFi OS verwenden (nicht 8443) +- Benutzername, Passwort und Site ID prüfen +- cURL-Extension muss aktiviert sein + +**UniFi OS: HTTP 404 oder 401:** +- Login-Endpunkt ist `/api/auth/login` (nicht `/api/login`) +- API-Pfade benötigen Präfix `/proxy/network/api/s/{site}/...` +- Älterer UniFi Network Controller (ohne UniFi OS) wird ab Version 2.1.0 nicht mehr unterstützt + +**Voucher werden nicht erstellt:** +- UniFi Controller Logs prüfen +- API-Berechtigungen des Admin-Accounts prüfen +- Site ID korrekt? (zu finden in der UniFi Controller URL) + +**Microsoft 365 Login funktioniert nicht:** +- Redirect URI in Azure AD prüfen +- Client ID, Secret und Tenant ID kontrollieren + +## API-Dokumentation + +**Login (UniFi OS):** +``` +POST /api/auth/login +Body: {"username": "admin", "password": "password"} +Response-Header: X-CSRF-Token: +``` + +**Voucher erstellen:** +``` +POST /proxy/network/api/s/{site_id}/cmd/hotspot +X-CSRF-Token: +Body: {"cmd": "create-voucher", "expire": 480, "n": 1, "note": "Name", "quota": 1} +``` + +**Vouchers abrufen:** +``` +GET /proxy/network/api/s/{site_id}/stat/voucher +``` + +**Voucher löschen:** +``` +POST /proxy/network/api/s/{site_id}/cmd/hotspot +X-CSRF-Token: +Body: {"cmd": "delete-voucher", "_id": ""} +``` + +## Roadmap + +- [ ] Voucher-Templates (vordefinierte Laufzeiten) +- [ ] Bulk-Voucher-Erstellung +- [ ] Erweiterte Reporting-Funktionen +- [ ] Docker-Container +- [ ] Mehrsprachigkeit + +--- + +**Version:** 2.1.0 | **Autor:** Friederich Loheide | **Letztes Update:** April 2026 diff --git a/admin/settings.php b/admin/settings.php index c95027d..9bc2255 100644 --- a/admin/settings.php +++ b/admin/settings.php @@ -5,6 +5,7 @@ ini_set('display_errors', 1); require_once __DIR__ . '/../config.php'; require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/Mailer.php'; $auth = new Auth(); $auth->requireAdmin(); @@ -12,6 +13,27 @@ $auth->requireAdmin(); $db = Database::getInstance(); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); +// AJAX: SMTP-Test-E-Mail senden +if (isset($_POST['ajax_smtp_test'])) { + header('Content-Type: application/json'); + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + echo json_encode(['success' => false, 'message' => 'Ungültiges Token']); + exit; + } + $to = trim($_POST['test_email'] ?? ''); + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + echo json_encode(['success' => false, 'message' => 'Ungültige E-Mail-Adresse']); + exit; + } + $mailer = new Mailer(); + $ok = $mailer->sendTestEmail($to); + echo json_encode([ + 'success' => $ok, + 'message' => $ok ? "Test-E-Mail wurde an {$to} gesendet." : 'Versand fehlgeschlagen. Prüfen Sie die SMTP-Einstellungen und den PHP-Fehlerlog.' + ]); + exit; +} + $error = ''; $success = ''; @@ -542,6 +564,20 @@ $faviconUrl = $db->getSetting('favicon_url', ''); + +
+

SMTP testen

+
+
+ + +
+ +
+ +
@@ -749,6 +785,32 @@ $faviconUrl = $db->getSetting('favicon_url', ''); }); } + // SMTP testen + async function testSmtp() { + const email = document.getElementById('smtpTestEmail').value.trim(); + const btn = document.getElementById('smtpTestBtn'); + const result = document.getElementById('smtpTestResult'); + if (!email) { result.textContent = 'Bitte eine E-Mail-Adresse eingeben.'; result.style.color = '#c33'; return; } + btn.disabled = true; + btn.innerHTML = ' Sende...'; + result.textContent = ''; + const fd = new FormData(); + fd.append('ajax_smtp_test', '1'); + fd.append('csrf_token', 'getCsrfToken() ?>'); + fd.append('test_email', email); + try { + const res = await fetch('settings.php', { method: 'POST', body: fd }); + const data = await res.json(); + result.textContent = data.message; + result.style.color = data.success ? '#3c3' : '#c33'; + } catch(e) { + result.textContent = 'Fehler beim Senden.'; + result.style.color = '#c33'; + } + btn.disabled = false; + btn.innerHTML = ' Testen'; + } + // Cron-Job testen async function testCronJob() { const btn = document.getElementById('testCronBtn'); diff --git a/admin/sites.php b/admin/sites.php index e5a14f2..2710302 100644 --- a/admin/sites.php +++ b/admin/sites.php @@ -654,6 +654,22 @@ $currentUser = $auth->getCurrentUser(); document.getElementById('editSiteModal').classList.add('active'); } + // Loading-State bei Formular-Absenden (verhindert Freeze-Eindruck) + document.getElementById('addSiteForm').addEventListener('submit', function() { + const btn = this.querySelector('button[name="add_site"]'); + if (btn) { + btn.disabled = true; + btn.innerHTML = ' Verbindung wird getestet...'; + } + }); + document.getElementById('editSiteForm').addEventListener('submit', function() { + const btn = this.querySelector('button[name="edit_site"]'); + if (btn) { + btn.disabled = true; + btn.innerHTML = ' Verbindung wird getestet...'; + } + }); + // Modal schließen bei Klick außerhalb document.getElementById('addSiteModal').addEventListener('click', function(e) { if (e.target === this) { diff --git a/admin/vouchers.php b/admin/vouchers.php index b2b4e5d..3bcd8ef 100644 --- a/admin/vouchers.php +++ b/admin/vouchers.php @@ -13,6 +13,41 @@ $auth->requireAdmin(); $db = Database::getInstance(); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); +// CSV-Export +if (isset($_GET['export_csv']) && isset($_GET['site_id'])) { + if (!$auth->validateCsrfToken($_GET['token'] ?? '')) { + http_response_code(403); + exit('Ungültiges Token'); + } + $siteId = (int)$_GET['site_id']; + $site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]); + if (!$site) { http_response_code(404); exit('Site nicht gefunden'); } + + $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", + [$siteId] + ); + + $filename = 'vouchers_' . preg_replace('/[^a-z0-9]/i', '_', $site['name']) . '_' . date('Ymd_His') . '.csv'; + header('Content-Type: text/csv; charset=UTF-8'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Cache-Control: no-cache'); + + $out = fopen('php://output', 'w'); + fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF)); // UTF-8 BOM für Excel + fputcsv($out, ['Code', 'Name', 'Max. Geräte', 'Gültigkeit (Min)', 'Status', 'Genutzt', 'Erstellt', 'Läuft ab'], ';'); + foreach ($rows as $r) { + fputcsv($out, [ + $r['voucher_code'], $r['voucher_name'], $r['max_uses'], + $r['expire_minutes'], $r['status'], $r['used_count'], + $r['created_at'], $r['expires_at'] ?? '' + ], ';'); + } + fclose($out); + exit; +} + // AJAX: Voucher abrufen (immer aus DB, optional vorher Live-Sync) if (isset($_GET['ajax_get_vouchers']) && isset($_GET['site_id'])) { header('Content-Type: application/json'); @@ -672,6 +707,9 @@ $faviconUrl = $db->getSetting('favicon_url', '');
@@ -694,6 +732,8 @@ $faviconUrl = $db->getSetting('favicon_url', ''); let currentSiteId = null; let allVouchers = []; let currentFilter = 'all'; + let currentPage = 1; + const PAGE_SIZE = 50; // Toast Notification anzeigen function showToast(type, title, message) { @@ -761,11 +801,17 @@ $faviconUrl = $db->getSetting('favicon_url', ''); if (result.success) { allVouchers = result.vouchers; + currentPage = 1; document.getElementById('voucherListTitle').textContent = `Vouchers - ${result.site_name} (${result.count})`; updateStats(); renderVouchers(); document.getElementById('statsContainer').style.display = 'block'; + // CSV-Button aktualisieren + const csvBtn = document.getElementById('csvExportBtn'); + csvBtn.style.display = 'inline-flex'; + csvBtn.href = `vouchers.php?export_csv=1&site_id=${siteId}&token=${csrfToken}`; + // Sync-Info anzeigen if (result.last_sync) { showToast('success', syncFirst ? 'Synchronisiert' : 'Geladen', @@ -810,12 +856,19 @@ $faviconUrl = $db->getSetting('favicon_url', ''); // Filter setzen function setFilter(filter) { currentFilter = filter; + currentPage = 1; document.querySelectorAll('.filter-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.filter === filter); }); renderVouchers(); } + function setPage(page) { + currentPage = page; + renderVouchers(); + document.querySelector('.card:last-of-type')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + // Vouchers rendern function renderVouchers() { let vouchers = allVouchers; @@ -824,6 +877,11 @@ $faviconUrl = $db->getSetting('favicon_url', ''); vouchers = allVouchers.filter(v => v.status === currentFilter); } + const totalPages = Math.ceil(vouchers.length / PAGE_SIZE); + if (currentPage > totalPages && totalPages > 0) currentPage = totalPages; + const pageStart = (currentPage - 1) * PAGE_SIZE; + const pageVouchers = vouchers.slice(pageStart, pageStart + PAGE_SIZE); + if (vouchers.length === 0) { document.getElementById('voucherContent').innerHTML = `
@@ -867,7 +925,7 @@ $faviconUrl = $db->getSetting('favicon_url', ''); `; - vouchers.forEach(voucher => { + pageVouchers.forEach(voucher => { const createDate = new Date(voucher.create_time * 1000); const expireDate = new Date(voucher.expire_time * 1000); const now = new Date(); @@ -938,11 +996,20 @@ $faviconUrl = $db->getSetting('favicon_url', ''); `; }); - html += ` - - -
- `; + html += `
`; + + // Paginierung + if (totalPages > 1) { + html += `
`; + html += `Seite ${currentPage} von ${totalPages} (${vouchers.length} Einträge)`; + html += `
`; + html += ``; + for (let p = Math.max(1, currentPage - 2); p <= Math.min(totalPages, currentPage + 2); p++) { + html += ``; + } + html += ``; + html += `
`; + } document.getElementById('voucherContent').innerHTML = html; } diff --git a/database.sql b/database.sql index 1015ffd..f0686d8 100644 --- a/database.sql +++ b/database.sql @@ -87,4 +87,32 @@ CREATE TABLE IF NOT EXISTS `sessions` ( `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, INDEX `idx_expires` (`expires_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; \ No newline at end of file +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `login_attempts` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `ip_address` VARCHAR(45) NOT NULL, + `email` VARCHAR(255) NOT NULL, + `attempted_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_ip` (`ip_address`), + INDEX `idx_email` (`email`), + INDEX `idx_attempted` (`attempted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `audit_log` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `user_id` INT, + `action` VARCHAR(100) NOT NULL, + `entity_type` VARCHAR(50), + `entity_id` VARCHAR(100), + `details` TEXT, + `ip_address` VARCHAR(45), + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL, + INDEX `idx_user` (`user_id`), + INDEX `idx_action` (`action`), + INDEX `idx_created` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Migration für bestehende Installationen: +-- Neue Tabellen werden automatisch erstellt (CREATE TABLE IF NOT EXISTS) \ No newline at end of file diff --git a/includes/Auth.php b/includes/Auth.php index 02d261b..ac0198e 100644 --- a/includes/Auth.php +++ b/includes/Auth.php @@ -23,19 +23,62 @@ class Auth { // Benutzer einloggen public function login($email, $password) { + $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; + + if ($this->isRateLimited($ip, $email)) { + return 'rate_limited'; + } + $user = $this->db->fetchOne( "SELECT * FROM users WHERE email = ? AND is_active = 1", [$email] ); - + if ($user && password_verify($password, $user['password_hash'])) { + $this->clearLoginAttempts($ip, $email); $this->setUserSession($user); $this->updateLastLogin($user['id']); return true; } - + + $this->recordLoginAttempt($ip, $email); return false; } + + private function isRateLimited($ip, $email) { + try { + $count = $this->db->fetchOne( + "SELECT COUNT(*) as cnt FROM login_attempts + WHERE (ip_address = ? OR email = ?) AND attempted_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE)", + [$ip, $email] + ); + return $count && (int)$count['cnt'] >= 10; + } catch (\Exception $e) { + return false; + } + } + + private function recordLoginAttempt($ip, $email) { + try { + $this->db->query( + "INSERT INTO login_attempts (ip_address, email) VALUES (?, ?)", + [$ip, $email] + ); + } catch (\Exception $e) { + // Tabelle existiert noch nicht – ignorieren + } + } + + private function clearLoginAttempts($ip, $email) { + try { + $this->db->query( + "DELETE FROM login_attempts WHERE ip_address = ? OR email = ?", + [$ip, $email] + ); + } catch (\Exception $e) { + // ignore + } + } // Microsoft 365 Login public function loginWithMicrosoft($microsoftUser) { diff --git a/includes/Database.php b/includes/Database.php index 68a252f..00ab48d 100644 --- a/includes/Database.php +++ b/includes/Database.php @@ -2,6 +2,7 @@ class Database { private static $instance = null; private $pdo; + private $settingsCache = []; private function __construct() { try { @@ -58,15 +59,21 @@ class Database { // Settings-Helper public function getSetting($key, $default = null) { + if (array_key_exists($key, $this->settingsCache)) { + return $this->settingsCache[$key] ?? $default; + } $result = $this->fetchOne("SELECT setting_value FROM settings WHERE setting_key = ?", [$key]); - return $result ? $result['setting_value'] : $default; + $value = $result ? $result['setting_value'] : null; + $this->settingsCache[$key] = $value; + return $value ?? $default; } - + public function setSetting($key, $value) { $this->query( - "INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) + "INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)", [$key, $value] ); + $this->settingsCache[$key] = $value; } } \ No newline at end of file diff --git a/includes/Mailer.php b/includes/Mailer.php index 8b2e655..9d0a5d5 100644 --- a/includes/Mailer.php +++ b/includes/Mailer.php @@ -201,6 +201,13 @@ class Mailer { return $this->send($to, $subject, $body, $isHtml); } + public function sendTestEmail($to) { + $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); + $subject = '[Test] E-Mail-Konfiguration – ' . $appTitle; + $body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet."; + return $this->send($to, $subject, $body, false); + } + public function sendUserNotification($to, $userName, $changes) { $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); diff --git a/includes/UniFiController.php b/includes/UniFiController.php index 4ab20aa..e5d00c7 100644 --- a/includes/UniFiController.php +++ b/includes/UniFiController.php @@ -36,6 +36,8 @@ class UniFiController { CURLOPT_SSL_VERIFYPEER => false, CURLOPT_COOKIEJAR => $this->cookieFile, CURLOPT_COOKIEFILE => $this->cookieFile, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_HEADERFUNCTION => function($ch, $header) { $parts = explode(':', $header, 2); @@ -59,11 +61,15 @@ class UniFiController { } $data = json_decode($response, true); - - if (!isset($data['meta']['rc']) || $data['meta']['rc'] !== 'ok') { - throw new Exception("Login fehlgeschlagen: Ungültige Antwort"); + + // UniFi OS gibt ein User-Objekt zurück (unique_id/email), die alte API meta.rc = ok + $isUnifiOs = is_array($data) && (isset($data['unique_id']) || isset($data['email'])); + $isOldApi = isset($data['meta']['rc']) && $data['meta']['rc'] === 'ok'; + + if (!$isUnifiOs && !$isOldApi) { + throw new Exception("Login fehlgeschlagen: Ungültige Antwort vom Controller"); } - + return true; } @@ -79,6 +85,8 @@ class UniFiController { CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_COOKIEFILE => $this->cookieFile, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_HTTPHEADER => array_filter([ 'Content-Type: application/json', ($method === 'POST' && $this->csrfToken !== null) diff --git a/index.php b/index.php index 140c627..79f1716 100644 --- a/index.php +++ b/index.php @@ -173,6 +173,9 @@ $autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0; <?= htmlspecialchars($appTitle) ?> + + +