Fix sites freeze bug, add features and shorten README
Bug fixes: - UniFiController: add CURLOPT_TIMEOUT (10s) and CURLOPT_CONNECTTIMEOUT (5s) to login() and apiRequest() — prevents page freeze when controller unreachable - UniFiController: fix login response validation for UniFi OS API which returns a user object instead of meta.rc=ok - admin/sites.php: add JS loading state on form submit to give visual feedback - login.php: handle new 'rate_limited' return value from Auth::login() New features: - Database: in-memory settings cache eliminates redundant DB queries per request - Auth: login rate limiting (10 attempts per 10 min per IP/email) via login_attempts table - admin/vouchers.php: CSV export with UTF-8 BOM for Excel compatibility - admin/vouchers.php: client-side pagination (50 per page) - index.php: QR code display after voucher creation (qrcodejs CDN) - Mailer: sendTestEmail() method - admin/settings.php: SMTP test button with AJAX handler - database.sql: add login_attempts and audit_log tables Readme: condensed from ~420 to ~220 lines, removed duplicated sections, M365 Azure Portal walkthrough, contribution guidelines, update/migration section https://claude.ai/code/session_01UsuvFAmmeagtQa14QA4iaq
This commit is contained in:
parent
3fd9b2190a
commit
73967caefa
11 changed files with 460 additions and 450 deletions
423
Readme.md
423
Readme.md
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
database.sql
28
database.sql
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
35
index.php
35
index.php
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,17 @@ 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)) {
|
} else {
|
||||||
// Nach erfolgreichem Login zu index.php
|
$result = $auth->login($email, $password);
|
||||||
|
if ($result === true) {
|
||||||
header('Location: index.php');
|
header('Location: index.php');
|
||||||
exit;
|
exit;
|
||||||
|
} elseif ($result === 'rate_limited') {
|
||||||
|
$error = 'Zu viele Fehlversuche. Bitte warten Sie 10 Minuten.';
|
||||||
} else {
|
} else {
|
||||||
$error = 'Ungültige E-Mail oder Passwort';
|
$error = 'Ungültige E-Mail oder Passwort';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$error = 'Login-Fehler: ' . $e->getMessage();
|
$error = 'Login-Fehler: ' . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue