Merge pull request #2 from friloo/claude/unifi-api-migration-dbNMa

Fix sites freeze bug, add features and shorten README
This commit is contained in:
friloo 2026-04-21 20:27:56 +02:00 committed by GitHub
commit b86746fef3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 460 additions and 450 deletions

423
Readme.md
View file

@ -1,372 +1,140 @@
# UniFi Voucher Management System # 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. Webbasiertes System zur Verwaltung von WLAN-Vouchers für UniFi OS mit Multi-Site-Unterstützung, Benutzerverwaltung und Microsoft 365 Integration.
## Features ## Features
### Kern-Funktionen - **Voucher-Erstellung** mit QR-Code-Anzeige und E-Mail-Versand
- 🎫 **Voucher-Erstellung**: Einfache Erstellung von zeitbegrenzten WLAN-Zugangscodes - **Multi-Site-Support** mehrere UniFi-Standorte verwalten
- 🏢 **Multi-Site-Support**: Verwaltung mehrerer UniFi Sites/Standorte - **Benutzerverwaltung** mit granularer Site-Zugriffskontrolle
- 👥 **Benutzerverwaltung**: Granulare Zugriffskontrolle auf Site-Ebene - **Authentifizierung** via lokale Accounts oder Microsoft 365 OAuth
- 🔐 **Authentifizierung**: Lokale Accounts und Microsoft 365 OAuth - **CSV-Export** aller Vouchers pro Site
- 📊 **Admin-Dashboard**: Übersichtliche Statistiken und Historie - **Admin-Dashboard** mit Live-Statistiken und Sync-Funktion
- 🌐 **Öffentlicher Zugriff**: Optional ohne Login nutzbar - **Öffentlicher Zugriff** optional ohne Login nutzbar
- 🎨 **Modernes Design**: Responsives, helles und professionelles UI - CSRF-Schutz, bcrypt-Passwörter, Prepared Statements, Login-Rate-Limiting
### Sicherheit ## Anforderungen
- 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 - 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)
### Server-Anforderungen ## Installation
- 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 ```bash
# Repository klonen oder ZIP herunterladen
git clone https://github.com/friloo/unifi-voucher-tool.git git clone https://github.com/friloo/unifi-voucher-tool.git
cd unifi-voucher-tool cd unifi-voucher-tool
# Dateien auf den Webserver hochladen
# Stellen Sie sicher, dass der Webserver-User Schreibrechte hat
``` ```
### Schritt 2: Ordnerstruktur 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
/
├── 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. **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
1. Öffnen Sie `http://ihre-domain.de/install.php` im Browser ## Voucher erstellen
2. Folgen Sie dem 5-Schritte-Installations-Assistenten:
#### Schritt 1: Datenbank-Konfiguration 1. Startseite öffnen (Login je nach Konfiguration optional)
- Datenbank-Host (meist `localhost`) 2. Voucher-Name, Anzahl Geräte und Standort wählen
- Datenbankname (z.B. `unifi_voucher`) 3. **Voucher erstellen** Code und QR-Code werden sofort angezeigt
- Datenbank-Benutzer 4. Code per E-Mail senden oder ausdrucken
- Datenbank-Passwort
#### Schritt 2: Administrator-Account ## Konfiguration
- 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 ### config.php
Die Datei wird automatisch erstellt, kann aber manuell angepasst werden: Wird automatisch durch den Installer erstellt:
```php ```php
<?php <?php
define('DB_HOST', 'localhost'); define('DB_HOST', 'localhost');
define('DB_NAME', 'unifi_voucher'); define('DB_NAME', 'unifi_voucher');
define('DB_USER', 'username'); define('DB_USER', 'username');
define('DB_PASS', 'password'); define('DB_PASS', 'password');
define('SESSION_LIFETIME', 3600);
define('SESSION_LIFETIME', 3600); // 1 Stunde
date_default_timezone_set('Europe/Berlin'); date_default_timezone_set('Europe/Berlin');
``` ```
### Microsoft 365 OAuth einrichten ### Microsoft 365 OAuth (optional)
1. Im [Azure Portal](https://portal.azure.com) eine App-Registrierung anlegen
2. Umleitungs-URI: `https://ihre-domain.de/m365_callback.php`
3. API-Berechtigungen: `User.Read`, `email`, `profile`, `openid`
4. Client ID, Client Secret und Tenant ID in **Administration → Einstellungen** eintragen
1. **Azure AD App registrieren**: ### Cron-Job (empfohlen)
- Gehen Sie zu https://portal.azure.com Automatische Synchronisation alle 30 Minuten:
- Navigieren Sie zu "Azure Active Directory" → "App-Registrierungen" ```bash
- Klicken Sie auf "Neue Registrierung" */30 * * * * curl -s "https://ihre-domain.de/cron_sync.php?token=IHR_CRON_TOKEN"
- Name: "UniFi Voucher System" ```
- Unterstützte Kontotypen: "Nur Konten in diesem Organisationsverzeichnis" Den Token finden Sie unter **Administration → Einstellungen → Cron**.
- Umleitungs-URI: `https://ihre-domain.de/login.php`
2. **API-Berechtigungen**: ## Sicherheit
- Microsoft Graph → Delegierte Berechtigungen
- `User.Read`
- `email`
- `profile`
- `openid`
3. **Client Secret erstellen**:
- Gehen Sie zu "Zertifikate & Geheimnisse"
- Erstellen Sie ein neues Client-Geheimnis
- Notieren Sie den Wert (nur einmal sichtbar!)
4. **In System eintragen**:
- Administration → Einstellungen
- Microsoft 365 Bereich ausfüllen
- Client ID, Client Secret und Tenant ID eintragen
## 🔐 Sicherheitsempfehlungen
### Server-Konfiguration
```apache ```apache
# .htaccess zusätzliche Sicherheit # .htaccess sensible Dateien sperren
<Files "config.php"> <FilesMatch "^(config\.php|database\.sql|.*\.md)$">
Order Allow,Deny
Deny from all
</Files>
<FilesMatch "\.(sql|md)$">
Order Allow,Deny Order Allow,Deny
Deny from all Deny from all
</FilesMatch> </FilesMatch>
``` ```
### Datenbank-Benutzer
Erstellen Sie einen dedizierten Datenbankbenutzer nur für diese Anwendung:
```sql ```sql
-- Dedizierter Datenbank-Benutzer
CREATE USER 'unifi_voucher'@'localhost' IDENTIFIED BY 'sicheres_passwort'; CREATE USER 'unifi_voucher'@'localhost' IDENTIFIED BY 'sicheres_passwort';
GRANT SELECT, INSERT, UPDATE, DELETE ON unifi_voucher.* TO 'unifi_voucher'@'localhost'; GRANT SELECT, INSERT, UPDATE, DELETE ON unifi_voucher.* TO 'unifi_voucher'@'localhost';
FLUSH PRIVILEGES;
``` ```
### HTTPS erzwingen ## Problembehandlung
```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:** **Login funktioniert nicht:**
- Prüfen Sie die Datenbankverbindung - Datenbankverbindung und PHP-Session-Konfiguration prüfen
- Stellen Sie sicher, dass Sessions funktionieren
- Überprüfen Sie die PHP-Session-Konfiguration
**UniFi-Verbindung schlägt fehl:** **UniFi-Verbindung schlägt fehl:**
- Testen Sie die Controller-URL im Browser - Controller-URL im Browser testen
- Prüfen Sie Benutzername und Passwort - Port 11443 für UniFi OS verwenden (nicht 8443)
- Stellen Sie sicher, dass cURL aktiviert ist - Benutzername, Passwort und Site ID prüfen
- Prüfen Sie SSL-Zertifikate (CURLOPT_SSL_VERIFYPEER) - cURL-Extension muss aktiviert sein
**UniFi OS: Verbindung schlägt fehl (HTTP 404 oder 401):** **UniFi OS: HTTP 404 oder 401:**
- Stellen Sie sicher, dass Sie Port 11443 verwenden (nicht 8443) - Login-Endpunkt ist `/api/auth/login` (nicht `/api/login`)
- UniFi OS erfordert den Pfad `/proxy/network/api/s/{site}/...` für alle API-Aufrufe - API-Pfade benötigen Präfix `/proxy/network/api/s/{site}/...`
- Der Login-Endpunkt lautet `/api/auth/login` (nicht `/api/login`) - Älterer UniFi Network Controller (ohne UniFi OS) wird ab Version 2.1.0 nicht mehr unterstützt
- Ä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:** **Voucher werden nicht erstellt:**
- Überprüfen Sie die UniFi Controller Logs - UniFi Controller Logs prüfen
- Prüfen Sie API-Berechtigungen - API-Berechtigungen des Admin-Accounts prüfen
- Stellen Sie sicher, dass die Site-ID korrekt ist - Site ID korrekt? (zu finden in der UniFi Controller URL)
**Microsoft 365 Login funktioniert nicht:** **Microsoft 365 Login funktioniert nicht:**
- Prüfen Sie die Redirect URI - Redirect URI in Azure AD prüfen
- Überprüfen Sie Client ID und Secret - Client ID, Secret und Tenant ID kontrollieren
- Stellen Sie sicher, dass API-Berechtigungen erteilt wurden
### Debugging aktivieren ## API-Dokumentation
In `config.php` hinzufügen: **Login (UniFi OS):**
```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 POST /api/auth/login
Body: {"username": "admin", "password": "password"} Body: {"username": "admin", "password": "password"}
Response-Header: X-CSRF-Token: <token> Response-Header: X-CSRF-Token: <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:** **Voucher erstellen:**
``` ```
POST /proxy/network/api/s/{site_id}/cmd/hotspot POST /proxy/network/api/s/{site_id}/cmd/hotspot
Headers: X-CSRF-Token: <token> X-CSRF-Token: <token>
Body: { Body: {"cmd": "create-voucher", "expire": 480, "n": 1, "note": "Name", "quota": 1}
"cmd": "create-voucher",
"expire": 480,
"n": 1,
"note": "Voucher Name",
"quota": 1
}
``` ```
**Vouchers abrufen:** **Vouchers abrufen:**
@ -377,53 +145,18 @@ GET /proxy/network/api/s/{site_id}/stat/voucher
**Voucher löschen:** **Voucher löschen:**
``` ```
POST /proxy/network/api/s/{site_id}/cmd/hotspot POST /proxy/network/api/s/{site_id}/cmd/hotspot
Headers: X-CSRF-Token: <token> X-CSRF-Token: <token>
Body: {"cmd": "delete-voucher", "_id": "<voucher_id>"} Body: {"cmd": "delete-voucher", "_id": "<voucher_id>"}
``` ```
## 🤝 Mitwirken ## Roadmap
Contributions sind willkommen! Bitte: - [ ] Voucher-Templates (vordefinierte Laufzeiten)
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 - [ ] Bulk-Voucher-Erstellung
- [ ] QR-Code-Generierung
- [ ] SMS-Versand von Codes
- [ ] Erweiterte Reporting-Funktionen - [ ] Erweiterte Reporting-Funktionen
- [ ] REST API für externe Integration
- [ ] Docker-Container - [ ] Docker-Container
- [ ] Mehrsprachigkeit - [ ] Mehrsprachigkeit
--- ---
**Version:** 2.1.0 **Version:** 2.1.0 | **Autor:** Friederich Loheide | **Letztes Update:** April 2026
**Letztes Update:** April 2026

View file

@ -5,6 +5,7 @@ ini_set('display_errors', 1);
require_once __DIR__ . '/../config.php'; require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Mailer.php';
$auth = new Auth(); $auth = new Auth();
$auth->requireAdmin(); $auth->requireAdmin();
@ -12,6 +13,27 @@ $auth->requireAdmin();
$db = Database::getInstance(); $db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $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 = ''; $error = '';
$success = ''; $success = '';
@ -542,6 +564,20 @@ $faviconUrl = $db->getSetting('favicon_url', '');
</div> </div>
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> Speichern</button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> Speichern</button>
</form> </form>
<div style="margin-top: 25px; padding-top: 25px; border-top: 1px solid #e0e0e0;">
<h3 style="margin-bottom: 15px;">SMTP testen</h3>
<div style="display: flex; gap: 10px; align-items: flex-end;">
<div style="flex: 1;">
<label for="smtpTestEmail">Test-E-Mail senden an</label>
<input type="email" id="smtpTestEmail" placeholder="empfaenger@example.com" style="margin-top: 6px;">
</div>
<button onclick="testSmtp()" class="btn btn-secondary" id="smtpTestBtn">
<i class="fas fa-paper-plane"></i> Testen
</button>
</div>
<span id="smtpTestResult" style="display: block; margin-top: 10px; font-size: 13px;"></span>
</div>
</div> </div>
<!-- TEIL 1 ENDET HIER - Fortsetzung in TEIL 2 --> <!-- TEIL 1 ENDET HIER - Fortsetzung in TEIL 2 -->
@ -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 = '<i class="fas fa-spinner fa-spin"></i> Sende...';
result.textContent = '';
const fd = new FormData();
fd.append('ajax_smtp_test', '1');
fd.append('csrf_token', '<?= $auth->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 = '<i class="fas fa-paper-plane"></i> Testen';
}
// Cron-Job testen // Cron-Job testen
async function testCronJob() { async function testCronJob() {
const btn = document.getElementById('testCronBtn'); const btn = document.getElementById('testCronBtn');

View file

@ -654,6 +654,22 @@ $currentUser = $auth->getCurrentUser();
document.getElementById('editSiteModal').classList.add('active'); 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 = '<i class="fas fa-spinner fa-spin"></i> Verbindung wird getestet...';
}
});
document.getElementById('editSiteForm').addEventListener('submit', function() {
const btn = this.querySelector('button[name="edit_site"]');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Verbindung wird getestet...';
}
});
// Modal schließen bei Klick außerhalb // Modal schließen bei Klick außerhalb
document.getElementById('addSiteModal').addEventListener('click', function(e) { document.getElementById('addSiteModal').addEventListener('click', function(e) {
if (e.target === this) { if (e.target === this) {

View file

@ -13,6 +13,41 @@ $auth->requireAdmin();
$db = Database::getInstance(); $db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $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) // AJAX: Voucher abrufen (immer aus DB, optional vorher Live-Sync)
if (isset($_GET['ajax_get_vouchers']) && isset($_GET['site_id'])) { if (isset($_GET['ajax_get_vouchers']) && isset($_GET['site_id'])) {
header('Content-Type: application/json'); header('Content-Type: application/json');
@ -672,6 +707,9 @@ $faviconUrl = $db->getSetting('favicon_url', '');
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2 class="card-title" id="voucherListTitle">Vouchers</h2> <h2 class="card-title" id="voucherListTitle">Vouchers</h2>
<a id="csvExportBtn" style="display:none;" class="btn btn-secondary btn-small" href="#">
<i class="fas fa-download"></i> CSV exportieren
</a>
</div> </div>
<div class="card-body" style="padding: 0;"> <div class="card-body" style="padding: 0;">
<div id="voucherContent"> <div id="voucherContent">
@ -694,6 +732,8 @@ $faviconUrl = $db->getSetting('favicon_url', '');
let currentSiteId = null; let currentSiteId = null;
let allVouchers = []; let allVouchers = [];
let currentFilter = 'all'; let currentFilter = 'all';
let currentPage = 1;
const PAGE_SIZE = 50;
// Toast Notification anzeigen // Toast Notification anzeigen
function showToast(type, title, message) { function showToast(type, title, message) {
@ -761,11 +801,17 @@ $faviconUrl = $db->getSetting('favicon_url', '');
if (result.success) { if (result.success) {
allVouchers = result.vouchers; allVouchers = result.vouchers;
currentPage = 1;
document.getElementById('voucherListTitle').textContent = `Vouchers - ${result.site_name} (${result.count})`; document.getElementById('voucherListTitle').textContent = `Vouchers - ${result.site_name} (${result.count})`;
updateStats(); updateStats();
renderVouchers(); renderVouchers();
document.getElementById('statsContainer').style.display = 'block'; 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 // Sync-Info anzeigen
if (result.last_sync) { if (result.last_sync) {
showToast('success', syncFirst ? 'Synchronisiert' : 'Geladen', showToast('success', syncFirst ? 'Synchronisiert' : 'Geladen',
@ -810,12 +856,19 @@ $faviconUrl = $db->getSetting('favicon_url', '');
// Filter setzen // Filter setzen
function setFilter(filter) { function setFilter(filter) {
currentFilter = filter; currentFilter = filter;
currentPage = 1;
document.querySelectorAll('.filter-btn').forEach(btn => { document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === filter); btn.classList.toggle('active', btn.dataset.filter === filter);
}); });
renderVouchers(); renderVouchers();
} }
function setPage(page) {
currentPage = page;
renderVouchers();
document.querySelector('.card:last-of-type')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// Vouchers rendern // Vouchers rendern
function renderVouchers() { function renderVouchers() {
let vouchers = allVouchers; let vouchers = allVouchers;
@ -824,6 +877,11 @@ $faviconUrl = $db->getSetting('favicon_url', '');
vouchers = allVouchers.filter(v => v.status === currentFilter); 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) { if (vouchers.length === 0) {
document.getElementById('voucherContent').innerHTML = ` document.getElementById('voucherContent').innerHTML = `
<div class="empty-state"> <div class="empty-state">
@ -867,7 +925,7 @@ $faviconUrl = $db->getSetting('favicon_url', '');
<tbody> <tbody>
`; `;
vouchers.forEach(voucher => { pageVouchers.forEach(voucher => {
const createDate = new Date(voucher.create_time * 1000); const createDate = new Date(voucher.create_time * 1000);
const expireDate = new Date(voucher.expire_time * 1000); const expireDate = new Date(voucher.expire_time * 1000);
const now = new Date(); const now = new Date();
@ -938,11 +996,20 @@ $faviconUrl = $db->getSetting('favicon_url', '');
`; `;
}); });
html += ` html += `</tbody></table></div>`;
</tbody>
</table> // Paginierung
</div> if (totalPages > 1) {
`; html += `<div style="display:flex;align-items:center;justify-content:space-between;padding:15px 25px;border-top:1px solid #e0e0e0;">`;
html += `<span style="font-size:13px;color:#666;">Seite ${currentPage} von ${totalPages} (${vouchers.length} Einträge)</span>`;
html += `<div style="display:flex;gap:6px;">`;
html += `<button class="btn btn-secondary btn-small" onclick="setPage(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}><i class="fas fa-chevron-left"></i></button>`;
for (let p = Math.max(1, currentPage - 2); p <= Math.min(totalPages, currentPage + 2); p++) {
html += `<button class="btn btn-small ${p === currentPage ? 'btn-primary' : 'btn-secondary'}" onclick="setPage(${p})">${p}</button>`;
}
html += `<button class="btn btn-secondary btn-small" onclick="setPage(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}><i class="fas fa-chevron-right"></i></button>`;
html += `</div></div>`;
}
document.getElementById('voucherContent').innerHTML = html; document.getElementById('voucherContent').innerHTML = html;
} }

View file

@ -88,3 +88,31 @@ CREATE TABLE IF NOT EXISTS `sessions` (
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
INDEX `idx_expires` (`expires_at`) INDEX `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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)

View file

@ -23,20 +23,63 @@ class Auth {
// Benutzer einloggen // Benutzer einloggen
public function login($email, $password) { 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( $user = $this->db->fetchOne(
"SELECT * FROM users WHERE email = ? AND is_active = 1", "SELECT * FROM users WHERE email = ? AND is_active = 1",
[$email] [$email]
); );
if ($user && password_verify($password, $user['password_hash'])) { if ($user && password_verify($password, $user['password_hash'])) {
$this->clearLoginAttempts($ip, $email);
$this->setUserSession($user); $this->setUserSession($user);
$this->updateLastLogin($user['id']); $this->updateLastLogin($user['id']);
return true; return true;
} }
$this->recordLoginAttempt($ip, $email);
return false; 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 // Microsoft 365 Login
public function loginWithMicrosoft($microsoftUser) { public function loginWithMicrosoft($microsoftUser) {
// Zuerst nach Microsoft ID suchen // Zuerst nach Microsoft ID suchen

View file

@ -2,6 +2,7 @@
class Database { class Database {
private static $instance = null; private static $instance = null;
private $pdo; private $pdo;
private $settingsCache = [];
private function __construct() { private function __construct() {
try { try {
@ -58,8 +59,13 @@ class Database {
// Settings-Helper // Settings-Helper
public function getSetting($key, $default = null) { 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]); $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) { public function setSetting($key, $value) {
@ -68,5 +74,6 @@ class Database {
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)", ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)",
[$key, $value] [$key, $value]
); );
$this->settingsCache[$key] = $value;
} }
} }

View file

@ -201,6 +201,13 @@ class Mailer {
return $this->send($to, $subject, $body, $isHtml); 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) { public function sendUserNotification($to, $userName, $changes) {
$appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System');

View file

@ -36,6 +36,8 @@ class UniFiController {
CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_COOKIEJAR => $this->cookieFile, CURLOPT_COOKIEJAR => $this->cookieFile,
CURLOPT_COOKIEFILE => $this->cookieFile, CURLOPT_COOKIEFILE => $this->cookieFile,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_HEADERFUNCTION => function($ch, $header) { CURLOPT_HEADERFUNCTION => function($ch, $header) {
$parts = explode(':', $header, 2); $parts = explode(':', $header, 2);
@ -60,8 +62,12 @@ class UniFiController {
$data = json_decode($response, true); $data = json_decode($response, true);
if (!isset($data['meta']['rc']) || $data['meta']['rc'] !== 'ok') { // UniFi OS gibt ein User-Objekt zurück (unique_id/email), die alte API meta.rc = ok
throw new Exception("Login fehlgeschlagen: Ungültige Antwort"); $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; return true;
@ -79,6 +85,8 @@ class UniFiController {
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_COOKIEFILE => $this->cookieFile, CURLOPT_COOKIEFILE => $this->cookieFile,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => array_filter([ CURLOPT_HTTPHEADER => array_filter([
'Content-Type: application/json', 'Content-Type: application/json',
($method === 'POST' && $this->csrfToken !== null) ($method === 'POST' && $this->csrfToken !== null)

View file

@ -173,6 +173,9 @@ $autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($appTitle) ?></title> <title><?= htmlspecialchars($appTitle) ?></title>
<?php if ($voucherCreated): ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSe2keRB6Q5pBUtIxCY7bQMsVB0ANBpd6JDg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<?php endif; ?>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
@ -431,6 +434,21 @@ $autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
.qr-wrapper {
display: flex;
flex-direction: column;
align-items: center;
margin: 20px 0 0;
}
.qr-wrapper canvas, .qr-wrapper img {
border: 6px solid white;
border-radius: 8px;
}
.qr-label {
font-size: 12px;
opacity: 0.8;
margin-top: 8px;
}
/* Print Styles */ /* Print Styles */
@media print { @media print {
@ -492,6 +510,10 @@ $autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
<div class="voucher-info"> <div class="voucher-info">
Der Code ist 8 Stunden ab Erstellung gültig Der Code ist 8 Stunden ab Erstellung gültig
</div> </div>
<div class="qr-wrapper no-print">
<div id="qrcode"></div>
<div class="qr-label">QR-Code scannen zum Verbinden</div>
</div>
</div> </div>
<?php if ($instructionHeader || $instructionText): ?> <?php if ($instructionHeader || $instructionText): ?>
@ -603,6 +625,19 @@ $autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
</div> </div>
<script> <script>
<?php if ($voucherCreated): ?>
document.addEventListener('DOMContentLoaded', function() {
new QRCode(document.getElementById('qrcode'), {
text: '<?= addslashes($voucherCode) ?>',
width: 160,
height: 160,
colorDark: '#ffffff',
colorLight: 'transparent',
correctLevel: QRCode.CorrectLevel.M
});
});
<?php endif; ?>
function toggleEmailField() { function toggleEmailField() {
const checkbox = document.getElementById('send_email'); const checkbox = document.getElementById('send_email');
const emailField = document.getElementById('email_field'); const emailField = document.getElementById('email_field');

View file

@ -31,12 +31,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($email) || empty($password)) { if (empty($email) || empty($password)) {
$error = 'Bitte E-Mail und Passwort eingeben'; $error = 'Bitte E-Mail und Passwort eingeben';
} elseif ($auth->login($email, $password)) {
// Nach erfolgreichem Login zu index.php
header('Location: index.php');
exit;
} else { } else {
$error = 'Ungültige E-Mail oder Passwort'; $result = $auth->login($email, $password);
if ($result === true) {
header('Location: index.php');
exit;
} elseif ($result === 'rate_limited') {
$error = 'Zu viele Fehlversuche. Bitte warten Sie 10 Minuten.';
} else {
$error = 'Ungültige E-Mail oder Passwort';
}
} }
} catch (Exception $e) { } catch (Exception $e) {
$error = 'Login-Fehler: ' . $e->getMessage(); $error = 'Login-Fehler: ' . $e->getMessage();