This commit is contained in:
friloo 2026-06-09 20:13:40 +00:00 committed by GitHub
commit 55e60dfa6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1124 additions and 661 deletions

30
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: Lint
on:
push:
pull_request:
jobs:
php-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: PHP Syntax-Check (alle Dateien)
run: |
set -e
fail=0
while IFS= read -r f; do
php -l "$f" > /dev/null || fail=1
done < <(git ls-files '*.php')
exit $fail
- name: Sprachdateien-Paritaet (de/en)
run: |
php -r '
$de = require "lang/de.php"; $en = require "lang/en.php";
$missing = array_merge(array_diff(array_keys($de), array_keys($en)), array_diff(array_keys($en), array_keys($de)));
if ($missing) { fwrite(STDERR, "Fehlende Keys: " . implode(", ", $missing) . "\n"); exit(1); }
echo "OK: " . count($de) . " Keys synchron\n";
'

View file

@ -45,22 +45,23 @@ $users = $db->fetchAll("SELECT id, name FROM users WHERE is_active = 1 ORDER BY
$currentPage = 'audit_log'; $currentPage = 'audit_log';
$adminBase = ''; $adminBase = '';
// WICHTIG: Die Keys muessen den tatsaechlich via writeAuditLog() geschriebenen
// Action-Namen entsprechen (user_create, site_edit, ...), sonst erscheinen
// die Eintraege als rohe Keys.
$actionLabels = [ $actionLabels = [
'voucher_created' => '🎫 Voucher erstellt', 'voucher_create' => '🎫 Voucher erstellt',
'voucher_bulk' => '🎫 Bulk Voucher', 'voucher_bulk' => '🎫 Bulk Voucher',
'user_login' => '🔐 Login', 'user_login' => '🔐 Login',
'user_logout' => '🚪 Logout', 'user_create' => '👤 Benutzer erstellt',
'user_created' => '👤 Benutzer erstellt', 'user_edit' => '👤 Benutzer geändert',
'user_updated' => '👤 Benutzer geändert', 'user_delete' => '👤 Benutzer gelöscht',
'user_deleted' => '👤 Benutzer gelöscht', 'site_create' => '🌐 Site hinzugefügt',
'site_added' => '🌐 Site hinzugefügt', 'site_edit' => '🌐 Site geändert',
'site_updated' => '🌐 Site geändert', 'site_delete' => '🌐 Site gelöscht',
'site_deleted' => '🌐 Site gelöscht',
'settings_saved' => '⚙️ Einstellungen gespeichert',
'password_reset' => '🔑 Passwort-Reset', 'password_reset' => '🔑 Passwort-Reset',
'template_created' => '📋 Profil erstellt', 'update_installed' => '🔄 Update installiert',
'template_updated' => '📋 Profil geändert', 'update_failed' => '🔄 Update fehlgeschlagen',
'template_deleted' => '📋 Profil gelöscht', 'migrations_run' => '🗄️ Migrationen ausgeführt',
]; ];
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
@ -81,7 +82,6 @@ $actionLabels = [
.table td { padding: 12px 15px; border-bottom: 1px solid var(--border-color); font-size: 13px; color: var(--text-primary); } .table td { padding: 12px 15px; border-bottom: 1px solid var(--border-color); font-size: 13px; color: var(--text-primary); }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: var(--bg-hover); } .table tr:hover td { background: var(--bg-hover); }
.badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; }
.filter-bar { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; } .filter-bar { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
.filter-bar select { padding: 9px 12px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 13px; background: var(--bg-input); color: var(--text-primary); } .filter-bar select { padding: 9px 12px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 13px; background: var(--bg-input); color: var(--text-primary); }
.filter-bar select:focus { outline: none; border-color: var(--accent); } .filter-bar select:focus { outline: none; border-color: var(--accent); }

View file

@ -26,9 +26,12 @@ if (isset($_GET['ajax_stats'])) {
$syncErrors = []; $syncErrors = [];
if ($syncFirst) { if ($syncFirst) {
// Mehrere Sites werden sequentiell synchronisiert (je bis zu ~15s
// bei Timeout) PHP-Default von 30s reicht dann nicht.
@set_time_limit(30 + count($sites) * 20);
foreach ($sites as $site) { foreach ($sites as $site) {
try { try {
$ctrl = new UniFiController($site['unifi_controller_url'], $site['unifi_username'], Crypto::decrypt($site['unifi_password']), $site['site_id']); $ctrl = new UniFiController($site['unifi_controller_url'], $site['unifi_username'], Crypto::decrypt($site['unifi_password']), $site['site_id'], $site['ssl_verify'] ?? 0);
$ctrl->syncVouchersToDatabase($db, $site['id']); $ctrl->syncVouchersToDatabase($db, $site['id']);
} catch (Exception $e) { } catch (Exception $e) {
$syncErrors[$site['id']] = $e->getMessage(); $syncErrors[$site['id']] = $e->getMessage();
@ -122,11 +125,6 @@ $currentPage = 'dashboard';
.table th { text-align: left; padding: 11px 14px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; } .table th { text-align: left; padding: 11px 14px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
.table td { padding: 13px 14px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; } .table td { padding: 13px 14px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; }
.badge-success { background: #d4edda; color: #155724; }
.badge-warning { background: #fff3cd; color: #856404; }
.badge-danger { background: #f8d7da; color: #721c24; }
.badge-info { background: var(--bg-badge-info); color: var(--text-badge-info); }
.btn-primary { background: var(--accent); color: white; } .btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); } .btn-primary:hover { background: var(--accent-hover); }
.btn-success { background: var(--success); color: white; } .btn-success { background: var(--success); color: white; }
@ -161,6 +159,13 @@ $currentPage = 'dashboard';
@media(max-width:768px){ .main-content{ margin-left:0!important; } .stats-grid{ grid-template-columns:1fr 1fr; } } @media(max-width:768px){ .main-content{ margin-left:0!important; } .stats-grid{ grid-template-columns:1fr 1fr; } }
</style> </style>
<?php if (!Crypto::hasKey()): ?>
<div class="alert alert-error">
<i class="fas fa-exclamation-triangle"></i>
<span><?= __('crypto_warning') ?></span>
</div>
<?php endif; ?>
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title"><?= __('dashboard_title') ?></h1> <h1 class="page-title"><?= __('dashboard_title') ?></h1>

View file

@ -8,6 +8,7 @@ require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Mailer.php'; require_once __DIR__ . '/../includes/Mailer.php';
require_once __DIR__ . '/../includes/I18n.php'; require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth(); $auth = new Auth();
$auth->requireAdmin(); $auth->requireAdmin();
@ -72,7 +73,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if ($formType === 'm365') { if ($formType === 'm365') {
$settings['m365_client_id'] = trim($_POST['m365_client_id'] ?? ''); $settings['m365_client_id'] = trim($_POST['m365_client_id'] ?? '');
$settings['m365_client_secret'] = trim($_POST['m365_client_secret'] ?? ''); // Secret nur aktualisieren, wenn eines eingegeben wurde es wird
// (wie das SMTP-Passwort) nicht mehr ins Formular zurueckgegeben.
if (!empty($_POST['m365_client_secret'])) {
$settings['m365_client_secret'] = trim($_POST['m365_client_secret']);
}
$settings['m365_tenant_id'] = trim($_POST['m365_tenant_id'] ?? ''); $settings['m365_tenant_id'] = trim($_POST['m365_tenant_id'] ?? '');
} }
@ -85,6 +90,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
$settings['smtp_password'] = trim($_POST['smtp_password']); $settings['smtp_password'] = trim($_POST['smtp_password']);
} }
$settings['smtp_encryption'] = trim($_POST['smtp_encryption'] ?? 'tls'); $settings['smtp_encryption'] = trim($_POST['smtp_encryption'] ?? 'tls');
$settings['smtp_verify_ssl'] = isset($_POST['smtp_verify_ssl']) ? '1' : '0';
$settings['smtp_from_email'] = trim($_POST['smtp_from_email'] ?? ''); $settings['smtp_from_email'] = trim($_POST['smtp_from_email'] ?? '');
$settings['smtp_from_name'] = trim($_POST['smtp_from_name'] ?? ''); $settings['smtp_from_name'] = trim($_POST['smtp_from_name'] ?? '');
} }
@ -106,7 +112,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
$db->setSetting($key, $value); $db->setSetting($key, $value);
} }
$success = __('settings_saved'); // PRG + Tab-Anker: F5 speichert nicht erneut, und der Nutzer landet
// wieder auf dem Tab, in dem er gespeichert hat.
$tabAnchors = [
'general' => 'general', 'defaults' => 'defaults', 'm365' => 'm365',
'smtp' => 'smtp', 'templates' => 'templates_email', 'system' => 'system',
];
flashSet(__('settings_saved'));
header('Location: settings.php#' . ($tabAnchors[$formType] ?? 'general'));
exit;
} catch (Exception $e) { } catch (Exception $e) {
$error = 'Fehler: ' . $e->getMessage(); $error = 'Fehler: ' . $e->getMessage();
} }
@ -119,7 +133,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['generate_cron_token']
$error = __('error_csrf'); $error = __('error_csrf');
} else { } else {
$db->setSetting('cron_token', bin2hex(random_bytes(32))); $db->setSetting('cron_token', bin2hex(random_bytes(32)));
$success = 'Neuer Cron-Token wurde generiert!'; flashSet(__('cron_token_generated'));
header('Location: settings.php#cron');
exit;
} }
} }
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token'])) {
@ -127,7 +143,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token']))
$error = __('error_csrf'); $error = __('error_csrf');
} else { } else {
$db->setSetting('cron_token', ''); $db->setSetting('cron_token', '');
$success = 'Cron-Token wurde gelöscht!'; flashSet(__('cron_token_deleted'));
header('Location: settings.php#cron');
exit;
} }
} }
@ -139,23 +157,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['change_password'])) {
try { try {
$user = $auth->getCurrentUser(); $user = $auth->getCurrentUser();
if (!password_verify($_POST['current_password'], $user['password_hash'])) { if (!password_verify($_POST['current_password'], $user['password_hash'])) {
throw new Exception('Aktuelles Passwort ist falsch'); throw new Exception(__('error_pw_current'));
} }
if (strlen($_POST['new_password']) < 8) { if (strlen($_POST['new_password']) < 8) {
throw new Exception(__('settings_pw_minlength')); throw new Exception(__('settings_pw_minlength'));
} }
if ($_POST['new_password'] !== $_POST['confirm_password']) { if ($_POST['new_password'] !== $_POST['confirm_password']) {
throw new Exception('Passwörter stimmen nicht überein'); throw new Exception(__('error_pw_mismatch'));
} }
$db->query("UPDATE users SET password_hash = ? WHERE id = ?", $db->query("UPDATE users SET password_hash = ? WHERE id = ?",
[password_hash($_POST['new_password'], PASSWORD_DEFAULT), $user['id']]); [password_hash($_POST['new_password'], PASSWORD_DEFAULT), $user['id']]);
$success = __('settings_pw_changed'); flashSet(__('settings_pw_changed'));
header('Location: settings.php#password');
exit;
} catch (Exception $e) { } catch (Exception $e) {
$error = $e->getMessage(); $error = $e->getMessage();
} }
} }
} }
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST']; $host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME'], 2); $scriptPath = dirname($_SERVER['SCRIPT_NAME'], 2);
@ -181,6 +205,7 @@ $cs = [
'smtp_username' => $db->getSetting('smtp_username', ''), 'smtp_username' => $db->getSetting('smtp_username', ''),
'smtp_password' => $db->getSetting('smtp_password', ''), 'smtp_password' => $db->getSetting('smtp_password', ''),
'smtp_encryption' => $db->getSetting('smtp_encryption', 'tls'), 'smtp_encryption' => $db->getSetting('smtp_encryption', 'tls'),
'smtp_verify_ssl' => $db->getSetting('smtp_verify_ssl', '0'),
'smtp_from_email' => $db->getSetting('smtp_from_email', ''), 'smtp_from_email' => $db->getSetting('smtp_from_email', ''),
'smtp_from_name' => $db->getSetting('smtp_from_name', ''), 'smtp_from_name' => $db->getSetting('smtp_from_name', ''),
'system_url' => $db->getSetting('system_url', $autoDetectedUrl), 'system_url' => $db->getSetting('system_url', $autoDetectedUrl),
@ -215,9 +240,6 @@ $adminBase = '';
<style> <style>
.page-header { margin-bottom: 30px; } .page-header { margin-bottom: 30px; }
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; } .page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
.alert { padding: 14px 20px; border-radius: 10px; margin-bottom: 25px; font-size: 14px; display: flex; align-items: center; gap: 10px; }
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
.tab-container { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; } .tab-container { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; }
.tab-navigation { display: flex; background: var(--bg-table-head); border-bottom: 2px solid var(--border-color); overflow-x: auto; position: sticky; top: 70px; z-index: 50; } .tab-navigation { display: flex; background: var(--bg-table-head); border-bottom: 2px solid var(--border-color); overflow-x: auto; position: sticky; top: 70px; z-index: 50; }
.tab-button { padding: 15px 20px; background: transparent; border: none; border-bottom: 3px solid transparent; cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text-secondary); transition: all 0.3s; white-space: nowrap; display: flex; align-items: center; gap: 7px; } .tab-button { padding: 15px 20px; background: transparent; border: none; border-bottom: 3px solid transparent; cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text-secondary); transition: all 0.3s; white-space: nowrap; display: flex; align-items: center; gap: 7px; }
@ -350,7 +372,7 @@ $adminBase = '';
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;"> <div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;">
<button onclick="copyToClipboard('<?= htmlspecialchars($cs['cron_token']) ?>')" class="btn btn-secondary"><i class="fas fa-copy"></i> Kopieren</button> <button onclick="copyToClipboard('<?= htmlspecialchars($cs['cron_token']) ?>')" class="btn btn-secondary"><i class="fas fa-copy"></i> Kopieren</button>
<form method="post" style="display:inline;"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="generate_cron_token" class="btn btn-secondary"><i class="fas fa-sync"></i> Neu generieren</button></form> <form method="post" style="display:inline;"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="generate_cron_token" class="btn btn-secondary"><i class="fas fa-sync"></i> Neu generieren</button></form>
<form method="post" style="display:inline;" onsubmit="return confirm('Token wirklich löschen?');"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="delete_cron_token" class="btn btn-secondary" style="color: var(--danger);"><i class="fas fa-trash"></i> Löschen</button></form> <form method="post" style="display:inline;" onsubmit="return confirm('<?= addslashes(__('confirm_delete_token')) ?>');"><input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"><button type="submit" name="delete_cron_token" class="btn btn-secondary" style="color: var(--danger);"><i class="fas fa-trash"></i> Löschen</button></form>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php <?php
@ -389,7 +411,7 @@ $adminBase = '';
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="m365"> <input type="hidden" name="form_type" value="m365">
<div class="form-group"><label>Client ID</label><input type="text" name="m365_client_id" value="<?= htmlspecialchars($cs['m365_client_id']) ?>"></div> <div class="form-group"><label>Client ID</label><input type="text" name="m365_client_id" value="<?= htmlspecialchars($cs['m365_client_id']) ?>"></div>
<div class="form-group"><label>Client Secret</label><input type="password" name="m365_client_secret" value="<?= htmlspecialchars($cs['m365_client_secret']) ?>"></div> <div class="form-group"><label>Client Secret</label><input type="password" name="m365_client_secret" placeholder="<?= $cs['m365_client_secret'] !== '' ? '••••••••' : '' ?>"><div class="help-text"><?= __('m365_secret_hint') ?></div></div>
<div class="form-group"><label>Tenant ID</label><input type="text" name="m365_tenant_id" value="<?= htmlspecialchars($cs['m365_tenant_id']) ?>"></div> <div class="form-group"><label>Tenant ID</label><input type="text" name="m365_tenant_id" value="<?= htmlspecialchars($cs['m365_tenant_id']) ?>"></div>
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button> <button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
</form> </form>
@ -407,6 +429,7 @@ $adminBase = '';
<div class="form-group"><label>Port</label><input type="number" name="smtp_port" value="<?= htmlspecialchars($cs['smtp_port']) ?>"></div> <div class="form-group"><label>Port</label><input type="number" name="smtp_port" value="<?= htmlspecialchars($cs['smtp_port']) ?>"></div>
</div> </div>
<div class="form-group"><label>Verschlüsselung</label><select name="smtp_encryption"><option value="tls" <?= $cs['smtp_encryption']==='tls'?'selected':'' ?>>TLS</option><option value="ssl" <?= $cs['smtp_encryption']==='ssl'?'selected':'' ?>>SSL</option><option value="none" <?= $cs['smtp_encryption']==='none'?'selected':'' ?>>Keine</option></select></div> <div class="form-group"><label>Verschlüsselung</label><select name="smtp_encryption"><option value="tls" <?= $cs['smtp_encryption']==='tls'?'selected':'' ?>>TLS</option><option value="ssl" <?= $cs['smtp_encryption']==='ssl'?'selected':'' ?>>SSL</option><option value="none" <?= $cs['smtp_encryption']==='none'?'selected':'' ?>>Keine</option></select></div>
<div class="checkbox-group" style="margin-bottom: 20px;"><input type="checkbox" name="smtp_verify_ssl" id="smtp_verify_ssl" <?= $cs['smtp_verify_ssl'] == '1' ? 'checked' : '' ?>><label for="smtp_verify_ssl" style="margin:0;"><?= __('smtp_verify_ssl') ?></label></div>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"><label>Benutzername</label><input type="text" name="smtp_username" value="<?= htmlspecialchars($cs['smtp_username']) ?>"></div> <div class="form-group"><label>Benutzername</label><input type="text" name="smtp_username" value="<?= htmlspecialchars($cs['smtp_username']) ?>"></div>
<div class="form-group"><label>Passwort</label><input type="password" name="smtp_password" placeholder="Leer = nicht ändern"></div> <div class="form-group"><label>Passwort</label><input type="password" name="smtp_password" placeholder="Leer = nicht ändern"></div>

View file

