@@ -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) ?>
+
+
+