@ -8,6 +8,7 @@ require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/UniFiController.php'; require_once __DIR__ . '/../includes/UniFiController.php';
require_once __DIR__ . '/../includes/I18n.php'; require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth(); $auth = new Auth();
$auth->requireAdmin(); $auth->requireAdmin();
@ -18,6 +19,32 @@ I18n::init();
$error = ''; $error = '';
$success = ''; $success = '';
// AJAX: Verbindungstest mit gespeicherten Zugangsdaten (Health-Check pro Site)
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['ajax_test_site'])) {
header('Content-Type: application/json');
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
echo json_encode(['success' => false, 'message' => __('error_csrf')]);
exit;
}
$site = $db->fetchOne("SELECT * FROM sites WHERE id=?", [(int)$_POST['ajax_test_site']]);
if (!$site) {
echo json_encode(['success' => false, 'message' => __('error_site_not_found')]);
exit;
}
$test = UniFiController::testConnection(
$site['unifi_controller_url'],
$site['unifi_username'],
Crypto::decrypt($site['unifi_password']),
$site['site_id'],
$site['ssl_verify'] ?? 0
);
echo json_encode([
'success' => $test === true,
'message' => $test === true ? __('site_test_ok') : __('site_test_fail') . ': ' . $test,
]);
exit;
}
// Edit site // Edit site
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) {
@ -31,18 +58,27 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
$username = trim($_POST['username']); $username = trim($_POST['username']);
$password = $_POST['password']; $password = $_POST['password'];
$publicAccess = isset($_POST['public_access']) ? 1 : 0; $publicAccess = isset($_POST['public_access']) ? 1 : 0;
$sslVerify = isset($_POST['ssl_verify']) ? 1 : 0;
if (empty($name)||empty($siteIdStr)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all')); if (empty($name)||empty($siteIdStr)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
if (!empty($password)) { if (!empty($password)) {
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr); $test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr,$sslVerify);
if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$test); if ($test !== true) throw new Exception(__('site_test_fail').': '.$test);
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,unifi_password=?,public_access=? WHERE id=?", $db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,unifi_password=?,public_access=?,ssl_verify=? WHERE id=?",
[$name,$siteIdStr,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$siteId]); [$name,$siteIdStr,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$sslVerify,$siteId]);
} else { } else {
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=? WHERE id=?", // Auch ohne Passwortaenderung testen (mit gespeichertem Passwort)
[$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$siteId]); // sonst fallen Tippfehler in URL/Username erst beim naechsten Voucher auf.
$stored = $db->fetchOne("SELECT unifi_password FROM sites WHERE id=?", [$siteId]);
if (!$stored) throw new Exception(__('error_site_not_found'));
$test = UniFiController::testConnection($controllerUrl,$username,Crypto::decrypt($stored['unifi_password']),$siteIdStr,$sslVerify);
if ($test !== true) throw new Exception(__('site_test_fail').': '.$test);
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=?,ssl_verify=? WHERE id=?",
[$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$sslVerify,$siteId]);
} }
$auth->writeAuditLog($_SESSION['user_id'],'site_edit','site',$siteId,"Site {$name} aktualisiert"); $auth->writeAuditLog($_SESSION['user_id'],'site_edit','site',$siteId,"Site {$name} aktualisiert");
$success = __('sites_updated'); flashSet(__('sites_updated'));
header('Location: sites.php');
exit;
} catch (Exception $e) { $error = $e->getMessage(); } } catch (Exception $e) { $error = $e->getMessage(); }
} }
} }
@ -59,38 +95,49 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_site'])) {
$username = trim($_POST['username']); $username = trim($_POST['username']);
$password = $_POST['password']; $password = $_POST['password'];
$publicAccess = isset($_POST['public_access']) ? 1 : 0; $publicAccess = isset($_POST['public_access']) ? 1 : 0;
$sslVerify = isset($_POST['ssl_verify']) ? 1 : 0;
if (empty($name)||empty($siteId)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all')); if (empty($name)||empty($siteId)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId); $test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId,$sslVerify);
if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$test); if ($test !== true) throw new Exception(__('site_test_fail').': '.$test);
$newId = $db->execute("INSERT INTO sites (name,site_id,unifi_controller_url,unifi_username,unifi_password,public_access) VALUES (?,?,?,?,?,?)", $newId = $db->execute("INSERT INTO sites (name,site_id,unifi_controller_url,unifi_username,unifi_password,public_access,ssl_verify) VALUES (?,?,?,?,?,?,?)",
[$name,$siteId,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess]); [$name,$siteId,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$sslVerify]);
$auth->writeAuditLog($_SESSION['user_id'],'site_create','site',$newId,"Site {$name} erstellt"); $auth->writeAuditLog($_SESSION['user_id'],'site_create','site',$newId,"Site {$name} erstellt");
$success = __('sites_added'); flashSet(__('sites_added'));
header('Location: sites.php');
exit;
} catch (Exception $e) { $error = $e->getMessage(); } } catch (Exception $e) { $error = $e->getMessage(); }
} }
} }
// Delete site // Delete site (POST + PRG)
if (isset($_GET['delete']) && isset($_GET['token'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['delete_site'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$delId = (int)$_GET['delete']; $delId = (int)$_POST['delete_site'];
$db->query("DELETE FROM sites WHERE id=?", [$delId]); $db->query("DELETE FROM sites WHERE id=?", [$delId]);
$auth->writeAuditLog($_SESSION['user_id'],'site_delete','site',$delId,'Site gelöscht'); $auth->writeAuditLog($_SESSION['user_id'],'site_delete','site',$delId,'Site gelöscht');
$success = __('sites_deleted'); flashSet(__('sites_deleted'));
header('Location: sites.php');
exit;
} else { $error = __('error_csrf'); } } else { $error = __('error_csrf'); }
} }
// Toggle site // Toggle site (POST + PRG)
if (isset($_GET['toggle']) && isset($_GET['token'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_site'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_GET['toggle']]); $site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_POST['toggle_site']]);
if ($site) { if ($site) {
$db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_GET['toggle']]); $db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_POST['toggle_site']]);
$success = 'Site-Status aktualisiert!'; flashSet(__('sites_status_updated'));
header('Location: sites.php');
exit;
} }
} else { $error = __('error_csrf'); } } else { $error = __('error_csrf'); }
} }
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$sites = $db->fetchAll("SELECT * FROM sites ORDER BY name"); $sites = $db->fetchAll("SELECT * FROM sites ORDER BY name");
$currentPage = 'sites'; $currentPage = 'sites';
?> ?>
@ -104,9 +151,6 @@ $currentPage = 'sites';
<style> <style>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; flex-wrap: wrap; gap: 12px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); } .page-title { font-size: 26px; font-weight: 700; color: var(--text-primary); }
.alert { padding: 13px 18px; border-radius: 10px; font-size: 14px; margin-bottom: 20px; }
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
.sites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 18px; } .sites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 18px; }
.site-card { background: var(--bg-card); border: 2px solid var(--border-color); border-radius: 14px; padding: 20px; transition: border-color .2s, box-shadow .2s; } .site-card { background: var(--bg-card); border: 2px solid var(--border-color); border-radius: 14px; padding: 20px; transition: border-color .2s, box-shadow .2s; }
.site-card:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(102,126,234,.15); } .site-card:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(102,126,234,.15); }
@ -116,10 +160,6 @@ $currentPage = 'sites';
.site-info { margin: 14px 0; font-size: 13px; color: var(--text-secondary); } .site-info { margin: 14px 0; font-size: 13px; color: var(--text-secondary); }
.site-info-item { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; } .site-info-item { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
.site-actions { display: flex; gap: 7px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color); flex-wrap: wrap; } .site-actions { display: flex; gap: 7px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color); flex-wrap: wrap; }
.badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; margin: 2px; }
.badge-success { background: #d4edda; color: #155724; }
.badge-warning { background: #fff3cd; color: #856404; }
.badge-info { background: var(--bg-badge-info); color: var(--text-badge-info); }
.btn { padding: 8px 15px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; } .btn { padding: 8px 15px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
.btn-primary { background: var(--accent); color: white; } .btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); } .btn-primary:hover { background: var(--accent-hover); }
@ -200,20 +240,31 @@ $currentPage = 'sites';
</div> </div>
</div> </div>
<div class="site-actions"> <div class="site-actions">
<button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>)" <button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>, <?= (int)($site['ssl_verify'] ?? 0) ?>)"
class="btn btn-secondary btn-sm"> class="btn btn-secondary btn-sm">
<i class="fas fa-edit"></i> <?= __('btn_edit') ?> <i class="fas fa-edit"></i> <?= __('btn_edit') ?>
</button> </button>
<a href="?toggle=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <form method="post" style="display:inline;">
class="btn btn-secondary btn-sm"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="toggle_site" value="<?= $site['id'] ?>">
<button type="submit" class="btn btn-secondary btn-sm">
<i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>"></i> <i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>"></i>
<?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?> <?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>
</a> </button>
<a href="?delete=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>" </form>
class="btn btn-danger btn-sm" <button type="button" class="btn btn-secondary btn-sm" onclick="testSite(<?= $site['id'] ?>, this)"
onclick="return confirm('Möchten Sie diese Site wirklich löschen?')"> title="<?= __('site_test_btn') ?>" aria-label="<?= __('site_test_btn') ?>">
<i class="fas fa-plug"></i>
</button>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= addslashes(__('confirm_delete_site')) ?>')">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="delete_site" value="<?= $site['id'] ?>">
<button type="submit" class="btn btn-danger btn-sm"
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</a> </button>
</form>
</div> </div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
@ -261,6 +312,11 @@ $currentPage = 'sites';
<input type="checkbox" id="add_public" name="public_access"> <input type="checkbox" id="add_public" name="public_access">
<label for="add_public" style="margin:0;"><?= __('sites_public') ?></label> <label for="add_public" style="margin:0;"><?= __('sites_public') ?></label>
</div> </div>
<div class="form-group checkbox-group">
<input type="checkbox" id="add_ssl_verify" name="ssl_verify">
<label for="add_ssl_verify" style="margin:0;"><?= __('sites_ssl_verify') ?></label>
</div>
<small style="color:var(--text-muted);font-size:12px;display:block;margin-top:-10px;margin-bottom:14px;"><?= __('sites_ssl_verify_hint') ?></small>
<div style="display:flex;gap:10px;margin-top:20px;"> <div style="display:flex;gap:10px;margin-top:20px;">
<button type="submit" class="btn btn-primary" style="flex:1;" id="addSiteSubmitBtn"> <button type="submit" class="btn btn-primary" style="flex:1;" id="addSiteSubmitBtn">
<i class="fas fa-save"></i> <?= __('sites_add') ?> <i class="fas fa-save"></i> <?= __('sites_add') ?>
@ -311,6 +367,11 @@ $currentPage = 'sites';
<input type="checkbox" id="edit_public_access" name="public_access"> <input type="checkbox" id="edit_public_access" name="public_access">
<label for="edit_public_access" style="margin:0;"><?= __('sites_public') ?></label> <label for="edit_public_access" style="margin:0;"><?= __('sites_public') ?></label>
</div> </div>
<div class="form-group checkbox-group">
<input type="checkbox" id="edit_ssl_verify" name="ssl_verify">
<label for="edit_ssl_verify" style="margin:0;"><?= __('sites_ssl_verify') ?></label>
</div>
<small style="color:var(--text-muted);font-size:12px;display:block;margin-top:-10px;margin-bottom:14px;"><?= __('sites_ssl_verify_hint') ?></small>
<div style="display:flex;gap:10px;margin-top:20px;"> <div style="display:flex;gap:10px;margin-top:20px;">
<button type="submit" class="btn btn-primary" style="flex:1;" id="editSiteSubmitBtn"> <button type="submit" class="btn btn-primary" style="flex:1;" id="editSiteSubmitBtn">
<i class="fas fa-save"></i> <?= __('btn_save') ?> <i class="fas fa-save"></i> <?= __('btn_save') ?>
@ -328,7 +389,7 @@ $currentPage = 'sites';
function openModal() { document.getElementById('addSiteModal').classList.add('active'); } function openModal() { document.getElementById('addSiteModal').classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); } function closeModal(id) { document.getElementById(id).classList.remove('active'); }
function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAccess) { function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAccess, sslVerify) {
document.getElementById('edit_site_id').value = id; document.getElementById('edit_site_id').value = id;
document.getElementById('edit_name').value = name; document.getElementById('edit_name').value = name;
document.getElementById('edit_site_id_str').value = siteIdStr; document.getElementById('edit_site_id_str').value = siteIdStr;
@ -336,6 +397,7 @@ function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAcces
document.getElementById('edit_username').value = username; document.getElementById('edit_username').value = username;
document.getElementById('edit_password').value = ''; document.getElementById('edit_password').value = '';
document.getElementById('edit_public_access').checked = publicAccess == 1; document.getElementById('edit_public_access').checked = publicAccess == 1;
document.getElementById('edit_ssl_verify').checked = sslVerify == 1;
document.getElementById('editSiteModal').classList.add('active'); document.getElementById('editSiteModal').classList.add('active');
} }
@ -355,6 +417,23 @@ document.getElementById('editSiteForm').addEventListener('submit', function() {
if (e.target === this) closeModal(id); if (e.target === this) closeModal(id);
}); });
}); });
async function testSite(siteId, btn) {
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const fd = new FormData();
fd.append('ajax_test_site', siteId);
fd.append('csrf_token', '<?= $auth->getCsrfToken() ?>');
const result = await fetch('sites.php', { method: 'POST', body: fd }).then(r => r.json());
showToast(result.success ? 'success' : 'error', '<?= addslashes(__('site_test_btn')) ?>', result.message);
} catch (e) {
showToast('error', '<?= addslashes(__('site_test_btn')) ?>', e.message);
}
btn.disabled = false;
btn.innerHTML = original;
}
</script> </script>
</body> </body>
</html> </html>

View file

@ -7,6 +7,7 @@ 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/I18n.php'; require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth(); $auth = new Auth();
$auth->requireAdmin(); $auth->requireAdmin();
@ -41,7 +42,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_template'])) {
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']] [$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']]
); );
$success = __('templates_added'); flashSet(__('templates_added'));
header('Location: templates.php');
exit;
} catch (Exception $e) { } catch (Exception $e) {
$error = $e->getMessage(); $error = $e->getMessage();
} }
@ -71,23 +74,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) {
"UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, qos_rate_max_down=?, qos_rate_max_up=?, qos_usage_quota=?, is_active=? WHERE id=?", "UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, qos_rate_max_down=?, qos_rate_max_up=?, qos_usage_quota=?, is_active=? WHERE id=?",
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id] [$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id]
); );
$success = __('templates_updated'); flashSet(__('templates_updated'));
header('Location: templates.php');
exit;
} catch (Exception $e) { } catch (Exception $e) {
$error = $e->getMessage(); $error = $e->getMessage();
} }
} }
} }
// Profil löschen // Profil löschen (POST + PRG)
if (isset($_GET['delete']) && isset($_GET['token'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_template'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_GET['delete']]); $db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_POST['delete_template']]);
$success = __('templates_deleted'); flashSet(__('templates_deleted'));
header('Location: templates.php');
exit;
} else { } else {
$error = __('error_csrf'); $error = __('error_csrf');
} }
} }
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$templates = $db->fetchAll("SELECT t.*, u.name as creator FROM voucher_templates t LEFT JOIN users u ON t.created_by = u.id ORDER BY t.is_active DESC, t.name"); $templates = $db->fetchAll("SELECT t.*, u.name as creator FROM voucher_templates t LEFT JOIN users u ON t.created_by = u.id ORDER BY t.is_active DESC, t.name");
$currentPage = 'templates'; $currentPage = 'templates';
@ -103,9 +114,6 @@ $adminBase = '';
<style> <style>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; flex-wrap: wrap; gap: 15px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; flex-wrap: wrap; gap: 15px; }
.page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); } .page-title { font-size: 28px; font-weight: 600; color: var(--text-primary); }
.alert { padding: 14px 20px; border-radius: 10px; margin-bottom: 25px; font-size: 14px; }
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
.card { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 25px; } .card { background: var(--bg-card); border-radius: 15px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 25px; }
.card-header { padding: 20px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; } .card-header { padding: 20px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.card-title { font-size: 18px; font-weight: 600; color: var(--text-primary); } .card-title { font-size: 18px; font-weight: 600; color: var(--text-primary); }
@ -114,9 +122,6 @@ $adminBase = '';
.table td { padding: 14px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; } .table td { padding: 14px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: var(--bg-hover); } .table tr:hover td { background: var(--bg-hover); }
.badge { display: inline-block; padding: 3px 10px; border-radius: 6px; font-size: 11px; font-weight: 500; }
.badge-success { background: #d4edda; color: #155724; }
.badge-secondary { background: var(--bg-hover); color: var(--text-muted); }
.btn-primary { background: var(--accent); color: white; } .btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); } .btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: var(--danger); color: white; } .btn-danger { background: var(--danger); color: white; }
@ -214,9 +219,15 @@ $adminBase = '';
<td> <td>
<button onclick="openEditModal(<?= $t['id'] ?>, '<?= htmlspecialchars($t['name'], ENT_QUOTES) ?>', <?= (int)$t['max_uses'] ?>, <?= (int)$t['expire_minutes'] ?>, '<?= htmlspecialchars($t['description'] ?? '', ENT_QUOTES) ?>', <?= (int)$t['is_active'] ?>, <?= (int)($t['qos_rate_max_down'] ?? 0) ?>, <?= (int)($t['qos_rate_max_up'] ?? 0) ?>, <?= (int)($t['qos_usage_quota'] ?? 0) ?>)" <button onclick="openEditModal(<?= $t['id'] ?>, '<?= htmlspecialchars($t['name'], ENT_QUOTES) ?>', <?= (int)$t['max_uses'] ?>, <?= (int)$t['expire_minutes'] ?>, '<?= htmlspecialchars($t['description'] ?? '', ENT_QUOTES) ?>', <?= (int)$t['is_active'] ?>, <?= (int)($t['qos_rate_max_down'] ?? 0) ?>, <?= (int)($t['qos_rate_max_up'] ?? 0) ?>, <?= (int)($t['qos_usage_quota'] ?? 0) ?>)"
class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button> class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button>
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <form method="post" style="display:inline;"
onclick="return confirm('Profil wirklich löschen?')" onsubmit="return confirm('<?= addslashes(__('confirm_delete_template')) ?>')">
class="btn btn-danger btn-small"><i class="fas fa-trash"></i></a> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="delete_template" value="<?= $t['id'] ?>">
<button type="submit" class="btn btn-danger btn-small"
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
<i class="fas fa-trash"></i>
</button>
</form>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>

View file

@ -8,6 +8,7 @@ require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Mailer.php'; require_once __DIR__ . '/../includes/Mailer.php';
require_once __DIR__ . '/../includes/I18n.php'; require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth(); $auth = new Auth();
$auth->requireAdmin(); $auth->requireAdmin();
@ -20,10 +21,11 @@ I18n::init();
$error = ''; $error = '';
$success = ''; $success = '';
// Send password reset link // Send password reset link (POST statt GET: kein CSRF-Token in URLs/Referrern,
if (isset($_GET['send_reset']) && isset($_GET['token'])) { // keine versehentliche Ausloesung durch Link-Prefetching)
if ($auth->validateCsrfToken($_GET['token'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['send_reset'])) {
$targetUser = $db->fetchOne("SELECT * FROM users WHERE id=? AND is_active=1 AND password_hash IS NOT NULL", [(int)$_GET['send_reset']]); if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$targetUser = $db->fetchOne("SELECT * FROM users WHERE id=? AND is_active=1 AND password_hash IS NOT NULL", [(int)$_POST['send_reset']]);
if ($targetUser) { if ($targetUser) {
try { try {
$db->execute("DELETE FROM password_reset_tokens WHERE user_id=?", [$targetUser['id']]); $db->execute("DELETE FROM password_reset_tokens WHERE user_id=?", [$targetUser['id']]);
@ -39,12 +41,14 @@ if (isset($_GET['send_reset']) && isset($_GET['token'])) {
$resetUrl = $systemUrl . '/reset_password.php?token=' . $token; $resetUrl = $systemUrl . '/reset_password.php?token=' . $token;
$mailer->sendRaw($targetUser['email'], $appTitle . ' Passwort zurücksetzen', $mailer->sendRaw($targetUser['email'], $appTitle . ' Passwort zurücksetzen',
"Hallo {$targetUser['name']},\n\nEin Administrator hat für Sie einen Passwort-Reset-Link erstellt:\n\n{$resetUrl}\n\n(Gültig für 1 Stunde)\n\n{$appTitle}"); "Hallo {$targetUser['name']},\n\nEin Administrator hat für Sie einen Passwort-Reset-Link erstellt:\n\n{$resetUrl}\n\n(Gültig für 1 Stunde)\n\n{$appTitle}");
$success = 'Passwort-Reset-Link wurde an ' . htmlspecialchars($targetUser['email']) . ' gesendet.'; flashSet(__('reset_link_sent', ['email' => $targetUser['email']]));
header('Location: users.php');
exit;
} catch (Exception $e) { } catch (Exception $e) {
$error = 'Fehler beim Senden: ' . $e->getMessage(); $error = 'Fehler beim Senden: ' . $e->getMessage();
} }
} else { } else {
$error = 'Benutzer nicht gefunden oder kein lokales Passwort.'; $error = __('reset_link_failed');
} }
} else { } else {
$error = __('error_csrf'); $error = __('error_csrf');
@ -60,6 +64,11 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_user'])) {
$userId = (int)$_POST['user_id']; $userId = (int)$_POST['user_id'];
$isAdmin = isset($_POST['is_admin']) ? 1 : 0; $isAdmin = isset($_POST['is_admin']) ? 1 : 0;
$siteIds = $_POST['site_ids'] ?? []; $siteIds = $_POST['site_ids'] ?? [];
// Lockout-Schutz: Der letzte Weg ins Admin-Panel darf nicht
// versehentlich gekappt werden.
if ($userId === (int)$_SESSION['user_id'] && !$isAdmin) {
throw new Exception(__('error_self_demote'));
}
$oldUser = $db->fetchOne("SELECT * FROM users WHERE id=?", [$userId]); $oldUser = $db->fetchOne("SELECT * FROM users WHERE id=?", [$userId]);
$oldSites= $db->fetchAll("SELECT s.name FROM sites s INNER JOIN user_site_access usa ON s.id=usa.site_id WHERE usa.user_id=?", [$userId]); $oldSites= $db->fetchAll("SELECT s.name FROM sites s INNER JOIN user_site_access usa ON s.id=usa.site_id WHERE usa.user_id=?", [$userId]);
$db->query("UPDATE users SET is_admin=? WHERE id=?", [$isAdmin, $userId]); $db->query("UPDATE users SET is_admin=? WHERE id=?", [$isAdmin, $userId]);
@ -83,8 +92,10 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_user'])) {
if (!empty($removedSites)) $changes[] = 'Zugriff entfernt von: ' . implode(', ', $removedSites); if (!empty($removedSites)) $changes[] = 'Zugriff entfernt von: ' . implode(', ', $removedSites);
if ($isAdmin && !$oldUser['is_admin']) $changes[] = 'Sie haben nun Zugriff auf alle Sites'; if ($isAdmin && !$oldUser['is_admin']) $changes[] = 'Sie haben nun Zugriff auf alle Sites';
if (!empty($changes)) $mailer->sendUserNotification($oldUser['email'], $oldUser['name'], $changes); if (!empty($changes)) $mailer->sendUserNotification($oldUser['email'], $oldUser['name'], $changes);
$success = __('users_updated') . (!empty($changes) ? ' '.__('users_notified') : '');
$auth->writeAuditLog($_SESSION['user_id'], 'user_edit', 'user', $userId, implode('; ', $changes) ?: 'Keine Änderungen'); $auth->writeAuditLog($_SESSION['user_id'], 'user_edit', 'user', $userId, implode('; ', $changes) ?: 'Keine Änderungen');
flashSet(__('users_updated') . (!empty($changes) ? ' '.__('users_notified') : ''));
header('Location: users.php');
exit;
} catch (Exception $e) { $error = $e->getMessage(); } } catch (Exception $e) { $error = $e->getMessage(); }
} }
} }
@ -103,59 +114,72 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_user'])) {
if (empty($email)||empty($name)||empty($password)) throw new Exception(__('error_fill_all')); if (empty($email)||empty($name)||empty($password)) throw new Exception(__('error_fill_all'));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid')); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid'));
if (strlen($password) < 8) throw new Exception(__('settings_pw_minlength')); if (strlen($password) < 8) throw new Exception(__('settings_pw_minlength'));
if ($db->fetchOne("SELECT id FROM users WHERE email=?", [$email])) throw new Exception('E-Mail bereits vorhanden'); if ($db->fetchOne("SELECT id FROM users WHERE email=?", [$email])) throw new Exception(__('error_email_exists'));
$userId = $auth->registerUser($email, $name, $password, $isAdmin); $userId = $auth->registerUser($email, $name, $password, $isAdmin);
if (!$userId) throw new Exception('Benutzer konnte nicht erstellt werden'); if (!$userId) throw new Exception(__('error_user_create'));
if (!$isAdmin && !empty($siteIds)) { if (!$isAdmin && !empty($siteIds)) {
foreach ($siteIds as $siteId) { foreach ($siteIds as $siteId) {
$db->execute("INSERT INTO user_site_access (user_id, site_id) VALUES (?,?)", [$userId, $siteId]); $db->execute("INSERT INTO user_site_access (user_id, site_id) VALUES (?,?)", [$userId, $siteId]);
} }
} }
$auth->writeAuditLog($_SESSION['user_id'], 'user_create', 'user', $userId, "Benutzer {$name} erstellt"); $auth->writeAuditLog($_SESSION['user_id'], 'user_create', 'user', $userId, "Benutzer {$name} erstellt");
$success = __('users_added'); flashSet(__('users_added'));
header('Location: users.php');
exit;
} catch (Exception $e) { $error = $e->getMessage(); } } catch (Exception $e) { $error = $e->getMessage(); }
} }
} }
// Delete user // Delete user (POST + PRG)
if (isset($_GET['delete']) && isset($_GET['token'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['delete_user'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$deleteId = (int)$_GET['delete']; $deleteId = (int)$_POST['delete_user'];
if ($deleteId === (int)$_SESSION['user_id']) { if ($deleteId === (int)$_SESSION['user_id']) {
$error = 'Sie können sich nicht selbst löschen'; $error = __('error_self_delete');
} else { } else {
$db->query("DELETE FROM users WHERE id=?", [$deleteId]); $db->query("DELETE FROM users WHERE id=?", [$deleteId]);
$auth->writeAuditLog($_SESSION['user_id'], 'user_delete', 'user', $deleteId, 'Benutzer gelöscht'); $auth->writeAuditLog($_SESSION['user_id'], 'user_delete', 'user', $deleteId, 'Benutzer gelöscht');
$success = __('users_deleted'); flashSet(__('users_deleted'));
header('Location: users.php');
exit;
} }
} else { $error = __('error_csrf'); } } else { $error = __('error_csrf'); }
} }
// Toggle user active // Toggle user active (POST + PRG)
if (isset($_GET['toggle']) && isset($_GET['token'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_user'])) {
if ($auth->validateCsrfToken($_GET['token'])) { if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$toggleId = (int)$_GET['toggle']; $toggleId = (int)$_POST['toggle_user'];
if ($toggleId === (int)$_SESSION['user_id']) { if ($toggleId === (int)$_SESSION['user_id']) {
$error = 'Sie können sich nicht selbst deaktivieren'; $error = __('error_self_deactivate');
} else { } else {
$user = $db->fetchOne("SELECT is_active FROM users WHERE id=?", [$toggleId]); $user = $db->fetchOne("SELECT is_active FROM users WHERE id=?", [$toggleId]);
if ($user) { if ($user) {
$newStatus = $user['is_active'] ? 0 : 1; $newStatus = $user['is_active'] ? 0 : 1;
$db->query("UPDATE users SET is_active=? WHERE id=?", [$newStatus, $toggleId]); $db->query("UPDATE users SET is_active=? WHERE id=?", [$newStatus, $toggleId]);
$success = 'Benutzer-Status aktualisiert!'; flashSet(__('users_status_updated'));
header('Location: users.php');
exit;
} }
} }
} else { $error = __('error_csrf'); } } else { $error = __('error_csrf'); }
} }
// 2FA eines Benutzers zurücksetzen (Admin-Hilfe bei verlorenem Authenticator) // 2FA eines Benutzers zurücksetzen (Admin-Hilfe bei verlorenem Authenticator)
if (isset($_GET['reset_2fa']) && isset($_GET['token'])) { // POST + PRG wie die uebrigen state-aendernden Aktionen
if ($auth->validateCsrfToken($_GET['token'])) { if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['reset_2fa'])) {
$auth->disableTotp((int)$_GET['reset_2fa']); if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$success = '2FA des Benutzers wurde zurückgesetzt.'; $auth->disableTotp((int)$_POST['reset_2fa']);
flashSet('2FA des Benutzers wurde zurückgesetzt.');
header('Location: users.php');
exit;
} else { $error = __('error_csrf'); } } else { $error = __('error_csrf'); }
} }
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$users = $db->fetchAll("SELECT * FROM users ORDER BY name"); $users = $db->fetchAll("SELECT * FROM users ORDER BY name");
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name"); $sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
$userSiteAccess = []; $userSiteAccess = [];
@ -177,19 +201,11 @@ $currentPage = 'users';
.card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 24px; } .card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 24px; }
.card-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); } .card-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); }
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); } .card-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.alert { padding: 13px 18px; border-radius: 10px; font-size: 14px; margin-bottom: 20px; }
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
.table { width: 100%; border-collapse: collapse; } .table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 12px 15px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; } .table th { text-align: left; padding: 12px 15px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
.table td { padding: 13px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; } .table td { padding: 13px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tr:hover { background: var(--bg-hover); } .table tr:hover { background: var(--bg-hover); }
.badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; margin: 2px; }
.badge-success { background: #d4edda; color: #155724; }
.badge-warning { background: #fff3cd; color: #856404; }
.badge-danger { background: #f8d7da; color: #721c24; }
.badge-info { background: var(--bg-badge-info); color: var(--text-badge-info); }
.btn { padding: 8px 16px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; } .btn { padding: 8px 16px; border-radius: 8px; border: none; font-weight: 500; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 7px; transition: all .2s; font-size: 13px; }
.btn-primary { background: var(--accent); color: white; } .btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); } .btn-primary:hover { background: var(--accent-hover); }
@ -301,29 +317,46 @@ $currentPage = 'users';
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button>
<?php if ($user['id'] != $_SESSION['user_id']): ?> <?php if ($user['id'] != $_SESSION['user_id']): ?>
<a href="?toggle=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <form method="post" style="display:inline;">
class="btn btn-secondary btn-sm" title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="toggle_user" value="<?= $user['id'] ?>">
<button type="submit" class="btn btn-secondary btn-sm"
title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>"
aria-label="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>">
<i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>"></i> <i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>"></i>
</a> </button>
</form>
<?php if ($smtpEnabled && !empty($user['password_hash'])): ?> <?php if ($smtpEnabled && !empty($user['password_hash'])): ?>
<a href="?send_reset=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <form method="post" style="display:inline;"
class="btn btn-warning btn-sm" title="<?= __('users_reset_pw') ?>" onsubmit="return confirm('<?= addslashes(__('confirm_send_reset', ['email' => $user['email']])) ?>')">
onclick="return confirm('Passwort-Reset-Link senden an <?= htmlspecialchars($user['email'], ENT_QUOTES) ?>?')"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="send_reset" value="<?= $user['id'] ?>">
<button type="submit" class="btn btn-warning btn-sm"
title="<?= __('users_reset_pw') ?>" aria-label="<?= __('users_reset_pw') ?>">
<i class="fas fa-key"></i> <i class="fas fa-key"></i>
</a> </button>
</form>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($user['totp_enabled'])): ?> <?php if (!empty($user['totp_enabled'])): ?>
<a href="?reset_2fa=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <form method="post" style="display:inline;"
class="btn btn-secondary btn-sm" title="2FA zurücksetzen" onsubmit="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
onclick="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="reset_2fa" value="<?= $user['id'] ?>">
<button type="submit" class="btn btn-secondary btn-sm"
title="2FA zurücksetzen" aria-label="2FA zurücksetzen">
<i class="fas fa-user-shield"></i> <i class="fas fa-user-shield"></i>
</a> </button>
</form>
<?php endif; ?> <?php endif; ?>
<a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <form method="post" style="display:inline;"
class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>" onsubmit="return confirm('<?= addslashes(__('confirm_delete_user')) ?>')">
onclick="return confirm('Benutzer wirklich löschen?')"> <input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="delete_user" value="<?= $user['id'] ?>">
<button type="submit" class="btn btn-danger btn-sm"
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</a> </button>
</form>
<?php endif; ?> <?php endif; ?>
</div> </div>
</td> </td>

View file

@ -23,13 +23,19 @@ if (isset($_GET['export_csv']) && isset($_GET['site_id'])) {
if (!$site) { http_response_code(404); exit; } if (!$site) { http_response_code(404); exit; }
$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]); $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'; $filename = 'vouchers_' . preg_replace('/[^a-z0-9]/i','_',$site['name']) . '_' . date('Ymd_His') . '.csv';
// Schutz vor CSV/Excel-Formula-Injection: Zellen, die mit =, +, -, @ oder
// Tab beginnen (Nutzereingabe voucher_name!), mit Apostroph neutralisieren.
$csvSafe = function ($v) {
$v = (string)$v;
return preg_match('/^[=+\-@\t]/', $v) ? "'" . $v : $v;
};
header('Content-Type: text/csv; charset=UTF-8'); header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output','w'); $out = fopen('php://output','w');
fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF)); fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($out,['Code','Name','Max. Geräte','Gültigkeit (Min)','Status','Genutzt','Erstellt','Läuft ab'],';'); fputcsv($out,['Code','Name','Max. Geräte','Gültigkeit (Min)','Status','Genutzt','Erstellt','Läuft ab'],';');
foreach ($rows as $r) { 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']??''],';'); fputcsv($out,[$csvSafe($r['voucher_code']),$csvSafe($r['voucher_name']),$r['max_uses'],$r['expire_minutes'],$r['status'],$r['used_count'],$r['created_at'],$r['expires_at']??''],';');
} }
fclose($out); exit; fclose($out); exit;
} }
@ -44,7 +50,7 @@ if (isset($_GET['ajax_get_vouchers']) && isset($_GET['site_id'])) {
if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; } if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; }
if ($syncFirst) { if ($syncFirst) {
try { try {
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id']); $ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id'],$site['ssl_verify'] ?? 0);
$ctrl->syncVouchersToDatabase($db,$siteId); $ctrl->syncVouchersToDatabase($db,$siteId);
$db->execute("INSERT INTO settings (setting_key,setting_value) VALUES ('last_cron_sync',NOW()) ON DUPLICATE KEY UPDATE setting_value=NOW()"); $db->execute("INSERT INTO settings (setting_key,setting_value) VALUES ('last_cron_sync',NOW()) ON DUPLICATE KEY UPDATE setting_value=NOW()");
} catch (Exception $e) { error_log("Sync error: ".$e->getMessage()); } } catch (Exception $e) { error_log("Sync error: ".$e->getMessage()); }
@ -83,12 +89,12 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
$siteId = (int)$_POST['site_id']; $siteId = (int)$_POST['site_id'];
$site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]); $site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]);
if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; } if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; }
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id']); $ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id'],$site['ssl_verify'] ?? 0);
if ($ctrl->deleteVoucher($voucherId)) { if ($ctrl->deleteVoucher($voucherId)) {
$db->execute("DELETE FROM vouchers WHERE unifi_voucher_id=? AND site_id=?", [$voucherId,$siteId]); $db->execute("DELETE FROM vouchers WHERE unifi_voucher_id=? AND site_id=?", [$voucherId,$siteId]);
echo json_encode(['success'=>true,'message'=>'Voucher erfolgreich gelöscht!']); echo json_encode(['success'=>true,'message'=>__('voucher_deleted')]);
} else { } else {
echo json_encode(['success'=>false,'message'=>'Voucher konnte nicht gelöscht werden']); echo json_encode(['success'=>false,'message'=>__('voucher_delete_failed')]);
} }
} catch (Exception $e) { } catch (Exception $e) {
echo json_encode(['success'=>false,'message'=>'Fehler: '.$e->getMessage()]); echo json_encode(['success'=>false,'message'=>'Fehler: '.$e->getMessage()]);
@ -170,11 +176,6 @@ $currentPage = 'vouchers';
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tr:hover { background: var(--bg-hover); } .table tr:hover { background: var(--bg-hover); }
.table tr.deleting { opacity: .45; pointer-events: none; } .table tr.deleting { opacity: .45; pointer-events: none; }
.badge { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 500; }
.badge-success { background: #d4edda; color: #155724; }
.badge-warning { background: #fff3cd; color: #856404; }
.badge-danger { background: #f8d7da; color: #721c24; }
.badge-info { background: var(--bg-badge-info); color: var(--text-badge-info); }
code { background: var(--bg-hover); padding: 4px 8px; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12px; letter-spacing: 1px; } code { background: var(--bg-hover); padding: 4px 8px; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12px; letter-spacing: 1px; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); } .empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
.empty-state i { font-size: 42px; margin-bottom: 18px; opacity: .3; display: block; } .empty-state i { font-size: 42px; margin-bottom: 18px; opacity: .3; display: block; }
@ -289,7 +290,7 @@ async function loadVouchers(syncFirst=false) {
refreshBtn.disabled = false; refreshBtn.disabled = false;
refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`; refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`;
document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner"></i><span>${syncFirst ? 'Synchronisiere...' : 'Lade...'}</span></div>`; document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner"></i><span>${syncFirst ? '<?= addslashes(__('syncing')) ?>' : '<?= addslashes(__('loading')) ?>'}</span></div>`;
try { try {
const result = await fetch(`vouchers.php?ajax_get_vouchers=1&site_id=${siteId}${syncFirst?'&sync=1':''}`).then(r=>r.json()); const result = await fetch(`vouchers.php?ajax_get_vouchers=1&site_id=${siteId}${syncFirst?'&sync=1':''}`).then(r=>r.json());
@ -305,7 +306,7 @@ async function loadVouchers(syncFirst=false) {
const csvBtn = document.getElementById('csvExportBtn'); const csvBtn = document.getElementById('csvExportBtn');
csvBtn.style.display = 'inline-flex'; csvBtn.style.display = 'inline-flex';
csvBtn.href = `vouchers.php?export_csv=1&site_id=${siteId}&token=${csrfToken}`; csvBtn.href = `vouchers.php?export_csv=1&site_id=${siteId}&token=${csrfToken}`;
if (syncFirst) showToast('success', '<?= addslashes(__('btn_refresh')) ?>', `${result.count} Vouchers geladen`); if (syncFirst) showToast('success', '<?= addslashes(__('btn_refresh')) ?>', <?= json_encode(__('vouchers_loaded')) ?>.replace('{count}', result.count));
} else { } else {
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></i><p>${result.message}</p></div>`; document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></i><p>${result.message}</p></div>`;
document.getElementById('statsContainer').style.display = 'none'; document.getElementById('statsContainer').style.display = 'none';
@ -397,7 +398,7 @@ function renderVouchers() {
html += `<tr id="voucher-${v._id}"> html += `<tr id="voucher-${v._id}">
<td><strong>${createDate.toLocaleDateString('de-DE')}</strong><br><small style="color:var(--text-muted)">${createDate.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}</small></td> <td><strong>${createDate.toLocaleDateString('de-DE')}</strong><br><small style="color:var(--text-muted)">${createDate.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}</small></td>
<td><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','Kopiert!')" title="Kopieren" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td> <td><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','<?= addslashes(__('toast_copied')) ?>')" title="<?= addslashes(__('click_to_copy')) ?>" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td>
<td class="voucher-note" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td> <td class="voucher-note" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td> <td><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
@ -444,7 +445,7 @@ async function resendVoucher(voucherId, code) {
} }
async function deleteVoucher(voucherId) { async function deleteVoucher(voucherId) {
if (!confirm('Voucher wirklich löschen?')) return; if (!confirm('<?= addslashes(__('confirm_delete_voucher')) ?>')) return;
const row = document.getElementById(`voucher-${voucherId}`); const row = document.getElementById(`voucher-${voucherId}`);
if (row) row.classList.add('deleting'); if (row) row.classList.add('deleting');
try { try {

View file

@ -29,6 +29,18 @@
--scrollbar-thumb: #c1c1c1; --scrollbar-thumb: #c1c1c1;
--toast-bg: #ffffff; --toast-bg: #ffffff;
--stat-sub: #f0f0f0; --stat-sub: #f0f0f0;
--alert-error-bg: #fee;
--alert-error-border: #fcc;
--alert-error-text: #c33;
--alert-success-bg: #efe;
--alert-success-border: #cfc;
--alert-success-text: #3c3;
--badge-success-bg: #d4edda;
--badge-success-text: #155724;
--badge-warning-bg: #fff3cd;
--badge-warning-text: #856404;
--badge-danger-bg: #f8d7da;
--badge-danger-text: #721c24;
} }
[data-theme="dark"] { [data-theme="dark"] {
@ -61,6 +73,18 @@
--scrollbar-thumb: #3a3f5a; --scrollbar-thumb: #3a3f5a;
--toast-bg: #1a1d27; --toast-bg: #1a1d27;
--stat-sub: #22273a; --stat-sub: #22273a;
--alert-error-bg: #3a161c;
--alert-error-border: #5c2230;
--alert-error-text: #f5a3ad;
--alert-success-bg: #122e1d;
--alert-success-border: #1e4d30;
--alert-success-text: #86e0a3;
--badge-success-bg: #122e1d;
--badge-success-text: #86e0a3;
--badge-warning-bg: #3a2f10;
--badge-warning-text: #f0c95c;
--badge-danger-bg: #3a161c;
--badge-danger-text: #f5a3ad;
} }
/* === DARK MODE OVERRIDES FOR COMMON ELEMENTS === */ /* === DARK MODE OVERRIDES FOR COMMON ELEMENTS === */
@ -199,18 +223,6 @@
[data-theme="dark"] .placeholder-info h4 { color: #d4a017 !important; } [data-theme="dark"] .placeholder-info h4 { color: #d4a017 !important; }
[data-theme="dark"] .placeholder-info code { background: #2a2000 !important; } [data-theme="dark"] .placeholder-info code { background: #2a2000 !important; }
[data-theme="dark"] .alert-error {
background: #2d0a0a !important;
border-color: #7f1d1d !important;
color: #fca5a5 !important;
}
[data-theme="dark"] .alert-success {
background: #052e16 !important;
border-color: #14532d !important;
color: #86efac !important;
}
[data-theme="dark"] .user-menu { [data-theme="dark"] .user-menu {
background: var(--bg-hover) !important; background: var(--bg-hover) !important;
} }
@ -469,3 +481,40 @@
body, .card, .sidebar, .header, input, select, textarea, .btn { body, .card, .sidebar, .header, input, select, textarea, .btn {
transition: background-color 0.2s, border-color 0.2s, color 0.2s; transition: background-color 0.2s, border-color 0.2s, color 0.2s;
} }
/* === SHARED COMPONENTS: ALERTS & BADGES ===
Zentral definiert (statt pro Seite dupliziert), damit Light- und
Dark-Mode ueber die CSS-Variablen oben konsistent funktionieren. */
.alert {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 18px;
border-radius: 10px;
font-size: 14px;
margin-bottom: 20px;
}
.alert-error {
background: var(--alert-error-bg);
border: 1px solid var(--alert-error-border);
color: var(--alert-error-text);
}
.alert-success {
background: var(--alert-success-bg);
border: 1px solid var(--alert-success-border);
color: var(--alert-success-text);
}
.badge {
display: inline-block;
padding: 3px 9px;
border-radius: 5px;
font-size: 11px;
font-weight: 500;
margin: 2px;
}
.badge-success { background: var(--badge-success-bg); color: var(--badge-success-text); }
.badge-warning { background: var(--badge-warning-bg); color: var(--badge-warning-text); }
.badge-danger { background: var(--badge-danger-bg); color: var(--badge-danger-text); }
.badge-info { background: var(--bg-badge-info); color: var(--text-badge-info); }
.badge-secondary { background: var(--bg-hover); color: var(--text-muted); }

View file

@ -35,6 +35,9 @@ document.addEventListener('DOMContentLoaded', updateDarkModeBtn);
container.id = 'toast-container'; container.id = 'toast-container';
document.body.appendChild(container); document.body.appendChild(container);
} }
// Screenreader ueber neue Toasts informieren
container.setAttribute('role', 'status');
container.setAttribute('aria-live', 'polite');
} }
return container; return container;
} }
@ -92,13 +95,21 @@ document.addEventListener('DOMContentLoaded', function() {
if (overlay) overlay.addEventListener('click', closeMobileSidebar); if (overlay) overlay.addEventListener('click', closeMobileSidebar);
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeMobileSidebar(); if (e.key === 'Escape') {
closeMobileSidebar();
// Offene Modals per Esc schliessen (Accessibility)
document.querySelectorAll('.modal.active').forEach(m => m.classList.remove('active'));
}
}); });
}); });
/* === LANGUAGE SWITCHER === */ /* === LANGUAGE SWITCHER === */
function switchLanguage(lang) { function switchLanguage(lang) {
fetch('?set_lang=' + lang, { method: 'GET' }).then(() => location.reload()); // Direkter Navigationswechsel statt fetch+reload: vermeidet den
// "Formular erneut senden?"-Dialog und erhaelt bestehende URL-Parameter.
const url = new URL(window.location.href);
url.searchParams.set('set_lang', lang);
window.location.href = url.toString();
} }
/* === CLIPBOARD === */ /* === CLIPBOARD === */

View file

@ -117,7 +117,8 @@ if (empty($cronToken)) {
exit; exit;
} }
if ($providedToken !== $cronToken) { // hash_equals: zeitkonstanter Vergleich (kein Timing-Seitenkanal)
if (!hash_equals((string)$cronToken, (string)$providedToken)) {
outputResponse([ outputResponse([
'success' => false, 'success' => false,
'message' => 'Ungültiger Token' 'message' => 'Ungültiger Token'
@ -174,7 +175,8 @@ try {
$site['unifi_controller_url'], $site['unifi_controller_url'],
$site['unifi_username'], $site['unifi_username'],
Crypto::decrypt($site['unifi_password']), Crypto::decrypt($site['unifi_password']),
$site['site_id'] $site['site_id'],
$site['ssl_verify'] ?? 0
); );
$stats = $controller->syncVouchersToDatabase($db, $site['id']); $stats = $controller->syncVouchersToDatabase($db, $site['id']);

View file

@ -1,5 +1,12 @@
<?php <?php
// Minimaler Test für Cron-Sync Debugging // Minimaler Test für Cron-Sync Debugging
// Nur fuer angemeldete Admins zugaenglich (leakt sonst DB-Schema & Token-Status)
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
$cronTestAuth = new Auth();
$cronTestAuth->requireAdmin();
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(['step' => 1, 'message' => 'PHP läuft']); echo json_encode(['step' => 1, 'message' => 'PHP läuft']);

View file

@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `sites` (
`unifi_password` VARCHAR(255) NOT NULL, `unifi_password` VARCHAR(255) NOT NULL,
`is_active` TINYINT(1) DEFAULT 1, `is_active` TINYINT(1) DEFAULT 1,
`public_access` TINYINT(1) DEFAULT 0, `public_access` TINYINT(1) DEFAULT 0,
`ssl_verify` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_active` (`is_active`) INDEX `idx_active` (`is_active`)
@ -147,6 +148,16 @@ CREATE TABLE IF NOT EXISTS `audit_log` (
INDEX `idx_created` (`created_at`) INDEX `idx_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- IP-basiertes Request-Throttling (anonyme Voucher-Erstellung, Passwort-Resets)
CREATE TABLE IF NOT EXISTS `request_throttle` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`ip_address` VARCHAR(45) NOT NULL,
`action` VARCHAR(50) NOT NULL,
`weight` INT NOT NULL DEFAULT 1,
`requested_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_throttle` (`action`, `ip_address`, `requested_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `password_reset_tokens` ( CREATE TABLE IF NOT EXISTS `password_reset_tokens` (
`id` INT PRIMARY KEY AUTO_INCREMENT, `id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL, `user_id` INT NOT NULL,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 313 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1,022 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 938 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,024 KiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 920 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Before After
Before After

View file

@ -8,6 +8,7 @@ require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php'; require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/Mailer.php'; require_once __DIR__ . '/includes/Mailer.php';
require_once __DIR__ . '/includes/I18n.php'; require_once __DIR__ . '/includes/I18n.php';
require_once __DIR__ . '/includes/Helpers.php';
$auth = new Auth(); $auth = new Auth();
if ($auth->isLoggedIn()) { header('Location: index.php'); exit; } if ($auth->isLoggedIn()) { header('Location: index.php'); exit; }
@ -16,8 +17,18 @@ I18n::init();
$db = Database::getInstance(); $db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', ''); $logoUrl = $db->getSetting('logo_url', '');
$faviconUrl = $db->getSetting('favicon_url', '');
$systemUrl = rtrim($db->getSetting('system_url', ''), '/'); $systemUrl = rtrim($db->getSetting('system_url', ''), '/');
// Fallback: URL automatisch erkennen (wie im Mailer), sonst ist der
// Reset-Link in der E-Mail relativ und damit kaputt.
if (empty($systemUrl)) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$systemUrl = $protocol . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . $scriptPath;
}
$error = ''; $error = '';
$success = ''; $success = '';
@ -34,7 +45,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} else { } else {
$rl[] = $now; $rl[] = $now;
$_SESSION['pwreset_times'] = $rl; $_SESSION['pwreset_times'] = $rl;
$user = $db->fetchOne("SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL", [$email]);
// Zusaetzlich zum Session-Throttle: IP-Rate-Limit gegen Mail-Bombing
// (max. 5 Reset-Anfragen / 15 Min., umgeht Cookie-Loeschen). Bei Limit
// trotzdem die generische Erfolgsmeldung zeigen (keine Information
// darueber preisgeben, ob das Konto existiert).
$resetLimited = throttleHit($db, 'password_reset', 5, 15) === true;
$user = $resetLimited
? null
: $db->fetchOne("SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL", [$email]);
// Always show success (don't reveal whether email exists) // Always show success (don't reveal whether email exists)
if ($user) { if ($user) {
@ -83,6 +103,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<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><?= __('reset_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title><?= __('reset_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php if ($faviconUrl): ?>
<link rel="icon" href="<?= htmlspecialchars($faviconUrl) ?>">
<?php endif; ?>
<link rel="stylesheet" href="assets/global.css"> <link rel="stylesheet" href="assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script> <script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<style> <style>
@ -98,9 +121,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
input:focus { outline: none; border-color: var(--accent); } input:focus { outline: none; border-color: var(--accent); }
.btn { width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 8px; } .btn { width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 8px; }
.btn:hover { background: var(--accent-hover); transform: translateY(-2px); } .btn:hover { background: var(--accent-hover); transform: translateY(-2px); }
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; text-align: left; }
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
.back-link { display: block; margin-top: 22px; color: var(--accent); text-decoration: none; font-size: 14px; } .back-link { display: block; margin-top: 22px; color: var(--accent); text-decoration: none; font-size: 14px; }
.back-link:hover { text-decoration: underline; } .back-link:hover { text-decoration: underline; }
</style> </style>

View file

@ -5,6 +5,8 @@ require_once __DIR__ . '/Crypto.php';
class Auth { class Auth {
private $db; private $db;
/** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */
private $sessionUser = false;
public function __construct() { public function __construct() {
try { try {
@ -18,6 +20,9 @@ class Auth {
ini_set('session.cookie_httponly', 1); ini_set('session.cookie_httponly', 1);
ini_set('session.use_strict_mode', 1); ini_set('session.use_strict_mode', 1);
ini_set('session.cookie_samesite', 'Lax'); ini_set('session.cookie_samesite', 'Lax');
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
ini_set('session.cookie_secure', 1);
}
// Opt-in: Sessions in der DB ablegen (für "überall abmelden" / Skalierung) // Opt-in: Sessions in der DB ablegen (für "überall abmelden" / Skalierung)
try { try {
@ -243,6 +248,8 @@ class Auth {
private function recordLoginAttempt($ip, $email) { private function recordLoginAttempt($ip, $email) {
try { try {
// Alte Eintraege aufraeumen, damit die Tabelle nicht unbegrenzt waechst
$this->db->query("DELETE FROM login_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 1 DAY)");
$this->db->query( $this->db->query(
"INSERT INTO login_attempts (ip_address, email) VALUES (?, ?)", "INSERT INTO login_attempts (ip_address, email) VALUES (?, ?)",
[$ip, $email] [$ip, $email]
@ -309,6 +316,12 @@ class Auth {
// Session setzen // Session setzen
private function setUserSession($user) { private function setUserSession($user) {
// Session-ID nach erfolgreichem Login rotieren (verhindert Session-Fixation)
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
$this->sessionUser = false; // User-Cache invalidieren
$_SESSION['user_id'] = $user['id']; $_SESSION['user_id'] = $user['id'];
$_SESSION['user_email'] = $user['email']; $_SESSION['user_email'] = $user['email'];
$_SESSION['user_name'] = $user['name']; $_SESSION['user_name'] = $user['name'];
@ -331,6 +344,7 @@ class Auth {
// Ausloggen // Ausloggen
public function logout() { public function logout() {
$this->sessionUser = null;
$_SESSION = []; $_SESSION = [];
if (isset($_COOKIE[session_name()])) { if (isset($_COOKIE[session_name()])) {
@ -340,6 +354,25 @@ class Auth {
session_destroy(); session_destroy();
} }
/**
* Laedt den Session-User einmal pro Request aus der DB. Dadurch wirken
* Rechteaenderungen (Admin entzogen, Konto deaktiviert/geloescht) sofort
* und nicht erst nach Ablauf der Session.
*/
private function loadSessionUser() {
if ($this->sessionUser === false) {
$this->sessionUser = null;
if (isset($_SESSION['user_id'])) {
$user = $this->db->fetchOne(
"SELECT * FROM users WHERE id = ? AND is_active = 1",
[$_SESSION['user_id']]
);
$this->sessionUser = $user ?: null;
}
}
return $this->sessionUser;
}
// Prüfen ob eingeloggt // Prüfen ob eingeloggt
public function isLoggedIn() { public function isLoggedIn() {
if (!isset($_SESSION['user_id']) || !isset($_SESSION['login_time'])) { if (!isset($_SESSION['user_id']) || !isset($_SESSION['login_time'])) {
@ -354,12 +387,24 @@ class Auth {
return false; return false;
} }
// Deaktivierte/geloeschte Konten sofort aussperren
if ($this->loadSessionUser() === null) {
$this->logout();
return false;
}
return true; return true;
} }
// Prüfen ob Admin // Prüfen ob Admin (live aus der DB, nicht aus dem Session-Cache)
public function isAdmin() { public function isAdmin() {
return $this->isLoggedIn() && isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true; if (!$this->isLoggedIn()) {
return false;
}
$user = $this->loadSessionUser();
$isAdmin = $user !== null && (bool)$user['is_admin'];
$_SESSION['is_admin'] = $isAdmin;
return $isAdmin;
} }
// Aktuellen Benutzer abrufen // Aktuellen Benutzer abrufen
@ -367,11 +412,7 @@ class Auth {
if (!$this->isLoggedIn()) { if (!$this->isLoggedIn()) {
return null; return null;
} }
return $this->loadSessionUser();
return $this->db->fetchOne(
"SELECT * FROM users WHERE id = ?",
[$_SESSION['user_id']]
);
} }
// Prüfen ob Benutzer Zugriff auf Site hat // Prüfen ob Benutzer Zugriff auf Site hat

View file

@ -114,4 +114,9 @@ class Crypto {
public static function isEncrypted($value) { public static function isEncrypted($value) {
return is_string($value) && strpos($value, self::PREFIX) === 0; return is_string($value) && strpos($value, self::PREFIX) === 0;
} }
/** Prueft, ob ein gueltiger APP_KEY konfiguriert ist (fuer Admin-Warnhinweis). */
public static function hasKey() {
return self::key() !== null;
}
} }

53
includes/Helpers.php Normal file
View file

@ -0,0 +1,53 @@
<?php
/**
* Kleine Shared-Helper:
* - Session-Flash-Messages fuer das PRG-Pattern (Redirect nach POST,
* Erfolgsmeldung ueberlebt den Redirect, F5 wiederholt keine Aktion).
* - IP-basiertes Request-Throttling ueber die Tabelle request_throttle.
*/
function flashSet($message, $type = 'success') {
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
/** @return array|null ['type' => ..., 'message' => ...] oder null */
function flashGet() {
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return $flash;
}
/**
* Zaehlt eine Aktion fuer die aktuelle IP und prueft das Limit.
*
* @param Database $db
* @param string $action Logischer Name, z.B. 'voucher_create'
* @param int $maxWeight Erlaubte Summe im Zeitfenster
* @param int $windowMinutes Zeitfenster in Minuten
* @param int $weight Gewicht dieser Anfrage (z.B. Bulk-Anzahl)
* @return bool|null true = limitiert, false = erlaubt (und gezaehlt),
* null = Tabelle fehlt (Aufrufer entscheidet ueber Fallback)
*/
function throttleHit($db, $action, $maxWeight, $windowMinutes, $weight = 1) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
try {
$db->query("DELETE FROM request_throttle WHERE requested_at < DATE_SUB(NOW(), INTERVAL 1 DAY)");
$row = $db->fetchOne(
"SELECT COALESCE(SUM(weight), 0) AS cnt FROM request_throttle
WHERE action = ? AND ip_address = ?
AND requested_at > DATE_SUB(NOW(), INTERVAL " . (int)$windowMinutes . " MINUTE)",
[$action, $ip]
);
if ((int)$row['cnt'] + $weight > $maxWeight) {
return true;
}
$db->query(
"INSERT INTO request_throttle (ip_address, action, weight) VALUES (?, ?, ?)",
[$ip, $action, $weight]
);
return false;
} catch (Exception $e) {
// Tabelle existiert noch nicht (Migration 0002 nicht gelaufen)
return null;
}
}

View file

@ -7,6 +7,7 @@ class Mailer {
private $smtpUsername; private $smtpUsername;
private $smtpPassword; private $smtpPassword;
private $smtpEncryption; private $smtpEncryption;
private $smtpVerifySsl;
private $fromEmail; private $fromEmail;
private $fromName; private $fromName;
@ -22,7 +23,8 @@ class Mailer {
$this->smtpUsername = $this->db->getSetting('smtp_username', ''); $this->smtpUsername = $this->db->getSetting('smtp_username', '');
$this->smtpPassword = $this->db->getSetting('smtp_password', ''); $this->smtpPassword = $this->db->getSetting('smtp_password', '');
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls'); $this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']); $this->smtpVerifySsl = $this->db->getSetting('smtp_verify_ssl', '0') === '1';
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost'));
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System')); $this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System'));
} }
@ -60,23 +62,29 @@ class Mailer {
private function sendWithSmtp($to, $subject, $body, $isHtml = false) { private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
try { try {
// Hostname auch im CLI-Kontext (Cron) verfuegbar
$heloHost = $_SERVER['HTTP_HOST'] ?? (gethostname() ?: 'localhost');
// Verbindung aufbauen // Verbindung aufbauen
$socket = $this->connectToSmtp(); $socket = $this->connectToSmtp();
// EHLO // EHLO
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); $this->smtpCommand($socket, "EHLO " . $heloHost);
// STARTTLS wenn nötig // STARTTLS wenn nötig
if ($this->smtpEncryption === 'tls') { if ($this->smtpEncryption === 'tls') {
$this->smtpCommand($socket, "STARTTLS"); $this->smtpCommand($socket, "STARTTLS");
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); $this->smtpCommand($socket, "EHLO " . $heloHost);
} }
// AUTH LOGIN // AUTH LOGIN nur wenn Zugangsdaten konfiguriert sind
// (Server ohne Auth lehnen ein leeres AUTH LOGIN sonst ab)
if ($this->smtpUsername !== '') {
$this->smtpCommand($socket, "AUTH LOGIN"); $this->smtpCommand($socket, "AUTH LOGIN");
$this->smtpCommand($socket, base64_encode($this->smtpUsername)); $this->smtpCommand($socket, base64_encode($this->smtpUsername));
$this->smtpCommand($socket, base64_encode($this->smtpPassword)); $this->smtpCommand($socket, base64_encode($this->smtpPassword));
}
// MAIL FROM // MAIL FROM
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>"); $this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
@ -101,11 +109,12 @@ class Mailer {
$message .= "\r\n"; $message .= "\r\n";
// Body - bei Plain Text Zeilenumbrüche konvertieren // Zeilenumbrueche auf CRLF normalisieren (der fruehere
if (!$isHtml) { // nl2br/str_replace-Umweg hat Umbrueche verdoppelt)
$body = nl2br($body, false); // Für Plain Text $body = preg_replace("/\r\n|\r|\n/", "\r\n", $body);
$body = str_replace('<br>', "\r\n", $body); // SMTP-Dot-Stuffing: Zeilen, die mit '.' beginnen, wuerden sonst
} // die DATA-Phase vorzeitig beenden (RFC 5321, 4.5.2)
$body = preg_replace('/^\./m', '..', $body);
$message .= $body; $message .= $body;
$message .= "\r\n.\r\n"; $message .= "\r\n.\r\n";
@ -126,11 +135,12 @@ class Mailer {
} }
private function connectToSmtp() { private function connectToSmtp() {
// Zertifikatspruefung optional aktivierbar (Setting smtp_verify_ssl)
$context = stream_context_create([ $context = stream_context_create([
'ssl' => [ 'ssl' => [
'verify_peer' => false, 'verify_peer' => $this->smtpVerifySsl,
'verify_peer_name' => false, 'verify_peer_name' => $this->smtpVerifySsl,
'allow_self_signed' => true 'allow_self_signed' => !$this->smtpVerifySsl
] ]
]); ]);

View file

@ -10,12 +10,15 @@ class UniFiController {
private $csrfToken = null; private $csrfToken = null;
private $sessionCookie = null; private $sessionCookie = null;
private $loggedIn = false; private $loggedIn = false;
/** SSL-Zertifikat pruefen? Default aus, da UnifFi-Controller meist self-signed sind. */
private $sslVerify = false;
public function __construct($controllerUrl, $username, $password, $siteId) { public function __construct($controllerUrl, $username, $password, $siteId, $sslVerify = false) {
$this->controllerUrl = rtrim($controllerUrl, '/'); $this->controllerUrl = rtrim($controllerUrl, '/');
$this->username = $username; $this->username = $username;
$this->password = $password; $this->password = $password;
$this->siteId = $siteId; $this->siteId = $siteId;
$this->sslVerify = (bool)$sslVerify;
$this->cookieFile = tempnam(sys_get_temp_dir(), 'UNIFI_'); $this->cookieFile = tempnam(sys_get_temp_dir(), 'UNIFI_');
} }
@ -41,7 +44,8 @@ class UniFiController {
'password' => $this->password 'password' => $this->password
]), ]),
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYPEER => $this->sslVerify,
CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0,
CURLOPT_COOKIEJAR => $this->cookieFile, CURLOPT_COOKIEJAR => $this->cookieFile,
CURLOPT_COOKIEFILE => $this->cookieFile, CURLOPT_COOKIEFILE => $this->cookieFile,
CURLOPT_TIMEOUT => 10, CURLOPT_TIMEOUT => 10,
@ -116,7 +120,8 @@ class UniFiController {
$options = [ $options = [
CURLOPT_URL => $url, CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYPEER => $this->sslVerify,
CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0,
CURLOPT_TIMEOUT => 10, CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => $headers CURLOPT_HTTPHEADER => $headers
@ -155,13 +160,31 @@ class UniFiController {
return json_decode($response, true); return json_decode($response, true);
} }
// Voucher erstellen // Einzelnen Voucher erstellen
// $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB] // $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480, $options = []) { public function createVoucher($voucherName, $maxUses, $expireMinutes = 480, $options = []) {
$vouchers = $this->createVouchers($voucherName, $maxUses, $expireMinutes, 1, $options);
return $vouchers[0];
}
/**
* Erstellt $count Voucher in EINEM API-Call (UniFi 'n'-Parameter) statt
* pro Voucher Login + Full-Fetch auszufuehren.
*
* Matching: Die create-voucher-Antwort liefert die create_time der neuen
* Voucher; darueber (plus note) werden exakt die soeben erstellten Codes
* identifiziert. Der fruehere Fallback "global neuester Voucher" konnte
* bei parallelen Erstellungen fremde Codes liefern und wurde entfernt.
*
* @param array $options Optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
* @return array Liste von ['code','formatted_code','unifi_id','create_time']
*/
public function createVouchers($voucherName, $maxUses, $expireMinutes = 480, $count = 1, $options = []) {
$count = max(1, (int)$count);
$data = [ $data = [
'cmd' => 'create-voucher', 'cmd' => 'create-voucher',
'expire' => (int)$expireMinutes, 'expire' => (int)$expireMinutes,
'n' => 1, 'n' => $count,
'note' => $voucherName, 'note' => $voucherName,
'quota' => (int)$maxUses 'quota' => (int)$maxUses
]; ];
@ -182,51 +205,51 @@ class UniFiController {
if (!isset($response['data'][0]['create_time'])) { if (!isset($response['data'][0]['create_time'])) {
throw new Exception("Voucher konnte nicht erstellt werden"); throw new Exception("Voucher konnte nicht erstellt werden");
} }
$createTime = $response['data'][0]['create_time'];
// Voucher-Code abrufen. WICHTIG: getVouchers() liefert die Voucher $all = $this->getVouchers();
// unsortiert zurueck ein blindes reset() kann bei parallelen
// Erstellungen den falschen (fremden) Code liefern. Daher gezielt
// nach dem soeben erstellten Voucher suchen: gleiche note + neueste
// create_time.
$vouchers = $this->getVouchers();
if (empty($vouchers)) { // Exakte Treffer: gleiche note UND die vom Controller gemeldete create_time
throw new Exception("Voucher-Code konnte nicht abgerufen werden"); $matches = [];
foreach ($all as $voucher) {
if (($voucher['note'] ?? null) === $voucherName
&& ($voucher['create_time'] ?? null) == $createTime) {
$matches[] = $voucher;
}
} }
$latestVoucher = null; // Fallback: nur note matchen (falls der Controller create_time leicht
foreach ($vouchers as $voucher) { // abweichend meldet), neueste zuerst, auf $count begrenzen.
// Nur Voucher mit passender Notiz beruecksichtigen if (empty($matches)) {
if (($voucher['note'] ?? null) !== $voucherName) { foreach ($all as $voucher) {
if (($voucher['note'] ?? null) === $voucherName) {
$matches[] = $voucher;
}
}
usort($matches, function ($a, $b) {
return ($b['create_time'] ?? 0) <=> ($a['create_time'] ?? 0);
});
$matches = array_slice($matches, 0, $count);
}
$result = [];
foreach ($matches as $voucher) {
if (empty($voucher['code'])) {
continue; continue;
} }
if ($latestVoucher === null $result[] = [
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) { 'code' => $voucher['code'],
$latestVoucher = $voucher; 'formatted_code' => $this->formatVoucherCode($voucher['code']),
} 'unifi_id' => $voucher['_id'] ?? null,
'create_time' => $voucher['create_time'] ?? null
];
} }
// Fallback: falls keine note-Uebereinstimmung (z.B. Sonderzeichen), if (empty($result)) {
// den global neuesten Voucher nehmen.
if ($latestVoucher === null) {
foreach ($vouchers as $voucher) {
if ($latestVoucher === null
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) {
$latestVoucher = $voucher;
}
}
}
if ($latestVoucher === null || empty($latestVoucher['code'])) {
throw new Exception("Voucher-Code konnte nicht abgerufen werden"); throw new Exception("Voucher-Code konnte nicht abgerufen werden");
} }
return [ return $result;
'code' => $latestVoucher['code'],
'formatted_code' => $this->formatVoucherCode($latestVoucher['code']),
'unifi_id' => $latestVoucher['_id'] ?? null,
'create_time' => $latestVoucher['create_time'] ?? null
];
} }
// Alle Voucher abrufen // Alle Voucher abrufen
@ -320,9 +343,9 @@ class UniFiController {
} }
// Verbindung testen // Verbindung testen
public static function testConnection($controllerUrl, $username, $password, $siteId) { public static function testConnection($controllerUrl, $username, $password, $siteId, $sslVerify = false) {
try { try {
$controller = new self($controllerUrl, $username, $password, $siteId); $controller = new self($controllerUrl, $username, $password, $siteId, $sslVerify);
$controller->login(); $controller->login();
return true; return true;
} catch (Exception $e) { } catch (Exception $e) {
@ -351,17 +374,26 @@ class UniFiController {
// Alle aktuellen UniFi-IDs sammeln // Alle aktuellen UniFi-IDs sammeln
$unifiIds = []; $unifiIds = [];
// Bestehende Voucher der Site einmal als Map laden statt pro Voucher
// ein SELECT auszufuehren (halbiert die Query-Anzahl bei grossen Syncs)
$existingRows = $db->fetchAll(
"SELECT id, unifi_voucher_id FROM vouchers WHERE site_id = ? AND unifi_voucher_id IS NOT NULL",
[$dbSiteId]
);
$existingMap = [];
foreach ($existingRows as $row) {
$existingMap[$row['unifi_voucher_id']] = $row['id'];
}
foreach ($vouchers as $voucher) { foreach ($vouchers as $voucher) {
$unifiIds[] = $voucher['_id']; $unifiIds[] = $voucher['_id'];
// Status zählen // Status zählen
$stats[$voucher['status']]++; $stats[$voucher['status']]++;
// Prüfen ob Voucher bereits existiert $existing = isset($existingMap[$voucher['_id']])
$existing = $db->fetchOne( ? ['id' => $existingMap[$voucher['_id']]]
"SELECT id, status, used_count FROM vouchers WHERE unifi_voucher_id = ? AND site_id = ?", : null;
[$voucher['_id'], $dbSiteId]
);
$expiresAt = date('Y-m-d H:i:s', $voucher['expire_time']); $expiresAt = date('Y-m-d H:i:s', $voucher['expire_time']);
$createdAt = date('Y-m-d H:i:s', $voucher['create_time']); $createdAt = date('Y-m-d H:i:s', $voucher['create_time']);

221
index.php
View file

@ -20,6 +20,7 @@ require_once __DIR__ . '/includes/Notifier.php';
require_once __DIR__ . '/includes/Captcha.php'; require_once __DIR__ . '/includes/Captcha.php';
require_once __DIR__ . '/includes/Sms.php'; require_once __DIR__ . '/includes/Sms.php';
require_once __DIR__ . '/includes/I18n.php'; require_once __DIR__ . '/includes/I18n.php';
require_once __DIR__ . '/includes/Helpers.php';
$auth = new Auth(); $auth = new Auth();
$db = Database::getInstance(); $db = Database::getInstance();
@ -27,27 +28,68 @@ $mailer = new Mailer();
I18n::init(); I18n::init();
/** /**
* Session-basierter Throttle fuer die anonyme oeffentliche Voucher-Erstellung. * Throttle fuer die anonyme oeffentliche Voucher-Erstellung:
* Erlaubt max. 10 Erstellungen in 10 Minuten pro Session. Verhindert, dass * max. 10 Voucher in 10 Minuten. Primaer IP-basiert ueber die Tabelle
* der oeffentliche Modus zum Spammen des UniFi-Controllers missbraucht wird. * request_throttle (laesst sich nicht per Cookie-Loeschen umgehen);
* Fallback auf den Session-Zaehler, falls die Tabelle auf einer alten
* Installation noch fehlt (Migration 0002 nicht gelaufen).
*/ */
function isVoucherRateLimited() { function isVoucherRateLimited($db, $voucherCount = 1) {
$window = 600; // 10 Minuten $window = 600; // 10 Minuten
$maxRequests = 10; $maxVouchers = 10;
$limited = throttleHit($db, 'voucher_create', $maxVouchers, 10, $voucherCount);
if ($limited !== null) {
return $limited;
}
// Tabelle existiert noch nicht (Migration 0002 nicht gelaufen)
// -> Session-Fallback (Legacy-Verhalten)
$now = time(); $now = time();
$timestamps = $_SESSION['voucher_create_times'] ?? []; $timestamps = $_SESSION['voucher_create_times'] ?? [];
$timestamps = array_values(array_filter($timestamps, function ($t) use ($now, $window) { $timestamps = array_values(array_filter($timestamps, function ($t) use ($now, $window) {
return ($now - $t) < $window; return ($now - $t) < $window;
})); }));
if (count($timestamps) >= $maxRequests) { if (count($timestamps) + $voucherCount > $maxVouchers) {
$_SESSION['voucher_create_times'] = $timestamps; $_SESSION['voucher_create_times'] = $timestamps;
return true; return true;
} }
for ($i = 0; $i < $voucherCount; $i++) {
$timestamps[] = $now; $timestamps[] = $now;
}
$_SESSION['voucher_create_times'] = $timestamps; $_SESSION['voucher_create_times'] = $timestamps;
return false; return false;
} }
/**
* Validiert die Voucher-Gueltigkeit (Minuten). Anonyme Nutzer duerfen nur den
* konfigurierten Default oder Werte aktiver Templates verwenden das Feld ist
* ein Hidden-Input und damit beliebig manipulierbar. Eingeloggte Nutzer werden
* auf maximal 1 Jahr begrenzt.
*/
function sanitizeExpireMinutes($expireMinutes, $isLoggedIn, $templates, $defaultExpire) {
$expireMinutes = (int)$expireMinutes;
if ($isLoggedIn) {
return max(1, min(525600, $expireMinutes));
}
$allowed = array_map(function ($t) { return (int)$t['expire_minutes']; }, $templates);
$allowed[] = $defaultExpire;
return in_array($expireMinutes, $allowed, true) ? $expireMinutes : $defaultExpire;
}
/** Minuten menschenlesbar formatieren (z.B. 480 -> "8 Stunden"). */
function formatDuration($minutes) {
$minutes = (int)$minutes;
if ($minutes >= 1440 && $minutes % 1440 === 0) {
$days = $minutes / 1440;
return $days === 1 ? __('dur_day_one') : __('dur_days', ['n' => $days]);
}
if ($minutes >= 60 && $minutes % 60 === 0) {
$hours = $minutes / 60;
return $hours === 1 ? __('dur_hour_one') : __('dur_hours', ['n' => $hours]);
}
return __('dur_minutes', ['n' => $minutes]);
}
/** /**
* Optionales Tageslimit pro (Nicht-Admin-)Benutzer (Setting * Optionales Tageslimit pro (Nicht-Admin-)Benutzer (Setting
* user_daily_voucher_limit, 0 = aus). Verhindert übermäßige Erstellung. * user_daily_voucher_limit, 0 = aus). Verhindert übermäßige Erstellung.
@ -70,6 +112,7 @@ function userDailyLimitExceeded($db, $auth, $additional = 1) {
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', ''); $logoUrl = $db->getSetting('logo_url', '');
$faviconUrl = $db->getSetting('favicon_url', '');
$instructionHeader = $db->getSetting('instruction_header', ''); $instructionHeader = $db->getSetting('instruction_header', '');
$instructionText = $db->getSetting('instruction_text', ''); $instructionText = $db->getSetting('instruction_text', '');
$publicAccess = $db->getSetting('public_access', 0); $publicAccess = $db->getSetting('public_access', 0);
@ -122,7 +165,8 @@ function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $us
$site['unifi_controller_url'], $site['unifi_controller_url'],
$site['unifi_username'], $site['unifi_username'],
Crypto::decrypt($site['unifi_password']), Crypto::decrypt($site['unifi_password']),
$site['site_id'] $site['site_id'],
$site['ssl_verify'] ?? 0
); );
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos); $voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
if (!is_array($voucher) || empty($voucher['formatted_code'])) { if (!is_array($voucher) || empty($voucher['formatted_code'])) {
@ -151,8 +195,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
} elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { } elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
// CSRF fuer ALLE (auch anonyme oeffentliche Erstellung) // CSRF fuer ALLE (auch anonyme oeffentliche Erstellung)
$error = __('error_csrf'); $error = __('error_csrf');
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) { } elseif (!$auth->isLoggedIn() && isVoucherRateLimited($db)) {
$error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.'; $error = __('error_rate_limited');
} elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) { } elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) {
$error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.'; $error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.';
} else { } else {
@ -160,7 +204,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
$siteId = (int)($_POST['site_id'] ?? 0); $siteId = (int)($_POST['site_id'] ?? 0);
$voucherName = trim((string)($_POST['voucher_name'] ?? '')); $voucherName = trim((string)($_POST['voucher_name'] ?? ''));
$maxUses = (int)($_POST['max_uses'] ?? $defaultMaxUses); $maxUses = (int)($_POST['max_uses'] ?? $defaultMaxUses);
$expireMinutes = max(1, (int)($_POST['expire_minutes'] ?? $defaultExpire)); $expireMinutes = sanitizeExpireMinutes($_POST['expire_minutes'] ?? $defaultExpire, $auth->isLoggedIn(), $templates, $defaultExpire);
$sendEmail = isset($_POST['send_email']) && !empty($_POST['recipient_email']); $sendEmail = isset($_POST['send_email']) && !empty($_POST['recipient_email']);
$recipientEmail= trim((string)($_POST['recipient_email'] ?? '')); $recipientEmail= trim((string)($_POST['recipient_email'] ?? ''));
@ -186,10 +230,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
$voucherCreated = true; $voucherCreated = true;
Notifier::voucherCreated(1, $site['name'], $_SESSION['user_name'] ?? null); Notifier::voucherCreated(1, $site['name'], $_SESSION['user_name'] ?? null);
$success = 'Voucher erfolgreich erstellt!';
if ($sendEmail && !empty($recipientEmail)) { if ($sendEmail && !empty($recipientEmail)) {
$mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses); $mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses);
$success .= ' E-Mail versendet.'; $success = __('voucher_created_mail');
} else {
$success = __('voucher_created_ok');
} }
// Optional: Code per SMS (Twilio) // Optional: Code per SMS (Twilio)
$recipientPhone = trim((string)($_POST['recipient_phone'] ?? '')); $recipientPhone = trim((string)($_POST['recipient_phone'] ?? ''));
@ -197,62 +242,128 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
$smsText = ($appTitle ? $appTitle . ': ' : '') . 'WLAN-Code ' . $voucherCode; $smsText = ($appTitle ? $appTitle . ': ' : '') . 'WLAN-Code ' . $voucherCode;
$success .= Sms::send($db, $recipientPhone, $smsText) ? ' SMS versendet.' : ' (SMS fehlgeschlagen)'; $success .= Sms::send($db, $recipientPhone, $smsText) ? ' SMS versendet.' : ' (SMS fehlgeschlagen)';
} }
$auth->writeAuditLog($userId, 'voucher_create', 'voucher', null,
"Voucher '{$voucherName}' für {$site['name']}" . ($userId === null ? ' (öffentlich)' : ''));
// PRG-Pattern: Redirect nach erfolgreichem POST, damit ein Reload
// (F5) keinen Duplikat-Voucher erzeugt. Ergebnis via Session-Flash.
$_SESSION['voucher_flash'] = ['type' => 'single', 'data' => $voucherData, 'success' => $success];
header('Location: index.php?created=1');
exit;
} catch (Exception $e) { } catch (Exception $e) {
$error = 'Fehler: ' . $e->getMessage(); $error = 'Fehler: ' . $e->getMessage();
} }
} }
} }
// Bulk voucher creation // Bulk voucher creation nur fuer eingeloggte Nutzer. Das Formular wird
// Anonymen zwar nicht angezeigt, der POST-Endpunkt muss es aber ebenfalls
// serverseitig erzwingen (sonst 20 Voucher pro Request im Public-Modus).
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
if (!$publicAccess && !$auth->isLoggedIn()) { if (!$auth->isLoggedIn()) {
$error = __('error_login_req'); $error = __('error_login_req');
} elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { } elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
// CSRF fuer ALLE (auch anonyme oeffentliche Erstellung)
$error = __('error_csrf'); $error = __('error_csrf');
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) {
$error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.';
} elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) {
$error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.';
} else { } else {
try { try {
$siteId = (int)($_POST['site_id'] ?? 0); $siteId = (int)($_POST['site_id'] ?? 0);
$voucherName = trim((string)($_POST['voucher_name'] ?? '')); $voucherName = trim((string)($_POST['voucher_name'] ?? ''));
$maxUses = (int)($_POST['max_uses'] ?? $defaultMaxUses); $maxUses = (int)($_POST['max_uses'] ?? $defaultMaxUses);
$expireMinutes = max(1, (int)($_POST['expire_minutes'] ?? $defaultExpire)); $expireMinutes = sanitizeExpireMinutes($_POST['expire_minutes'] ?? $defaultExpire, true, $templates, $defaultExpire);
$bulkCount = max(1, min(20, (int)($_POST['bulk_count'] ?? 1))); $bulkCount = max(1, min(20, (int)($_POST['bulk_count'] ?? 1)));
if (empty($voucherName)) throw new Exception(__('error_name_req')); if (empty($voucherName)) throw new Exception(__('error_name_req'));
if ($maxUses < 1 || $maxUses > $maxUsesLimit) throw new Exception(__('error_devices_range', ['max' => $maxUsesLimit])); if ($maxUses < 1 || $maxUses > $maxUsesLimit) throw new Exception(__('error_devices_range', ['max' => $maxUsesLimit]));
if ($siteId <= 0) throw new Exception(__('error_site_req')); if ($siteId <= 0) throw new Exception(__('error_site_req'));
if ($auth->isLoggedIn() && !$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm')); if (!$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
if (userDailyLimitExceeded($db, $auth, $bulkCount)) throw new Exception('Tageslimit für Voucher erreicht.'); if (userDailyLimitExceeded($db, $auth, $bulkCount)) throw new Exception('Tageslimit für Voucher erreicht.');
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]); $site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
if (!$site) throw new Exception(__('error_site_not_found')); if (!$site) throw new Exception(__('error_site_not_found'));
$userId = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : null; $userId = $_SESSION['user_id'] ?? null;
$qos = [ $qos = [
'down' => max(0, (int)($_POST['qos_down'] ?? 0)), 'down' => max(0, (int)($_POST['qos_down'] ?? 0)),
'up' => max(0, (int)($_POST['qos_up'] ?? 0)), 'up' => max(0, (int)($_POST['qos_up'] ?? 0)),
'quota_mb' => max(0, (int)($_POST['qos_quota'] ?? 0)), 'quota_mb' => max(0, (int)($_POST['qos_quota'] ?? 0)),
]; ];
for ($i = 0; $i < $bulkCount; $i++) { // Alle Voucher in EINEM UniFi-API-Call erstellen ('n'-Parameter)
$bulkVouchers[] = doCreateVoucher($db, $site, $voucherName . '_' . ($i + 1), $maxUses, $expireMinutes, $userId, $qos); // statt pro Voucher Login + Voucherliste abzurufen.
$fullName = date('Y-m-d') . '_' . $voucherName;
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
Crypto::decrypt($site['unifi_password']),
$site['site_id'],
$site['ssl_verify'] ?? 0
);
$created = $controller->createVouchers($fullName, $maxUses, $expireMinutes, $bulkCount, $qos);
$expiryTs = time() + ($expireMinutes * 60);
foreach ($created as $i => $voucher) {
$db->execute(
"INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
VALUES (?, ?, ?, ?, ?, ?, ?)",
[$site['id'], $userId, $voucher['code'], $fullName . '_' . ($i + 1), $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
);
$bulkVouchers[] = [
'code' => $voucher['formatted_code'],
'site_name' => $site['name'],
'max_uses' => $maxUses,
'expire_min' => $expireMinutes,
'expiry_date' => date('d.m.Y', $expiryTs),
'expiry_time' => date('H:i', $expiryTs),
];
} }
Notifier::voucherCreated($bulkCount, $site['name'], $_SESSION['user_name'] ?? null); Notifier::voucherCreated(count($created), $site['name'], $_SESSION['user_name'] ?? null);
$bulkCreated = true; $auth->writeAuditLog($userId, 'voucher_bulk', 'voucher', null,
$success = str_replace('{count}', $bulkCount, __('bulk_success')); count($created) . " Vouchers '{$voucherName}' für {$site['name']}");
$success = str_replace('{count}', count($created), __('bulk_success'));
// PRG-Pattern: Reload darf die Bulk-Erstellung nicht wiederholen.
$_SESSION['voucher_flash'] = ['type' => 'bulk', 'data' => $bulkVouchers, 'success' => $success];
header('Location: index.php?created=1');
exit;
} catch (Exception $e) { } catch (Exception $e) {
$error = 'Fehler: ' . $e->getMessage(); $error = 'Fehler: ' . $e->getMessage();
} }
} }
} }
// PRG: Ergebnis nach Redirect aus dem Session-Flash wiederherstellen.
// Der Flash bleibt fuer Reloads der Ergebnisseite erhalten und wird beim
// Zurueckkehren zum Formular (GET ohne ?created) verworfen.
if (isset($_GET['created']) && !empty($_SESSION['voucher_flash'])) {
$flash = $_SESSION['voucher_flash'];
if (($flash['type'] ?? '') === 'bulk') {
$bulkVouchers = $flash['data'];
$bulkCreated = true;
} else {
$voucherData = $flash['data'];
$voucherCode = $voucherData['code'];
$voucherCreated = true;
}
$success = $flash['success'] ?? '';
} elseif ($_SERVER['REQUEST_METHOD'] !== 'POST') {
unset($_SESSION['voucher_flash']);
}
$currentUser = $auth->isLoggedIn() ? $auth->getCurrentUser() : null; $currentUser = $auth->isLoggedIn() ? $auth->getCurrentUser() : null;
// Bei Validierungsfehlern: eingegebene Werte und aktiven Tab erhalten
$activeMode = ($error && isset($_POST['create_bulk'])) ? 'bulk' : 'single';
$stickyName = $error ? trim((string)($_POST['voucher_name'] ?? '')) : '';
$stickyMaxUses = $error ? (int)($_POST['max_uses'] ?? $defaultMaxUses) : $defaultMaxUses;
$stickyBulkCount = $error ? max(1, min(20, (int)($_POST['bulk_count'] ?? 5))) : 5;
$stickySiteId = $error ? (int)($_POST['site_id'] ?? 0) : 0;
if ($stickyMaxUses < 1 || $stickyMaxUses > $maxUsesLimit) $stickyMaxUses = $defaultMaxUses;
// Anonyme Gaeste wissen oft nicht, was sie als Namen eintragen sollen -> Default
if ($stickyName === '' && !$auth->isLoggedIn()) $stickyName = __('voucher_name_default');
// Captcha nur für anonyme öffentliche Erstellung // Captcha nur für anonyme öffentliche Erstellung
$captchaMode = !$auth->isLoggedIn() ? Captcha::mode($db) : 'off'; $captchaMode = !$auth->isLoggedIn() ? Captcha::mode($db) : 'off';
$captchaQuestion = $captchaMode === 'math' ? Captcha::newMathChallenge() : ''; $captchaQuestion = $captchaMode === 'math' ? Captcha::newMathChallenge() : '';
@ -284,6 +395,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<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 ($faviconUrl): ?>
<link rel="icon" href="<?= htmlspecialchars($faviconUrl) ?>">
<?php endif; ?>
<link rel="stylesheet" href="assets/global.css"> <link rel="stylesheet" href="assets/global.css">
<?php if ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== ''): ?> <?php if ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== ''): ?>
<script src="https://js.hcaptcha.com/1/api.js" async defer></script> <script src="https://js.hcaptcha.com/1/api.js" async defer></script>
@ -303,9 +417,6 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
.container { max-width: 600px; margin: 0 auto; background: var(--bg-card); border-radius: 20px; box-shadow: 0 20px 60px var(--shadow-lg); padding: 40px; } .container { max-width: 600px; margin: 0 auto; background: var(--bg-card); border-radius: 20px; box-shadow: 0 20px 60px var(--shadow-lg); padding: 40px; }
h1 { text-align: center; color: var(--text-primary); margin-bottom: 30px; font-size: 28px; } h1 { text-align: center; color: var(--text-primary); margin-bottom: 30px; font-size: 28px; }
.logo { max-width: 250px; display: block; margin: 0 auto 30px; } .logo { max-width: 250px; display: block; margin: 0 auto 30px; }
.alert { padding: 14px; border-radius: 10px; margin-bottom: 25px; font-size: 14px; }
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
.form-group { margin-bottom: 20px; } .form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 8px; color: var(--text-secondary); font-weight: 500; font-size: 14px; } label { display: block; margin-bottom: 8px; color: var(--text-secondary); font-weight: 500; font-size: 14px; }
input[type="text"], input[type="number"], input[type="email"], select { width: 100%; padding: 14px; border: 2px solid var(--border-color); border-radius: 10px; font-size: 15px; transition: all 0.2s; background: var(--bg-input); color: var(--text-primary); } input[type="text"], input[type="number"], input[type="email"], select { width: 100%; padding: 14px; border: 2px solid var(--border-color); border-radius: 10px; font-size: 15px; transition: all 0.2s; background: var(--bg-input); color: var(--text-primary); }
@ -411,11 +522,11 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<div class="voucher-result no-print"> <div class="voucher-result no-print">
<div style="font-size:18px;margin-bottom:10px;"><?= __('voucher_success_title') ?></div> <div style="font-size:18px;margin-bottom:10px;"><?= __('voucher_success_title') ?></div>
<div class="voucher-code" id="voucherCode" onclick="copyCode()" title="Klicken zum Kopieren"> <div class="voucher-code" id="voucherCode" onclick="copyCode()" title="<?= __('click_to_copy') ?>">
<?= htmlspecialchars($voucherCode) ?> <?= htmlspecialchars($voucherCode) ?>
</div> </div>
<div class="voucher-info"> <div class="voucher-info">
<?= str_replace('{minutes}', $voucherData['expire_min'], __('voucher_validity')) ?> <?= str_replace('{duration}', formatDuration($voucherData['expire_min']), __('voucher_validity')) ?>
</div> </div>
<div class="qr-wrapper no-print"> <div class="qr-wrapper no-print">
<div id="qrcode"></div> <div id="qrcode"></div>
@ -463,8 +574,8 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<tr> <tr>
<td><?= $i + 1 ?></td> <td><?= $i + 1 ?></td>
<td> <td>
<code onclick="copyToClipboard('<?= addslashes($bv['code']) ?>', 'Kopiert!')" <code onclick="copyToClipboard('<?= addslashes($bv['code']) ?>', '<?= addslashes(__('toast_copied')) ?>')"
title="Klicken zum Kopieren"><?= htmlspecialchars($bv['code']) ?></code> title="<?= __('click_to_copy') ?>"><?= htmlspecialchars($bv['code']) ?></code>
</td> </td>
<td><?= htmlspecialchars($bv['site_name']) ?></td> <td><?= htmlspecialchars($bv['site_name']) ?></td>
<td><?= $bv['expiry_date'] ?> <?= $bv['expiry_time'] ?></td> <td><?= $bv['expiry_date'] ?> <?= $bv['expiry_time'] ?></td>
@ -472,7 +583,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
<p class="copy-hint" style="margin-top:8px;">Code anklicken zum Kopieren</p> <p class="copy-hint" style="margin-top:8px;"><?= __('copy_hint') ?></p>
</div> </div>
<div class="no-print" style="display:flex;gap:10px;margin-top:20px;"> <div class="no-print" style="display:flex;gap:10px;margin-top:20px;">
@ -542,13 +653,14 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<div class="form-group"> <div class="form-group">
<label for="voucher_name"><?= __('voucher_name_label') ?></label> <label for="voucher_name"><?= __('voucher_name_label') ?></label>
<input type="text" id="voucher_name" name="voucher_name" <input type="text" id="voucher_name" name="voucher_name"
value="<?= htmlspecialchars($stickyName) ?>"
placeholder="<?= __('voucher_name_hint') ?>" required> placeholder="<?= __('voucher_name_hint') ?>" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="max_uses"><?= __('voucher_devices_label') ?></label> <label for="max_uses"><?= __('voucher_devices_label') ?></label>
<input type="number" id="max_uses" name="max_uses" <input type="number" id="max_uses" name="max_uses"
min="1" max="<?= $maxUsesLimit ?>" value="<?= $defaultMaxUses ?>" required> min="1" max="<?= $maxUsesLimit ?>" value="<?= $stickyMaxUses ?>" required>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -558,7 +670,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<option value=""><?= __('voucher_site_select') ?></option> <option value=""><?= __('voucher_site_select') ?></option>
<?php endif; ?> <?php endif; ?>
<?php foreach ($sites as $site): ?> <?php foreach ($sites as $site): ?>
<option value="<?= (int)$site['id'] ?>" <?= ($autoSelectSite == $site['id']) ? 'selected' : '' ?>> <option value="<?= (int)$site['id'] ?>" <?= (($stickySiteId ?: $autoSelectSite) == $site['id']) ? 'selected' : '' ?>>
<?= htmlspecialchars($site['name']) ?> <?= htmlspecialchars($site['name']) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
@ -602,7 +714,8 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
</form> </form>
</div> </div>
<!-- Bulk creation form --> <!-- Bulk creation form (nur fuer eingeloggte Nutzer, serverseitig erzwungen) -->
<?php if ($auth->isLoggedIn()): ?>
<div id="mode-bulk" style="display:none;"> <div id="mode-bulk" style="display:none;">
<form method="post" id="bulkForm"> <form method="post" id="bulkForm">
<input type="hidden" name="create_bulk" value="1"> <input type="hidden" name="create_bulk" value="1">
@ -616,20 +729,21 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<div class="form-group"> <div class="form-group">
<label for="bulk_count"><?= __('bulk_quantity') ?></label> <label for="bulk_count"><?= __('bulk_quantity') ?></label>
<input type="number" id="bulk_count" name="bulk_count" <input type="number" id="bulk_count" name="bulk_count"
min="1" max="20" value="5" required> min="1" max="20" value="<?= $stickyBulkCount ?>" required>
<p style="font-size:12px;color:var(--text-muted);margin-top:5px;"><?= __('bulk_quantity_hint') ?></p> <p style="font-size:12px;color:var(--text-muted);margin-top:5px;"><?= __('bulk_quantity_hint') ?></p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="bulk_voucher_name"><?= __('bulk_name_prefix') ?></label> <label for="bulk_voucher_name"><?= __('bulk_name_prefix') ?></label>
<input type="text" id="bulk_voucher_name" name="voucher_name" <input type="text" id="bulk_voucher_name" name="voucher_name"
value="<?= $activeMode === 'bulk' ? htmlspecialchars($stickyName) : '' ?>"
placeholder="<?= __('voucher_name_hint') ?>" required> placeholder="<?= __('voucher_name_hint') ?>" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="bulk_max_uses"><?= __('voucher_devices_label') ?></label> <label for="bulk_max_uses"><?= __('voucher_devices_label') ?></label>
<input type="number" id="bulk_max_uses" name="max_uses" <input type="number" id="bulk_max_uses" name="max_uses"
min="1" max="<?= $maxUsesLimit ?>" value="<?= $defaultMaxUses ?>" required> min="1" max="<?= $maxUsesLimit ?>" value="<?= $stickyMaxUses ?>" required>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -639,7 +753,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<option value=""><?= __('voucher_site_select') ?></option> <option value=""><?= __('voucher_site_select') ?></option>
<?php endif; ?> <?php endif; ?>
<?php foreach ($sites as $site): ?> <?php foreach ($sites as $site): ?>
<option value="<?= (int)$site['id'] ?>" <?= ($autoSelectSite == $site['id']) ? 'selected' : '' ?>> <option value="<?= (int)$site['id'] ?>" <?= (($stickySiteId ?: $autoSelectSite) == $site['id']) ? 'selected' : '' ?>>
<?= htmlspecialchars($site['name']) ?> <?= htmlspecialchars($site['name']) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
@ -647,10 +761,11 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
</div> </div>
<button type="submit" class="btn" id="bulkSubmitBtn"> <button type="submit" class="btn" id="bulkSubmitBtn">
<?= str_replace('{count}', '<span id="bulkCountLabel">5</span>', __('bulk_create_btn')) ?> <?= str_replace('{count}', '<span id="bulkCountLabel">' . $stickyBulkCount . '</span>', __('bulk_create_btn')) ?>
</button> </button>
</form> </form>
</div> </div>
<?php endif; ?>
<?php if ($instructionHeader || $instructionText): ?> <?php if ($instructionHeader || $instructionText): ?>
<div class="instruction-box" style="margin-top:25px;"> <div class="instruction-box" style="margin-top:25px;">
@ -667,16 +782,18 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<script> <script>
<?php if ($voucherCreated): ?> <?php if ($voucherCreated): ?>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Dunkle Module auf weissem Grund: invertierte QR-Codes (hell auf
// dunkel) werden von vielen Kamera-Apps nicht erkannt.
new QRCode(document.getElementById('qrcode'), { new QRCode(document.getElementById('qrcode'), {
text: '<?= addslashes($voucherCode) ?>', text: '<?= addslashes($voucherCode) ?>',
width: 160, height: 160, width: 160, height: 160,
colorDark: '#ffffff', colorLight: 'transparent', colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M correctLevel: QRCode.CorrectLevel.M
}); });
}); });
function copyCode() { function copyCode() {
copyToClipboard('<?= addslashes($voucherCode) ?>', 'Code kopiert!'); copyToClipboard('<?= addslashes($voucherCode) ?>', '<?= addslashes(__('toast_copied')) ?>');
} }
<?php endif; ?> <?php endif; ?>
@ -699,8 +816,11 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
} }
function switchMode(mode) { function switchMode(mode) {
document.getElementById('mode-single').style.display = mode === 'single' ? '' : 'none'; const single = document.getElementById('mode-single');
document.getElementById('mode-bulk').style.display = mode === 'bulk' ? '' : 'none'; const bulk = document.getElementById('mode-bulk');
if (!single || !bulk) return; // Bulk existiert nur fuer eingeloggte Nutzer
single.style.display = mode === 'single' ? '' : 'none';
bulk.style.display = mode === 'bulk' ? '' : 'none';
document.getElementById('tab-single').classList.toggle('active', mode === 'single'); document.getElementById('tab-single').classList.toggle('active', mode === 'single');
document.getElementById('tab-bulk').classList.toggle('active', mode === 'bulk'); document.getElementById('tab-bulk').classList.toggle('active', mode === 'bulk');
} }
@ -710,8 +830,10 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
const expire = opt.value ? parseInt(opt.dataset.expire) : <?= $defaultExpire ?>; const expire = opt.value ? parseInt(opt.dataset.expire) : <?= $defaultExpire ?>;
const maxUses = opt.value ? parseInt(opt.dataset.maxUses) : <?= $defaultMaxUses ?>; const maxUses = opt.value ? parseInt(opt.dataset.maxUses) : <?= $defaultMaxUses ?>;
document.getElementById('expire_minutes').value = expire; const expEl = document.getElementById('expire_minutes');
document.getElementById('bulk_expire_minutes').value = expire; if (expEl) expEl.value = expire;
const bexpEl = document.getElementById('bulk_expire_minutes');
if (bexpEl) bexpEl.value = expire;
const muEl = document.getElementById('max_uses'); const muEl = document.getElementById('max_uses');
if (muEl) muEl.value = maxUses; if (muEl) muEl.value = maxUses;
const bmuEl = document.getElementById('bulk_max_uses'); const bmuEl = document.getElementById('bulk_max_uses');
@ -755,6 +877,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
toggleEmailField?.(); toggleEmailField?.();
<?php if ($activeMode === 'bulk'): ?>
switchMode('bulk'); // Nach Fehler im Bulk-Formular im Bulk-Tab bleiben
<?php endif; ?>
}); });
</script> </script>
</body> </body>

View file

@ -159,11 +159,12 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
file_put_contents(__DIR__ . '/config.php', $configContent); file_put_contents(__DIR__ . '/config.php', $configContent);
// .htaccess erstellen (ohne Rewrite Rules die Probleme machen) // .htaccess erstellen (ohne Rewrite Rules die Probleme machen)
// "Require all denied" = Apache 2.4-Syntax; das alte "Order Allow,Deny"
// (2.2) fuehrt auf 2.4 ohne mod_access_compat zu einem 500er.
$htaccess = "# UniFi Voucher System\n\n"; $htaccess = "# UniFi Voucher System\n\n";
$htaccess .= "# Security\n"; $htaccess .= "# Security\n";
$htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|m365_debug\\.php|\\.md)$\">\n"; $htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|m365_debug\\.php|cron_test\\.php|\\.md)$\">\n";
$htaccess .= " Order Allow,Deny\n"; $htaccess .= " Require all denied\n";
$htaccess .= " Deny from all\n";
$htaccess .= "</FilesMatch>\n\n"; $htaccess .= "</FilesMatch>\n\n";
$htaccess .= "DirectoryIndex index.php\n"; $htaccess .= "DirectoryIndex index.php\n";
file_put_contents(__DIR__ . '/.htaccess', $htaccess); file_put_contents(__DIR__ . '/.htaccess', $htaccess);

View file

@ -99,12 +99,12 @@ return [
'voucher_create_btn' => 'Voucher erstellen', 'voucher_create_btn' => 'Voucher erstellen',
'voucher_creating' => 'Erstelle Voucher...', 'voucher_creating' => 'Erstelle Voucher...',
'voucher_success_title' => '✓ Ihr Zugangs-Code', 'voucher_success_title' => '✓ Ihr Zugangs-Code',
'voucher_validity' => 'Gültig für {minutes} Minuten ab Erstellung', 'voucher_validity' => 'Gültig für {duration} ab Erstellung',
'voucher_qr_label' => 'QR-Code scannen zum Verbinden', 'voucher_qr_label' => 'QR-Code scannen zum Verbinden',
'voucher_print_btn' => 'Code ausdrucken', 'voucher_print_btn' => 'Code ausdrucken',
'voucher_no_sites' => 'Keine verfügbaren Sites gefunden.', 'voucher_no_sites' => 'Keine verfügbaren Sites gefunden.',
'voucher_no_sites_admin'=> 'Klicken Sie hier, um Sites anzulegen', 'voucher_no_sites_admin'=> 'Klicken Sie hier, um Sites anzulegen',
'voucher_no_sites_user' => 'Bitte kontaktieren Sie Ihren Administrator.', 'voucher_no_sites_user' => 'Ihr Konto hat noch keinen Site-Zugriff. Bitte kontaktieren Sie Ihren Administrator, um Berechtigungen zu erhalten.',
'voucher_template_select'=> '-- Kein Profil (manuell) --', 'voucher_template_select'=> '-- Kein Profil (manuell) --',
'voucher_template_label'=> 'Schnellprofil (optional)', 'voucher_template_label'=> 'Schnellprofil (optional)',
@ -292,4 +292,64 @@ return [
'never' => 'Noch nie', 'never' => 'Noch nie',
'unknown' => 'Unbekannt', 'unknown' => 'Unbekannt',
'or' => 'oder', 'or' => 'oder',
// Durations (human readable)
'dur_minutes' => '{n} Minuten',
'dur_hour_one' => '1 Stunde',
'dur_hours' => '{n} Stunden',
'dur_day_one' => '1 Tag',
'dur_days' => '{n} Tage',
// Voucher creation (messages)
'voucher_created_ok' => 'Voucher erfolgreich erstellt!',
'voucher_created_mail' => 'Voucher erstellt. E-Mail versendet.',
'voucher_name_default' => 'Gast',
'voucher_deleted' => 'Voucher erfolgreich gelöscht!',
'voucher_delete_failed'=> 'Voucher konnte nicht gelöscht werden',
'error_rate_limited' => 'Zu viele Anfragen. Bitte warten Sie einen Moment.',
// Clipboard / UI
'toast_copied' => 'Kopiert!',
'click_to_copy' => 'Klicken zum Kopieren',
'copy_hint' => 'Code anklicken zum Kopieren',
'loading' => 'Lade…',
'syncing' => 'Synchronisiere…',
'vouchers_loaded' => '{count} Vouchers geladen',
// Confirm dialogs
'confirm_delete_user' => 'Benutzer wirklich löschen?',
'confirm_delete_site' => 'Möchten Sie diese Site wirklich löschen?',
'confirm_delete_template' => 'Profil wirklich löschen?',
'confirm_delete_voucher' => 'Voucher wirklich löschen?',
'confirm_send_reset' => 'Passwort-Reset-Link senden an {email}?',
'confirm_delete_token' => 'Token wirklich löschen?',
// Admin messages
'users_status_updated' => 'Benutzer-Status aktualisiert!',
'sites_status_updated' => 'Site-Status aktualisiert!',
'error_self_delete' => 'Sie können sich nicht selbst löschen',
'error_self_deactivate' => 'Sie können sich nicht selbst deaktivieren',
'error_self_demote' => 'Sie können sich nicht selbst die Administrator-Rechte entziehen',
'error_pw_mismatch' => 'Passwörter stimmen nicht überein',
'error_pw_current' => 'Aktuelles Passwort ist falsch',
'error_email_exists' => 'E-Mail bereits vorhanden',
'error_user_create' => 'Benutzer konnte nicht erstellt werden',
'reset_link_sent' => 'Passwort-Reset-Link wurde an {email} gesendet.',
'reset_link_failed' => 'Benutzer nicht gefunden oder kein lokales Passwort.',
'cron_token_generated' => 'Neuer Cron-Token wurde generiert!',
'cron_token_deleted' => 'Cron-Token wurde gelöscht!',
'm365_secret_hint' => 'Leer lassen = nicht ändern. Zum Deaktivieren des M365-Logins die Client ID leeren.',
// SSL-Verifizierung
'sites_ssl_verify' => 'SSL-Zertifikat des Controllers prüfen',
'sites_ssl_verify_hint' => 'Nur aktivieren, wenn der Controller ein gültiges Zertifikat besitzt (UniFi nutzt standardmäßig self-signed).',
'smtp_verify_ssl' => 'SSL-Zertifikat des SMTP-Servers prüfen',
// Site connection test
'site_test_btn' => 'Verbindung testen',
'site_test_ok' => 'Verbindung erfolgreich',
'site_test_fail' => 'Verbindung fehlgeschlagen',
// System warnings
'crypto_warning' => 'Verschlüsselung inaktiv: In der config.php ist kein gültiger APP_KEY gesetzt. UniFi-Passwörter werden im Klartext gespeichert. Bei einem Serverumzug mit verändertem APP_KEY schlagen Controller-Logins still fehl.',
]; ];

View file

@ -99,12 +99,12 @@ return [
'voucher_create_btn' => 'Create Voucher', 'voucher_create_btn' => 'Create Voucher',
'voucher_creating' => 'Creating Voucher...', 'voucher_creating' => 'Creating Voucher...',
'voucher_success_title' => '✓ Your Access Code', 'voucher_success_title' => '✓ Your Access Code',
'voucher_validity' => 'Valid for {minutes} minutes from creation', 'voucher_validity' => 'Valid for {duration} from creation',
'voucher_qr_label' => 'Scan QR code to connect', 'voucher_qr_label' => 'Scan QR code to connect',
'voucher_print_btn' => 'Print Code', 'voucher_print_btn' => 'Print Code',
'voucher_no_sites' => 'No available sites found.', 'voucher_no_sites' => 'No available sites found.',
'voucher_no_sites_admin'=> 'Click here to create sites', 'voucher_no_sites_admin'=> 'Click here to create sites',
'voucher_no_sites_user' => 'Please contact your administrator.', 'voucher_no_sites_user' => 'Your account has no site access yet. Please contact your administrator to be granted permissions.',
'voucher_template_select'=> '-- No Profile (manual) --', 'voucher_template_select'=> '-- No Profile (manual) --',
'voucher_template_label'=> 'Quick Profile (optional)', 'voucher_template_label'=> 'Quick Profile (optional)',
@ -292,4 +292,64 @@ return [
'never' => 'Never', 'never' => 'Never',
'unknown' => 'Unknown', 'unknown' => 'Unknown',
'or' => 'or', 'or' => 'or',
// Durations (human readable)
'dur_minutes' => '{n} minutes',
'dur_hour_one' => '1 hour',
'dur_hours' => '{n} hours',
'dur_day_one' => '1 day',
'dur_days' => '{n} days',
// Voucher creation (messages)
'voucher_created_ok' => 'Voucher created successfully!',
'voucher_created_mail' => 'Voucher created. Email sent.',
'voucher_name_default' => 'Guest',
'voucher_deleted' => 'Voucher deleted successfully!',
'voucher_delete_failed'=> 'Voucher could not be deleted',
'error_rate_limited' => 'Too many requests. Please wait a moment.',
// Clipboard / UI
'toast_copied' => 'Copied!',
'click_to_copy' => 'Click to copy',
'copy_hint' => 'Click a code to copy it',
'loading' => 'Loading…',
'syncing' => 'Syncing…',
'vouchers_loaded' => '{count} vouchers loaded',
// Confirm dialogs
'confirm_delete_user' => 'Really delete this user?',
'confirm_delete_site' => 'Really delete this site?',
'confirm_delete_template' => 'Really delete this profile?',
'confirm_delete_voucher' => 'Really delete this voucher?',
'confirm_send_reset' => 'Send a password reset link to {email}?',
'confirm_delete_token' => 'Really delete the token?',
// Admin messages
'users_status_updated' => 'User status updated!',
'sites_status_updated' => 'Site status updated!',
'error_self_delete' => 'You cannot delete yourself',
'error_self_deactivate' => 'You cannot deactivate yourself',
'error_self_demote' => 'You cannot remove your own administrator rights',
'error_pw_mismatch' => 'Passwords do not match',
'error_pw_current' => 'Current password is incorrect',
'error_email_exists' => 'Email address already exists',
'error_user_create' => 'User could not be created',
'reset_link_sent' => 'Password reset link has been sent to {email}.',
'reset_link_failed' => 'User not found or no local password set.',
'cron_token_generated' => 'New cron token generated!',
'cron_token_deleted' => 'Cron token deleted!',
'm365_secret_hint' => 'Leave empty to keep the current secret. To disable M365 login, clear the Client ID.',
// SSL verification
'sites_ssl_verify' => "Verify the controller's SSL certificate",
'sites_ssl_verify_hint' => 'Only enable if the controller has a valid certificate (UniFi uses self-signed certificates by default).',
'smtp_verify_ssl' => "Verify the SMTP server's SSL certificate",
// Site connection test
'site_test_btn' => 'Test connection',
'site_test_ok' => 'Connection successful',
'site_test_fail' => 'Connection failed',
// System warnings
'crypto_warning' => 'Encryption inactive: no valid APP_KEY is set in config.php. UniFi passwords are stored in plain text. If the APP_KEY changes (e.g. after a server move), controller logins will silently fail.',
]; ];

View file

@ -67,6 +67,7 @@ try {
$db = Database::getInstance(); $db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', ''); $logoUrl = $db->getSetting('logo_url', '');
$faviconUrl = $db->getSetting('favicon_url', '');
$m365ClientId = $db->getSetting('m365_client_id', ''); $m365ClientId = $db->getSetting('m365_client_id', '');
$m365ClientSecret = $db->getSetting('m365_client_secret', ''); $m365ClientSecret = $db->getSetting('m365_client_secret', '');
@ -127,6 +128,9 @@ try {
<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><?= __('login_title') ?> <?= htmlspecialchars($appTitle) ?></title> <title><?= __('login_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php if (!empty($faviconUrl)): ?>
<link rel="icon" href="<?= htmlspecialchars($faviconUrl) ?>">
<?php endif; ?>
<link rel="stylesheet" href="assets/global.css"> <link rel="stylesheet" href="assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script> <script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<style> <style>
@ -148,9 +152,6 @@ try {
.divider { margin: 22px 0; text-align: center; position: relative; } .divider { margin: 22px 0; text-align: center; position: relative; }
.divider::before { content: ''; position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: var(--border-color); } .divider::before { content: ''; position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: var(--border-color); }
.divider span { background: var(--bg-card); padding: 0 15px; color: var(--text-muted); font-size: 13px; position: relative; z-index: 1; } .divider span { background: var(--bg-card); padding: 0 15px; color: var(--text-muted); font-size: 13px; position: relative; z-index: 1; }
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; }
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
.back-link { display: block; margin-top: 20px; color: var(--accent); text-decoration: none; font-size: 14px; } .back-link { display: block; margin-top: 20px; color: var(--accent); text-decoration: none; font-size: 14px; }
.back-link:hover { text-decoration: underline; } .back-link:hover { text-decoration: underline; }
.local-login-link { display: block; margin-top: 20px; color: var(--text-muted); text-decoration: none; font-size: 13px; } .local-login-link { display: block; margin-top: 20px; color: var(--text-muted); text-decoration: none; font-size: 13px; }

View file

@ -1,329 +0,0 @@
<?php
// Umfassendes Error Reporting
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('log_errors', 1);
// Versuche Dateien zu laden
$loadErrors = [];
try {
if (!file_exists(__DIR__ . '/config.php')) {
throw new Exception('config.php nicht gefunden');
}
require_once __DIR__ . '/config.php';
} catch (Exception $e) {
$loadErrors[] = "Config: " . $e->getMessage();
}
try {
if (!file_exists(__DIR__ . '/includes/Database.php')) {
throw new Exception('includes/Database.php nicht gefunden');
}
require_once __DIR__ . '/includes/Database.php';
} catch (Exception $e) {
$loadErrors[] = "Database: " . $e->getMessage();
}
try {
if (!file_exists(__DIR__ . '/includes/Auth.php')) {
throw new Exception('includes/Auth.php nicht gefunden');
}
require_once __DIR__ . '/includes/Auth.php';
} catch (Exception $e) {
$loadErrors[] = "Auth: " . $e->getMessage();
}
// Wenn Ladefehler aufgetreten sind, zeige sie an
if (!empty($loadErrors)) {
die('<h1>Fehler beim Laden der Dateien</h1><ul><li>' . implode('</li><li>', $loadErrors) . '</li></ul>');
}
// Ab hier normal weiter
try {
$auth = new Auth();
} catch (Exception $e) {
die('<h1>Fehler bei Auth-Initialisierung</h1><p>' . $e->getMessage() . '</p>');
}
// Wenn bereits eingeloggt, weiterleiten
if ($auth->isLoggedIn()) {
header('Location: index.php');
exit;
}
$error = '';
$success = '';
// Login-Verarbeitung
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
if (empty($email) || empty($password)) {
$error = 'Bitte E-Mail und Passwort eingeben';
} elseif ($auth->login($email, $password)) {
header('Location: index.php');
exit;
} else {
$error = 'Ungültige E-Mail oder Passwort';
}
} catch (Exception $e) {
$error = 'Login-Fehler: ' . $e->getMessage();
}
}
try {
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
$m365Enabled = !empty($db->getSetting('m365_client_id')) &&
!empty($db->getSetting('m365_client_secret')) &&
!empty($db->getSetting('m365_tenant_id'));
$publicAccess = $db->getSetting('public_access', 0);
// M365 OAuth URL generieren falls aktiviert
$m365LoginUrl = '';
if ($m365Enabled) {
$clientId = $db->getSetting('m365_client_id');
$tenantId = $db->getSetting('m365_tenant_id');
// Dynamische Redirect URI basierend auf aktuellem Pfad
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
$params = [
'client_id' => $clientId,
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'response_mode' => 'query',
'scope' => 'openid profile email User.Read',
'state' => bin2hex(random_bytes(16))
];
$_SESSION['m365_state'] = $params['state'];
$m365LoginUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?" . http_build_query($params);
}
} catch (Exception $e) {
die('<h1>Datenbankfehler</h1><p>' . $e->getMessage() . '</p>');
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - <?= htmlspecialchars($appTitle) ?></title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
max-width: 420px;
width: 100%;
padding: 50px 40px;
text-align: center;
}
.logo {
max-width: 200px;
height: auto;
margin-bottom: 30px;
}
h1 {
color: #333;
font-size: 28px;
margin-bottom: 10px;
}
.subtitle {
color: #666;
font-size: 14px;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
font-size: 14px;
}
input[type="email"],
input[type="password"] {
width: 100%;
padding: 14px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 15px;
transition: all 0.3s;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.btn {
width: 100%;
padding: 14px;
background: #667eea;
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
margin-top: 10px;
}
.btn:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-microsoft {
background: white;
color: #333;
border: 2px solid #e0e0e0;
margin-top: 15px;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-microsoft:hover {
background: #f8f9fa;
border-color: #667eea;
transform: translateY(-2px);
text-decoration: none;
}
.divider {
margin: 25px 0;
text-align: center;
position: relative;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: #e0e0e0;
}
.divider span {
background: white;
padding: 0 15px;
color: #999;
font-size: 13px;
position: relative;
z-index: 1;
}
.alert {
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
}
.alert-error {
background: #fee;
border: 1px solid #fcc;
color: #c33;
}
.alert-success {
background: #efe;
border: 1px solid #cfc;
color: #3c3;
}
.back-link {
display: block;
margin-top: 20px;
color: #667eea;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
.debug-info {
background: #f8f9fa;
border: 1px solid #e0e0e0;
padding: 15px;
margin-top: 20px;
border-radius: 8px;
text-align: left;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<div class="login-container">
<?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
<?php else: ?>
<h1><?= htmlspecialchars($appTitle) ?></h1>
<?php endif; ?>
<p class="subtitle">Melden Sie sich an, um fortzufahren</p>
<?php if ($error): ?>
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<form method="post" action="">
<div class="form-group">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required autofocus>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn">Anmelden</button>
</form>
<?php if ($m365Enabled): ?>
<div class="divider"><span>oder</span></div>
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn btn-microsoft">
🔷 Mit Microsoft 365 anmelden
</a>
<?php endif; ?>
<?php if ($publicAccess): ?>
<a href="index.php" class="back-link"> Zurück zur Code-Erstellung</a>
<?php endif; ?>
<!-- Debug Info (kann nach erfolgreicher Einrichtung entfernt werden) -->
<div class="debug-info">
<strong>System-Status:</strong><br>
PHP Version: <?= phpversion() ?><br>
Session Status: <?= session_status() === PHP_SESSION_ACTIVE ? 'Aktiv' : 'Inaktiv' ?><br>
Eingeloggt: <?= $auth->isLoggedIn() ? 'Ja' : 'Nein' ?>
</div>
</div>
</body>
</html>

View file

@ -103,15 +103,19 @@ if (isset($_GET['code'])) {
$userData = json_decode($userResponse, true); $userData = json_decode($userResponse, true);
if (!isset($userData['id']) || !isset($userData['mail'])) { // Graph liefert 'mail' nur bei Nutzern mit Exchange-Postfach fuer alle
// anderen auf den userPrincipalName zurueckfallen.
$userEmail = $userData['mail'] ?? $userData['userPrincipalName'] ?? null;
if (!isset($userData['id']) || empty($userEmail)) {
die("Ungültige Benutzer-Daten erhalten: " . htmlspecialchars($userResponse) . "<br><a href='login.php'>Zurück zum Login</a>"); die("Ungültige Benutzer-Daten erhalten: " . htmlspecialchars($userResponse) . "<br><a href='login.php'>Zurück zum Login</a>");
} }
// Benutzer einloggen oder anlegen // Benutzer einloggen oder anlegen
$microsoftUser = [ $microsoftUser = [
'id' => $userData['id'], 'id' => $userData['id'],
'email' => $userData['mail'] ?? $userData['userPrincipalName'], 'email' => $userEmail,
'name' => $userData['displayName'] ?? $userData['givenName'] . ' ' . $userData['surname'] 'name' => $userData['displayName'] ?? trim(($userData['givenName'] ?? '') . ' ' . ($userData['surname'] ?? ''))
]; ];
try { try {

View file

@ -15,6 +15,7 @@ I18n::init();
$db = Database::getInstance(); $db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', ''); $logoUrl = $db->getSetting('logo_url', '');
$faviconUrl = $db->getSetting('favicon_url', '');
$token = trim($_GET['token'] ?? ''); $token = trim($_GET['token'] ?? '');
$error = ''; $error = '';
@ -69,6 +70,9 @@ if ($valid && $_SERVER['REQUEST_METHOD'] === 'POST') {
<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><?= __('reset_new_pw') ?> <?= htmlspecialchars($appTitle) ?></title> <title><?= __('reset_new_pw') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php if ($faviconUrl): ?>
<link rel="icon" href="<?= htmlspecialchars($faviconUrl) ?>">
<?php endif; ?>
<link rel="stylesheet" href="assets/global.css"> <link rel="stylesheet" href="assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script> <script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<style> <style>
@ -84,9 +88,6 @@ if ($valid && $_SERVER['REQUEST_METHOD'] === 'POST') {
input:focus { outline: none; border-color: var(--accent); } input:focus { outline: none; border-color: var(--accent); }
.btn { width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 8px; } .btn { width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 8px; }
.btn:hover { background: var(--accent-hover); transform: translateY(-2px); } .btn:hover { background: var(--accent-hover); transform: translateY(-2px); }
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; text-align: left; }
.alert-error { background: #fee; border: 1px solid #fcc; color: #c33; }
.alert-success { background: #efe; border: 1px solid #cfc; color: #3c3; }
.back-link { display: block; margin-top: 22px; color: var(--accent); text-decoration: none; font-size: 14px; } .back-link { display: block; margin-top: 22px; color: var(--accent); text-decoration: none; font-size: 14px; }
.back-link:hover { text-decoration: underline; } .back-link:hover { text-decoration: underline; }
.pw-strength { height: 4px; border-radius: 2px; margin-top: 6px; transition: all 0.3s; background: var(--border-color); } .pw-strength { height: 4px; border-radius: 2px; margin-top: 6px; transition: all 0.3s; background: var(--border-color); }

View file

@ -86,3 +86,17 @@ rm updater/storage/.maintenance # wieder normal
> Hinweis: Ein vollständiger Installations-Durchlauf (`action=install`) setzt > Hinweis: Ein vollständiger Installations-Durchlauf (`action=install`) setzt
> einen erreichbaren Update-Proxy unter den oben genannten URLs voraus. > einen erreichbaren Update-Proxy unter den oben genannten URLs voraus.
## Sicherheits-Hinweise & Limitierungen
- **Keine Paket-Signatur:** Die Integrität der Updates hängt derzeit allein an
TLS zur Update-Proxy-URL. Der Updater validiert Zip-Einträge und Dateipfade
gegen Pfad-Traversal und legt vor dem Anwenden ein Backup an
(`updater/storage/.backup-last`), das bei Fehlern automatisch
zurückgespielt wird. Eine kryptografische Signaturprüfung der Pakete
(z.B. signierte SHA-256-Manifeste) erfordert serverseitige Unterstützung
des Update-Proxys und steht noch aus.
- **Rollback:** Schlägt das Anwenden des Updates oder eine Migration fehl,
werden die überschriebenen Dateien aus dem Backup wiederhergestellt.
Datenbank-Migrationen werden dabei nicht automatisch rückgängig gemacht
(jede Migration läuft aber in einer eigenen Transaktion).

View file

@ -17,12 +17,15 @@ class UpdateController
private $auth; private $auth;
/** @var UpdateManager */ /** @var UpdateManager */
private $manager; private $manager;
/** @var AuditLogger */
private $audit;
public function __construct(\Database $db, \Auth $auth) public function __construct(\Database $db, \Auth $auth)
{ {
$this->db = $db; $this->db = $db;
$this->auth = $auth; $this->auth = $auth;
$this->manager = UpdaterFactory::create($db, new AuditLogger($db)); $this->audit = new AuditLogger($db);
$this->manager = UpdaterFactory::create($db, $this->audit);
} }
public function handle(): void public function handle(): void

View file

@ -197,11 +197,21 @@ class UpdateManager
} }
$stagingRoot = $this->resolveStagingRoot($stagingDir); $stagingRoot = $this->resolveStagingRoot($stagingDir);
// 4) Staging -> Production (geschuetzte Pfade ueberspringen) // 4) Backup aller Dateien anlegen, die gleich ueberschrieben werden.
// Schlaegt das Anwenden oder eine Migration fehl, wird der alte
// Stand wiederhergestellt statt eine halb-aktualisierte
// Installation online zu nehmen.
$this->setProgress(60, 'Sichere bestehende Dateien …');
$backupDir = $this->storageDir . '/.backup-last';
$this->cleanDir($backupDir);
$this->backupExisting($stagingRoot, $backupDir);
try {
// 5) Staging -> Production (geschuetzte Pfade ueberspringen)
$this->setProgress(65, 'Wende Update an …'); $this->setProgress(65, 'Wende Update an …');
$this->applyStaging($stagingRoot); $this->applyStaging($stagingRoot);
// 5) Migrationen ausfuehren // 6) Migrationen ausfuehren
$this->setProgress(80, 'Fuehre Datenbank-Migrationen aus …'); $this->setProgress(80, 'Fuehre Datenbank-Migrationen aus …');
$runner = new MigrationRunner( $runner = new MigrationRunner(
$this->db->getConnection(), $this->db->getConnection(),
@ -209,6 +219,11 @@ class UpdateManager
$this->storageDir $this->storageDir
); );
$runner->runPending(true); $runner->runPending(true);
} catch (\Throwable $e) {
$this->setProgress(70, 'Fehler stelle vorherigen Stand wieder her …');
$this->restoreBackup($backupDir);
throw $e;
}
// 6) Caches leeren // 6) Caches leeren
$this->setProgress(90, 'Leere Caches …'); $this->setProgress(90, 'Leere Caches …');
@ -280,6 +295,15 @@ class UpdateManager
if ($zip->open($zipPath) !== true) { if ($zip->open($zipPath) !== true) {
throw new \RuntimeException('ZIP konnte nicht geoeffnet werden.'); throw new \RuntimeException('ZIP konnte nicht geoeffnet werden.');
} }
// Zip-Slip-Schutz: Eintraege mit Pfad-Traversal oder absoluten Pfaden
// ablehnen, bevor irgendetwas entpackt wird.
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string)$zip->getNameIndex($i);
if ($name === '' || $name[0] === '/' || strpos($name, '..') !== false || strpos($name, ':') !== false) {
$zip->close();
throw new \RuntimeException("ZIP enthaelt unsicheren Pfad: $name");
}
}
if (!is_dir($dest)) { if (!is_dir($dest)) {
@mkdir($dest, 0775, true); @mkdir($dest, 0775, true);
} }
@ -308,6 +332,10 @@ class UpdateManager
continue; continue;
} }
$relPath = $entry['path']; $relPath = $entry['path'];
// Pfad-Traversal-Schutz: Proxy-Antworten nicht blind vertrauen
if ($relPath[0] === '/' || strpos($relPath, '..') !== false || strpos($relPath, ':') !== false) {
throw new \RuntimeException("Dateiliste enthaelt unsicheren Pfad: $relPath");
}
[$st, $content] = $this->httpGet($this->proxyUrl . '/download/' . str_replace('%2F', '/', rawurlencode($relPath))); [$st, $content] = $this->httpGet($this->proxyUrl . '/download/' . str_replace('%2F', '/', rawurlencode($relPath)));
if ($st !== 200) { if ($st !== 200) {
throw new \RuntimeException("Download fehlgeschlagen: $relPath (HTTP $st)"); throw new \RuntimeException("Download fehlgeschlagen: $relPath (HTTP $st)");
@ -369,6 +397,64 @@ class UpdateManager
} }
} }
/**
* Sichert alle Produktionsdateien, die durch das Staging ueberschrieben
* wuerden, in ein Backup-Verzeichnis (Spiegelstruktur).
*/
private function backupExisting(string $stagingRoot, string $backupDir): void
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($stagingRoot, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
if ($item->isDir()) {
continue;
}
$rel = ltrim(str_replace('\\', '/', substr($item->getPathname(), strlen($stagingRoot))), '/');
if ($rel === '' || $this->isProtected($rel)) {
continue;
}
$existing = $this->rootDir . '/' . $rel;
if (!is_file($existing)) {
continue;
}
$target = $backupDir . '/' . $rel;
$dir = dirname($target);
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
@copy($existing, $target);
}
}
/** Stellt ein zuvor angelegtes Backup wieder in die Produktion zurueck. */
private function restoreBackup(string $backupDir): void
{
if (!is_dir($backupDir)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($backupDir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
if ($item->isDir()) {
continue;
}
$rel = ltrim(str_replace('\\', '/', substr($item->getPathname(), strlen($backupDir))), '/');
if ($rel === '') {
continue;
}
$target = $this->rootDir . '/' . $rel;
$dir = dirname($target);
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
@copy($item->getPathname(), $target);
}
}
private function isProtected(string $rel): bool private function isProtected(string $rel): bool
{ {
foreach (self::PROTECTED_PATHS as $p) { foreach (self::PROTECTED_PATHS as $p) {

View file

@ -0,0 +1,11 @@
-- IP-basiertes Request-Throttling (z.B. anonyme Voucher-Erstellung,
-- Passwort-Reset-Anfragen). Ersetzt das rein session-basierte Throttling,
-- das sich per Cookie-Loeschen umgehen liess.
CREATE TABLE IF NOT EXISTS `request_throttle` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`ip_address` VARCHAR(45) NOT NULL,
`action` VARCHAR(50) NOT NULL,
`weight` INT NOT NULL DEFAULT 1,
`requested_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_throttle` (`action`, `ip_address`, `requested_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -0,0 +1,3 @@
-- Opt-in SSL-Zertifikatspruefung pro Site (Default aus: UniFi-Controller
-- nutzen meist self-signed Zertifikate).
ALTER TABLE `sites` ADD COLUMN `ssl_verify` TINYINT(1) NOT NULL DEFAULT 0;