Security-, Bugfix- und UX-Überarbeitung auf Basis des Code-Reviews

Sicherheit:
- Bulk-Erstellung serverseitig auf eingeloggte Nutzer beschränkt;
  expire_minutes wird validiert (anonym: nur Default/Template-Werte,
  eingeloggt: max. 1 Jahr)
- IP-basiertes Rate-Limit über neue Tabelle request_throttle
  (Voucher-Erstellung + Passwort-Reset-Anfragen), Session-Fallback
  für Alt-Installationen; Migration 0002
- session_regenerate_id() nach Login, Secure-Cookie-Flag bei HTTPS
- Admin-/Aktiv-Status wird pro Request live aus der DB geprüft
  (Rechteentzug & Deaktivierung wirken sofort); Schutz vor
  Selbst-Degradierung im Benutzer-Edit
- Alle state-ändernden Admin-Aktionen von GET auf POST umgestellt
  (kein CSRF-Token mehr in URLs)
- login_simple.php (Legacy, Debug-Leak) entfernt; cron_test.php nur
  noch für Admins; .htaccess auf Apache-2.4-Syntax inkl. cron_test.php
- M365 Client Secret wird nicht mehr ins Formular zurückgegeben
- Updater: Zip-Slip-/Pfad-Traversal-Schutz, Backup vor dem Anwenden
  mit automatischem Rollback bei Fehlern, AuditLogger-Bug behoben
- cron_sync: Token-Vergleich mit hash_equals; login_attempts-Pruning
- CSV-Export gegen Excel-Formula-Injection abgesichert

Bugfixes:
- M365-Login: Fallback auf userPrincipalName, wenn Graph kein 'mail'
  liefert (Nutzer ohne Exchange-Postfach konnten sich nie anmelden)
- PRG-Pattern überall: F5 erzeugt keine Duplikat-Voucher und
  wiederholt keine Admin-Aktionen (Session-Flash-Messages)
- QR-Code nicht mehr invertiert (schwarz auf weiß, scanbar)
- Bulk-Erstellung nutzt den UniFi 'n'-Parameter: 1 API-Call statt
  n× Login + Voucherlisten-Abruf; exaktes Code-Matching per
  create_time statt "global neuester Voucher"
- Mailer: doppelte Zeilenumbrüche behoben, AUTH nur mit Credentials,
  SMTP-Dot-Stuffing, CLI-sicherer EHLO-Host
- forgot_password: System-URL-Auto-Detect (Reset-Link war sonst
  relativ/kaputt) + Rate-Limit
- Audit-Log-Labels an tatsächliche Action-Keys angepasst;
  Voucher-Erstellung (einzeln & bulk) wird jetzt auditiert
- Site-Edit testet die Verbindung auch ohne Passwortänderung

UX/UI:
- Alert-/Badge-Styles zentral in global.css mit Dark-Mode-Variablen
  (vorher 7× dupliziert mit hart codierten Hellfarben)
- Sticky-Formulare + Tab-Erhalt nach Validierungsfehlern (Bulk),
  Settings kehren nach dem Speichern zum aktiven Tab zurück
- Gültigkeit menschenlesbar (z.B. "8 Stunden" statt "480 Minuten")
- Voucher-Name-Default "Gast/Guest" im öffentlichen Modus
- Favicon auch auf Login-/öffentlichen Seiten
- Verbindungstest-Button pro Site-Karte (Health-Check)
- i18n-Pass: Confirm-Dialoge, Toasts, Fehl-/Erfolgsmeldungen in de/en
- Sprachumschalter ohne fetch+reload (kein Re-Submit-Dialog)
- A11y: Esc schließt Modals, aria-live für Toasts, aria-labels auf
  Icon-Buttons; APP_KEY-Warnbanner im Dashboard
- Dashboard-Sync: set_time_limit passend zur Site-Anzahl;
  Voucher-Sync mit Map statt SELECT pro Voucher

Tooling:
- GitHub-Actions-Workflow: PHP-Lint aller Dateien + de/en-Key-Parität

https://claude.ai/code/session_01KKVpVPJjrTKGoRgpJcySD4
This commit is contained in:
Claude 2026-06-09 19:43:13 +00:00
parent f747a3d429
commit 6e19958a37
No known key found for this signature in database
31 changed files with 1040 additions and 628 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';
$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 = [
'voucher_created' => '🎫 Voucher erstellt',
'voucher_create' => '🎫 Voucher erstellt',
'voucher_bulk' => '🎫 Bulk Voucher',
'user_login' => '🔐 Login',
'user_logout' => '🚪 Logout',
'user_created' => '👤 Benutzer erstellt',
'user_updated' => '👤 Benutzer geändert',
'user_deleted' => '👤 Benutzer gelöscht',
'site_added' => '🌐 Site hinzugefügt',
'site_updated' => '🌐 Site geändert',
'site_deleted' => '🌐 Site gelöscht',
'settings_saved' => '⚙️ Einstellungen gespeichert',
'user_create' => '👤 Benutzer erstellt',
'user_edit' => '👤 Benutzer geändert',
'user_delete' => '👤 Benutzer gelöscht',
'site_create' => '🌐 Site hinzugefügt',
'site_edit' => '🌐 Site geändert',
'site_delete' => '🌐 Site gelöscht',
'password_reset' => '🔑 Passwort-Reset',
'template_created' => '📋 Profil erstellt',
'template_updated' => '📋 Profil geändert',
'template_deleted' => '📋 Profil gelöscht',
'update_installed' => '🔄 Update installiert',
'update_failed' => '🔄 Update fehlgeschlagen',
'migrations_run' => '🗄️ Migrationen ausgeführt',
];
?>
<!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 tr:last-child td { border-bottom: none; }
.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 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); }

View file

@ -26,6 +26,9 @@ if (isset($_GET['ajax_stats'])) {
$syncErrors = [];
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) {
try {
$ctrl = new UniFiController($site['unifi_controller_url'], $site['unifi_username'], Crypto::decrypt($site['unifi_password']), $site['site_id']);
@ -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 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; }
.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:hover { background: var(--accent-hover); }
.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; } }
</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>
<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/Mailer.php';
require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth();
$auth->requireAdmin();
@ -72,7 +73,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if ($formType === 'm365') {
$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'] ?? '');
}
@ -106,7 +111,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
$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) {
$error = 'Fehler: ' . $e->getMessage();
}
@ -119,7 +132,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['generate_cron_token']
$error = __('error_csrf');
} else {
$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'])) {
@ -127,7 +142,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token']))
$error = __('error_csrf');
} else {
$db->setSetting('cron_token', '');
$success = 'Cron-Token wurde gelöscht!';
flashSet(__('cron_token_deleted'));
header('Location: settings.php#cron');
exit;
}
}
@ -139,23 +156,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['change_password'])) {
try {
$user = $auth->getCurrentUser();
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) {
throw new Exception(__('settings_pw_minlength'));
}
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 = ?",
[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) {
$error = $e->getMessage();
}
}
}
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME'], 2);
@ -215,9 +238,6 @@ $adminBase = '';
<style>
.page-header { margin-bottom: 30px; }
.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-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; }
@ -350,7 +370,7 @@ $adminBase = '';
<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>
<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>
<?php endif; ?>
<?php
@ -389,7 +409,7 @@ $adminBase = '';
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<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 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>
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
</form>

View file

@ -8,6 +8,7 @@ require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/UniFiController.php';
require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth();
$auth->requireAdmin();
@ -18,6 +19,31 @@ I18n::init();
$error = '';
$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']
);
echo json_encode([
'success' => $test === true,
'message' => $test === true ? __('site_test_ok') : __('site_test_fail') . ': ' . $test,
]);
exit;
}
// Edit site
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) {
@ -34,15 +60,23 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
if (empty($name)||empty($siteIdStr)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
if (!empty($password)) {
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr);
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=?",
[$name,$siteIdStr,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$siteId]);
} else {
// Auch ohne Passwortaenderung testen (mit gespeichertem Passwort)
// 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);
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=? WHERE id=?",
[$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$siteId]);
}
$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(); }
}
}
@ -61,36 +95,46 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_site'])) {
$publicAccess = isset($_POST['public_access']) ? 1 : 0;
if (empty($name)||empty($siteId)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId);
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 (?,?,?,?,?,?)",
[$name,$siteId,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess]);
$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(); }
}
}
// Delete site
if (isset($_GET['delete']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$delId = (int)$_GET['delete'];
// Delete site (POST + PRG)
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['delete_site'])) {
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$delId = (int)$_POST['delete_site'];
$db->query("DELETE FROM sites WHERE id=?", [$delId]);
$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'); }
}
// Toggle site
if (isset($_GET['toggle']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_GET['toggle']]);
// Toggle site (POST + PRG)
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_site'])) {
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_POST['toggle_site']]);
if ($site) {
$db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_GET['toggle']]);
$success = 'Site-Status aktualisiert!';
$db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_POST['toggle_site']]);
flashSet(__('sites_status_updated'));
header('Location: sites.php');
exit;
}
} else { $error = __('error_csrf'); }
}
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$sites = $db->fetchAll("SELECT * FROM sites ORDER BY name");
$currentPage = 'sites';
?>
@ -104,9 +148,6 @@ $currentPage = 'sites';
<style>
.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); }
.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; }
.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); }
@ -116,10 +157,6 @@ $currentPage = 'sites';
.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-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-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
@ -204,16 +241,27 @@ $currentPage = 'sites';
class="btn btn-secondary btn-sm">
<i class="fas fa-edit"></i> <?= __('btn_edit') ?>
</button>
<a href="?toggle=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-secondary btn-sm">
<i class="fas fa-<?= $site['is_active'] ? 'pause' : 'play' ?>"></i>
<?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>
</a>
<a href="?delete=<?= $site['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-danger btn-sm"
onclick="return confirm('Möchten Sie diese Site wirklich löschen?')">
<i class="fas fa-trash"></i>
</a>
<form method="post" style="display:inline;">
<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>
<?= $site['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>
</button>
</form>
<button type="button" class="btn btn-secondary btn-sm" onclick="testSite(<?= $site['id'] ?>, this)"
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>
</button>
</form>
</div>
</div>
<?php endforeach; ?>
@ -355,6 +403,23 @@ document.getElementById('editSiteForm').addEventListener('submit', function() {
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>
</body>
</html>

View file

@ -7,6 +7,7 @@ require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth();
$auth->requireAdmin();
@ -37,7 +38,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_template'])) {
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, created_by) VALUES (?, ?, ?, ?, ?)",
[$name, $maxUses, $expireMin, $description, $_SESSION['user_id']]
);
$success = __('templates_added');
flashSet(__('templates_added'));
header('Location: templates.php');
exit;
} catch (Exception $e) {
$error = $e->getMessage();
}
@ -63,23 +66,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) {
"UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, is_active=? WHERE id=?",
[$name, $maxUses, $expireMin, $description, $isActive, $id]
);
$success = __('templates_updated');
flashSet(__('templates_updated'));
header('Location: templates.php');
exit;
} catch (Exception $e) {
$error = $e->getMessage();
}
}
}
// Profil löschen
if (isset($_GET['delete']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_GET['delete']]);
$success = __('templates_deleted');
// Profil löschen (POST + PRG)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_template'])) {
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_POST['delete_template']]);
flashSet(__('templates_deleted'));
header('Location: templates.php');
exit;
} else {
$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");
$currentPage = 'templates';
@ -95,9 +106,6 @@ $adminBase = '';
<style>
.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); }
.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-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); }
@ -106,9 +114,6 @@ $adminBase = '';
.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: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:hover { background: var(--accent-hover); }
.btn-danger { background: var(--danger); color: white; }
@ -206,9 +211,15 @@ $adminBase = '';
<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'] ?>)"
class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button>
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
onclick="return confirm('Profil wirklich löschen?')"
class="btn btn-danger btn-small"><i class="fas fa-trash"></i></a>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= addslashes(__('confirm_delete_template')) ?>')">
<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>
</tr>
<?php endforeach; ?>

View file

@ -8,6 +8,7 @@ require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Mailer.php';
require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Helpers.php';
$auth = new Auth();
$auth->requireAdmin();
@ -20,10 +21,11 @@ I18n::init();
$error = '';
$success = '';
// Send password reset link
if (isset($_GET['send_reset']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$targetUser = $db->fetchOne("SELECT * FROM users WHERE id=? AND is_active=1 AND password_hash IS NOT NULL", [(int)$_GET['send_reset']]);
// Send password reset link (POST statt GET: kein CSRF-Token in URLs/Referrern,
// keine versehentliche Ausloesung durch Link-Prefetching)
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['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) {
try {
$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;
$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}");
$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) {
$error = 'Fehler beim Senden: ' . $e->getMessage();
}
} else {
$error = 'Benutzer nicht gefunden oder kein lokales Passwort.';
$error = __('reset_link_failed');
}
} else {
$error = __('error_csrf');
@ -60,6 +64,11 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_user'])) {
$userId = (int)$_POST['user_id'];
$isAdmin = isset($_POST['is_admin']) ? 1 : 0;
$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]);
$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]);
@ -83,8 +92,10 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_user'])) {
if (!empty($removedSites)) $changes[] = 'Zugriff entfernt von: ' . implode(', ', $removedSites);
if ($isAdmin && !$oldUser['is_admin']) $changes[] = 'Sie haben nun Zugriff auf alle Sites';
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');
flashSet(__('users_updated') . (!empty($changes) ? ' '.__('users_notified') : ''));
header('Location: users.php');
exit;
} catch (Exception $e) { $error = $e->getMessage(); }
}
}
@ -103,51 +114,61 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_user'])) {
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 (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);
if (!$userId) throw new Exception('Benutzer konnte nicht erstellt werden');
if (!$userId) throw new Exception(__('error_user_create'));
if (!$isAdmin && !empty($siteIds)) {
foreach ($siteIds as $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");
$success = __('users_added');
flashSet(__('users_added'));
header('Location: users.php');
exit;
} catch (Exception $e) { $error = $e->getMessage(); }
}
}
// Delete user
if (isset($_GET['delete']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$deleteId = (int)$_GET['delete'];
// Delete user (POST + PRG)
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['delete_user'])) {
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$deleteId = (int)$_POST['delete_user'];
if ($deleteId === (int)$_SESSION['user_id']) {
$error = 'Sie können sich nicht selbst löschen';
$error = __('error_self_delete');
} else {
$db->query("DELETE FROM users WHERE id=?", [$deleteId]);
$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'); }
}
// Toggle user active
if (isset($_GET['toggle']) && isset($_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$toggleId = (int)$_GET['toggle'];
// Toggle user active (POST + PRG)
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_user'])) {
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$toggleId = (int)$_POST['toggle_user'];
if ($toggleId === (int)$_SESSION['user_id']) {
$error = 'Sie können sich nicht selbst deaktivieren';
$error = __('error_self_deactivate');
} else {
$user = $db->fetchOne("SELECT is_active FROM users WHERE id=?", [$toggleId]);
if ($user) {
$newStatus = $user['is_active'] ? 0 : 1;
$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'); }
}
if (empty($success) && empty($error) && ($flash = flashGet())) {
$success = $flash['message'];
}
$users = $db->fetchAll("SELECT * FROM users ORDER BY name");
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
$userSiteAccess = [];
@ -169,19 +190,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-header { padding: 18px 22px; border-bottom: 1px solid var(--border-color); }
.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 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 tr:last-child td { border-bottom: none; }
.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-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
@ -293,22 +306,35 @@ $currentPage = 'users';
<i class="fas fa-edit"></i>
</button>
<?php if ($user['id'] != $_SESSION['user_id']): ?>
<a href="?toggle=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-secondary btn-sm" title="<?= $user['is_active'] ? __('sites_deactivate') : __('sites_activate') ?>">
<i class="fas fa-<?= $user['is_active'] ? 'pause' : 'play' ?>"></i>
</a>
<form method="post" style="display:inline;">
<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>
</button>
</form>
<?php if ($smtpEnabled && !empty($user['password_hash'])): ?>
<a href="?send_reset=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-warning btn-sm" title="<?= __('users_reset_pw') ?>"
onclick="return confirm('Passwort-Reset-Link senden an <?= htmlspecialchars($user['email'], ENT_QUOTES) ?>?')">
<i class="fas fa-key"></i>
</a>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= addslashes(__('confirm_send_reset', ['email' => $user['email']])) ?>')">
<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>
</button>
</form>
<?php endif; ?>
<a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"
onclick="return confirm('Benutzer wirklich löschen?')">
<i class="fas fa-trash"></i>
</a>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= addslashes(__('confirm_delete_user')) ?>')">
<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>
</button>
</form>
<?php endif; ?>
</div>
</td>

View file

@ -23,13 +23,19 @@ if (isset($_GET['export_csv']) && isset($_GET['site_id'])) {
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]);
$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-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output','w');
fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($out,['Code','Name','Max. Geräte','Gültigkeit (Min)','Status','Genutzt','Erstellt','Läuft ab'],';');
foreach ($rows as $r) {
fputcsv($out,[$r['voucher_code'],$r['voucher_name'],$r['max_uses'],$r['expire_minutes'],$r['status'],$r['used_count'],$r['created_at'],$r['expires_at']??''],';');
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;
}
@ -86,9 +92,9 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id']);
if ($ctrl->deleteVoucher($voucherId)) {
$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 {
echo json_encode(['success'=>false,'message'=>'Voucher konnte nicht gelöscht werden']);
echo json_encode(['success'=>false,'message'=>__('voucher_delete_failed')]);
}
} catch (Exception $e) {
echo json_encode(['success'=>false,'message'=>'Fehler: '.$e->getMessage()]);
@ -151,11 +157,6 @@ $currentPage = 'vouchers';
.table tr:last-child td { border-bottom: none; }
.table tr:hover { background: var(--bg-hover); }
.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; }
.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; }
@ -270,7 +271,7 @@ async function loadVouchers(syncFirst=false) {
refreshBtn.disabled = false;
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 {
const result = await fetch(`vouchers.php?ajax_get_vouchers=1&site_id=${siteId}${syncFirst?'&sync=1':''}`).then(r=>r.json());
@ -286,7 +287,7 @@ async function loadVouchers(syncFirst=false) {
const csvBtn = document.getElementById('csvExportBtn');
csvBtn.style.display = 'inline-flex';
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 {
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';
@ -378,7 +379,7 @@ function renderVouchers() {
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><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>${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>
@ -408,7 +409,7 @@ function renderVouchers() {
function escapeHtml(t) { const d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
async function deleteVoucher(voucherId) {
if (!confirm('Voucher wirklich löschen?')) return;
if (!confirm('<?= addslashes(__('confirm_delete_voucher')) ?>')) return;
const row = document.getElementById(`voucher-${voucherId}`);
if (row) row.classList.add('deleting');
try {

View file

@ -29,6 +29,18 @@
--scrollbar-thumb: #c1c1c1;
--toast-bg: #ffffff;
--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"] {
@ -61,6 +73,18 @@
--scrollbar-thumb: #3a3f5a;
--toast-bg: #1a1d27;
--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 === */
@ -199,18 +223,6 @@
[data-theme="dark"] .placeholder-info h4 { color: #d4a017 !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 {
background: var(--bg-hover) !important;
}
@ -469,3 +481,40 @@
body, .card, .sidebar, .header, input, select, textarea, .btn {
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';
document.body.appendChild(container);
}
// Screenreader ueber neue Toasts informieren
container.setAttribute('role', 'status');
container.setAttribute('aria-live', 'polite');
}
return container;
}
@ -92,13 +95,21 @@ document.addEventListener('DOMContentLoaded', function() {
if (overlay) overlay.addEventListener('click', closeMobileSidebar);
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 === */
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 === */

View file

@ -116,7 +116,8 @@ if (empty($cronToken)) {
exit;
}
if ($providedToken !== $cronToken) {
// hash_equals: zeitkonstanter Vergleich (kein Timing-Seitenkanal)
if (!hash_equals((string)$cronToken, (string)$providedToken)) {
outputResponse([
'success' => false,
'message' => 'Ungültiger Token'

View file

@ -1,5 +1,12 @@
<?php
// 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');
echo json_encode(['step' => 1, 'message' => 'PHP läuft']);

View file

@ -118,6 +118,16 @@ CREATE TABLE IF NOT EXISTS `audit_log` (
INDEX `idx_created` (`created_at`)
) 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` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL,

View file

@ -8,6 +8,7 @@ require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/Mailer.php';
require_once __DIR__ . '/includes/I18n.php';
require_once __DIR__ . '/includes/Helpers.php';
$auth = new Auth();
if ($auth->isLoggedIn()) { header('Location: index.php'); exit; }
@ -16,8 +17,18 @@ I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
$faviconUrl = $db->getSetting('favicon_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 = '';
$success = '';
@ -27,7 +38,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = __('error_email_invalid');
} else {
$user = $db->fetchOne("SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL", [$email]);
// IP-Rate-Limit gegen Mail-Bombing: max. 5 Reset-Anfragen / 15 Min.
// 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)
if ($user) {
@ -76,6 +94,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('reset_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php if ($faviconUrl): ?>
<link rel="icon" href="<?= htmlspecialchars($faviconUrl) ?>">
<?php endif; ?>
<link rel="stylesheet" href="assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<style>
@ -91,9 +112,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
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: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:hover { text-decoration: underline; }
</style>

View file

@ -1,6 +1,8 @@
<?php
class Auth {
private $db;
/** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */
private $sessionUser = false;
public function __construct() {
try {
@ -14,6 +16,9 @@ class Auth {
ini_set('session.cookie_httponly', 1);
ini_set('session.use_strict_mode', 1);
ini_set('session.cookie_samesite', 'Lax');
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
ini_set('session.cookie_secure', 1);
}
if (!session_start()) {
die("Session konnte nicht gestartet werden");
@ -72,6 +77,8 @@ class Auth {
private function recordLoginAttempt($ip, $email) {
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(
"INSERT INTO login_attempts (ip_address, email) VALUES (?, ?)",
[$ip, $email]
@ -138,6 +145,12 @@ class Auth {
// Session setzen
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_email'] = $user['email'];
$_SESSION['user_name'] = $user['name'];
@ -160,6 +173,7 @@ class Auth {
// Ausloggen
public function logout() {
$this->sessionUser = null;
$_SESSION = [];
if (isset($_COOKIE[session_name()])) {
@ -169,6 +183,25 @@ class Auth {
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
public function isLoggedIn() {
if (!isset($_SESSION['user_id']) || !isset($_SESSION['login_time'])) {
@ -183,12 +216,24 @@ class Auth {
return false;
}
// Deaktivierte/geloeschte Konten sofort aussperren
if ($this->loadSessionUser() === null) {
$this->logout();
return false;
}
return true;
}
// Prüfen ob Admin
// Prüfen ob Admin (live aus der DB, nicht aus dem Session-Cache)
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
@ -196,11 +241,7 @@ class Auth {
if (!$this->isLoggedIn()) {
return null;
}
return $this->db->fetchOne(
"SELECT * FROM users WHERE id = ?",
[$_SESSION['user_id']]
);
return $this->loadSessionUser();
}
// Prüfen ob Benutzer Zugriff auf Site hat

View file

@ -114,4 +114,9 @@ class Crypto {
public static function isEncrypted($value) {
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

@ -22,7 +22,7 @@ class Mailer {
$this->smtpUsername = $this->db->getSetting('smtp_username', '');
$this->smtpPassword = $this->db->getSetting('smtp_password', '');
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']);
$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'));
}
@ -49,23 +49,29 @@ class Mailer {
private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
try {
// Hostname auch im CLI-Kontext (Cron) verfuegbar
$heloHost = $_SERVER['HTTP_HOST'] ?? (gethostname() ?: 'localhost');
// Verbindung aufbauen
$socket = $this->connectToSmtp();
// EHLO
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
$this->smtpCommand($socket, "EHLO " . $heloHost);
// STARTTLS wenn nötig
if ($this->smtpEncryption === 'tls') {
$this->smtpCommand($socket, "STARTTLS");
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
$this->smtpCommand($socket, "AUTH LOGIN");
$this->smtpCommand($socket, base64_encode($this->smtpUsername));
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
// 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, base64_encode($this->smtpUsername));
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
}
// MAIL FROM
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
@ -90,11 +96,12 @@ class Mailer {
$message .= "\r\n";
// Body - bei Plain Text Zeilenumbrüche konvertieren
if (!$isHtml) {
$body = nl2br($body, false); // Für Plain Text
$body = str_replace('<br>', "\r\n", $body);
}
// Zeilenumbrueche auf CRLF normalisieren (der fruehere
// nl2br/str_replace-Umweg hat Umbrueche verdoppelt)
$body = preg_replace("/\r\n|\r|\n/", "\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 .= "\r\n.\r\n";

View file

@ -155,12 +155,29 @@ class UniFiController {
return json_decode($response, true);
}
// Voucher erstellen
// Einzelnen Voucher erstellen
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480) {
$vouchers = $this->createVouchers($voucherName, $maxUses, $expireMinutes, 1);
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.
*
* @return array Liste von ['code','formatted_code','unifi_id','create_time']
*/
public function createVouchers($voucherName, $maxUses, $expireMinutes = 480, $count = 1) {
$count = max(1, (int)$count);
$data = [
'cmd' => 'create-voucher',
'expire' => (int)$expireMinutes,
'n' => 1,
'n' => $count,
'note' => $voucherName,
'quota' => (int)$maxUses
];
@ -170,51 +187,51 @@ class UniFiController {
if (!isset($response['data'][0]['create_time'])) {
throw new Exception("Voucher konnte nicht erstellt werden");
}
$createTime = $response['data'][0]['create_time'];
// Voucher-Code abrufen. WICHTIG: getVouchers() liefert die Voucher
// 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();
$all = $this->getVouchers();
if (empty($vouchers)) {
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
}
$latestVoucher = null;
foreach ($vouchers as $voucher) {
// Nur Voucher mit passender Notiz beruecksichtigen
if (($voucher['note'] ?? null) !== $voucherName) {
continue;
}
if ($latestVoucher === null
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) {
$latestVoucher = $voucher;
// Exakte Treffer: gleiche note UND die vom Controller gemeldete create_time
$matches = [];
foreach ($all as $voucher) {
if (($voucher['note'] ?? null) === $voucherName
&& ($voucher['create_time'] ?? null) == $createTime) {
$matches[] = $voucher;
}
}
// Fallback: falls keine note-Uebereinstimmung (z.B. Sonderzeichen),
// 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;
// Fallback: nur note matchen (falls der Controller create_time leicht
// abweichend meldet), neueste zuerst, auf $count begrenzen.
if (empty($matches)) {
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);
}
if ($latestVoucher === null || empty($latestVoucher['code'])) {
$result = [];
foreach ($matches as $voucher) {
if (empty($voucher['code'])) {
continue;
}
$result[] = [
'code' => $voucher['code'],
'formatted_code' => $this->formatVoucherCode($voucher['code']),
'unifi_id' => $voucher['_id'] ?? null,
'create_time' => $voucher['create_time'] ?? null
];
}
if (empty($result)) {
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
}
return [
'code' => $latestVoucher['code'],
'formatted_code' => $this->formatVoucherCode($latestVoucher['code']),
'unifi_id' => $latestVoucher['_id'] ?? null,
'create_time' => $latestVoucher['create_time'] ?? null
];
return $result;
}
// Alle Voucher abrufen
@ -339,17 +356,26 @@ class UniFiController {
// Alle aktuellen UniFi-IDs sammeln
$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) {
$unifiIds[] = $voucher['_id'];
// Status zählen
$stats[$voucher['status']]++;
// Prüfen ob Voucher bereits existiert
$existing = $db->fetchOne(
"SELECT id, status, used_count FROM vouchers WHERE unifi_voucher_id = ? AND site_id = ?",
[$voucher['_id'], $dbSiteId]
);
$existing = isset($existingMap[$voucher['_id']])
? ['id' => $existingMap[$voucher['_id']]]
: null;
$expiresAt = date('Y-m-d H:i:s', $voucher['expire_time']);
$createdAt = date('Y-m-d H:i:s', $voucher['create_time']);

216
index.php
View file

@ -17,6 +17,7 @@ require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/UniFiController.php';
require_once __DIR__ . '/includes/Mailer.php';
require_once __DIR__ . '/includes/I18n.php';
require_once __DIR__ . '/includes/Helpers.php';
$auth = new Auth();
$db = Database::getInstance();
@ -24,29 +25,71 @@ $mailer = new Mailer();
I18n::init();
/**
* Session-basierter Throttle fuer die anonyme oeffentliche Voucher-Erstellung.
* Erlaubt max. 10 Erstellungen in 10 Minuten pro Session. Verhindert, dass
* der oeffentliche Modus zum Spammen des UniFi-Controllers missbraucht wird.
* Throttle fuer die anonyme oeffentliche Voucher-Erstellung:
* max. 10 Voucher in 10 Minuten. Primaer IP-basiert ueber die Tabelle
* 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() {
$window = 600; // 10 Minuten
$maxRequests = 10;
function isVoucherRateLimited($db, $voucherCount = 1) {
$window = 600; // 10 Minuten
$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();
$timestamps = $_SESSION['voucher_create_times'] ?? [];
$timestamps = array_values(array_filter($timestamps, function ($t) use ($now, $window) {
return ($now - $t) < $window;
}));
if (count($timestamps) >= $maxRequests) {
if (count($timestamps) + $voucherCount > $maxVouchers) {
$_SESSION['voucher_create_times'] = $timestamps;
return true;
}
$timestamps[] = $now;
for ($i = 0; $i < $voucherCount; $i++) {
$timestamps[] = $now;
}
$_SESSION['voucher_create_times'] = $timestamps;
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]);
}
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
$faviconUrl = $db->getSetting('favicon_url', '');
$instructionHeader = $db->getSetting('instruction_header', '');
$instructionText = $db->getSetting('instruction_text', '');
$publicAccess = $db->getSetting('public_access', 0);
@ -128,14 +171,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
} elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
// CSRF fuer ALLE (auch anonyme oeffentliche Erstellung)
$error = __('error_csrf');
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) {
$error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.';
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited($db)) {
$error = __('error_rate_limited');
} else {
try {
$siteId = (int)($_POST['site_id'] ?? 0);
$voucherName = trim((string)($_POST['voucher_name'] ?? ''));
$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']);
$recipientEmail= trim((string)($_POST['recipient_email'] ?? ''));
@ -156,57 +199,124 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
if ($sendEmail && !empty($recipientEmail)) {
$mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses);
$success = 'Voucher erstellt. E-Mail versendet.';
$success = __('voucher_created_mail');
} else {
$success = 'Voucher erfolgreich erstellt!';
$success = __('voucher_created_ok');
}
$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) {
$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 (!$publicAccess && !$auth->isLoggedIn()) {
if (!$auth->isLoggedIn()) {
$error = __('error_login_req');
} elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
// CSRF fuer ALLE (auch anonyme oeffentliche Erstellung)
$error = __('error_csrf');
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) {
$error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.';
} else {
try {
$siteId = (int)($_POST['site_id'] ?? 0);
$voucherName = trim((string)($_POST['voucher_name'] ?? ''));
$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)));
if (empty($voucherName)) throw new Exception(__('error_name_req'));
if ($maxUses < 1 || $maxUses > $maxUsesLimit) throw new Exception(__('error_devices_range', ['max' => $maxUsesLimit]));
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'));
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
if (!$site) throw new Exception(__('error_site_not_found'));
$userId = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : null;
$userId = $_SESSION['user_id'] ?? null;
for ($i = 0; $i < $bulkCount; $i++) {
$bulkVouchers[] = doCreateVoucher($db, $site, $voucherName . '_' . ($i + 1), $maxUses, $expireMinutes, $userId);
// Alle Voucher in EINEM UniFi-API-Call erstellen ('n'-Parameter)
// 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']
);
$created = $controller->createVouchers($fullName, $maxUses, $expireMinutes, $bulkCount);
$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),
];
}
$bulkCreated = true;
$success = str_replace('{count}', $bulkCount, __('bulk_success'));
$auth->writeAuditLog($userId, 'voucher_bulk', 'voucher', null,
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) {
$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;
// 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');
// Build print HTML for each voucher
function buildPrintCard($template, $data, $instructionHeader, $instructionText, $appTitle) {
$instructions = $instructionHeader || $instructionText
@ -225,6 +335,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($appTitle) ?></title>
<?php if ($faviconUrl): ?>
<link rel="icon" href="<?= htmlspecialchars($faviconUrl) ?>">
<?php endif; ?>
<link rel="stylesheet" href="assets/global.css">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<?php if ($voucherCreated): ?>
@ -241,9 +354,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; }
h1 { text-align: center; color: var(--text-primary); margin-bottom: 30px; font-size: 28px; }
.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; }
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); }
@ -349,11 +459,11 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<div class="voucher-result no-print">
<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) ?>
</div>
<div class="voucher-info">
<?= str_replace('{minutes}', $voucherData['expire_min'], __('voucher_validity')) ?>
<?= str_replace('{duration}', formatDuration($voucherData['expire_min']), __('voucher_validity')) ?>
</div>
<div class="qr-wrapper no-print">
<div id="qrcode"></div>
@ -401,8 +511,8 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<tr>
<td><?= $i + 1 ?></td>
<td>
<code onclick="copyToClipboard('<?= addslashes($bv['code']) ?>', 'Kopiert!')"
title="Klicken zum Kopieren"><?= htmlspecialchars($bv['code']) ?></code>
<code onclick="copyToClipboard('<?= addslashes($bv['code']) ?>', '<?= addslashes(__('toast_copied')) ?>')"
title="<?= __('click_to_copy') ?>"><?= htmlspecialchars($bv['code']) ?></code>
</td>
<td><?= htmlspecialchars($bv['site_name']) ?></td>
<td><?= $bv['expiry_date'] ?> <?= $bv['expiry_time'] ?></td>
@ -410,7 +520,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<?php endforeach; ?>
</tbody>
</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 class="no-print" style="display:flex;gap:10px;margin-top:20px;">
@ -473,13 +583,14 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<div class="form-group">
<label for="voucher_name"><?= __('voucher_name_label') ?></label>
<input type="text" id="voucher_name" name="voucher_name"
value="<?= htmlspecialchars($stickyName) ?>"
placeholder="<?= __('voucher_name_hint') ?>" required>
</div>
<div class="form-group">
<label for="max_uses"><?= __('voucher_devices_label') ?></label>
<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 class="form-group">
@ -489,7 +600,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<option value=""><?= __('voucher_site_select') ?></option>
<?php endif; ?>
<?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']) ?>
</option>
<?php endforeach; ?>
@ -520,7 +631,8 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
</form>
</div>
<!-- Bulk creation form -->
<!-- Bulk creation form (nur fuer eingeloggte Nutzer, serverseitig erzwungen) -->
<?php if ($auth->isLoggedIn()): ?>
<div id="mode-bulk" style="display:none;">
<form method="post" id="bulkForm">
<input type="hidden" name="create_bulk" value="1">
@ -530,20 +642,21 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<div class="form-group">
<label for="bulk_count"><?= __('bulk_quantity') ?></label>
<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>
</div>
<div class="form-group">
<label for="bulk_voucher_name"><?= __('bulk_name_prefix') ?></label>
<input type="text" id="bulk_voucher_name" name="voucher_name"
value="<?= $activeMode === 'bulk' ? htmlspecialchars($stickyName) : '' ?>"
placeholder="<?= __('voucher_name_hint') ?>" required>
</div>
<div class="form-group">
<label for="bulk_max_uses"><?= __('voucher_devices_label') ?></label>
<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 class="form-group">
@ -553,7 +666,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<option value=""><?= __('voucher_site_select') ?></option>
<?php endif; ?>
<?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']) ?>
</option>
<?php endforeach; ?>
@ -561,10 +674,11 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
</div>
<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>
</form>
</div>
<?php endif; ?>
<?php if ($instructionHeader || $instructionText): ?>
<div class="instruction-box" style="margin-top:25px;">
@ -581,16 +695,18 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<script>
<?php if ($voucherCreated): ?>
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'), {
text: '<?= addslashes($voucherCode) ?>',
width: 160, height: 160,
colorDark: '#ffffff', colorLight: 'transparent',
colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
});
function copyCode() {
copyToClipboard('<?= addslashes($voucherCode) ?>', 'Code kopiert!');
copyToClipboard('<?= addslashes($voucherCode) ?>', '<?= addslashes(__('toast_copied')) ?>');
}
<?php endif; ?>
@ -613,8 +729,11 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
}
function switchMode(mode) {
document.getElementById('mode-single').style.display = mode === 'single' ? '' : 'none';
document.getElementById('mode-bulk').style.display = mode === 'bulk' ? '' : 'none';
const single = document.getElementById('mode-single');
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-bulk').classList.toggle('active', mode === 'bulk');
}
@ -624,8 +743,10 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
const expire = opt.value ? parseInt(opt.dataset.expire) : <?= $defaultExpire ?>;
const maxUses = opt.value ? parseInt(opt.dataset.maxUses) : <?= $defaultMaxUses ?>;
document.getElementById('expire_minutes').value = expire;
document.getElementById('bulk_expire_minutes').value = expire;
const expEl = document.getElementById('expire_minutes');
if (expEl) expEl.value = expire;
const bexpEl = document.getElementById('bulk_expire_minutes');
if (bexpEl) bexpEl.value = expire;
const muEl = document.getElementById('max_uses');
if (muEl) muEl.value = maxUses;
const bmuEl = document.getElementById('bulk_max_uses');
@ -662,6 +783,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
document.addEventListener('DOMContentLoaded', function() {
toggleEmailField?.();
<?php if ($activeMode === 'bulk'): ?>
switchMode('bulk'); // Nach Fehler im Bulk-Formular im Bulk-Tab bleiben
<?php endif; ?>
});
</script>
</body>

View file

@ -159,11 +159,12 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
file_put_contents(__DIR__ . '/config.php', $configContent);
// .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 .= "# Security\n";
$htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|m365_debug\\.php|\\.md)$\">\n";
$htaccess .= " Order Allow,Deny\n";
$htaccess .= " Deny from all\n";
$htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|m365_debug\\.php|cron_test\\.php|\\.md)$\">\n";
$htaccess .= " Require all denied\n";
$htaccess .= "</FilesMatch>\n\n";
$htaccess .= "DirectoryIndex index.php\n";
file_put_contents(__DIR__ . '/.htaccess', $htaccess);

View file

@ -93,12 +93,12 @@ return [
'voucher_create_btn' => 'Voucher erstellen',
'voucher_creating' => 'Erstelle Voucher...',
'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_print_btn' => 'Code ausdrucken',
'voucher_no_sites' => 'Keine verfügbaren Sites gefunden.',
'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_label'=> 'Schnellprofil (optional)',
@ -286,4 +286,59 @@ return [
'never' => 'Noch nie',
'unknown' => 'Unbekannt',
'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.',
// 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

@ -93,12 +93,12 @@ return [
'voucher_create_btn' => 'Create Voucher',
'voucher_creating' => 'Creating Voucher...',
'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_print_btn' => 'Print Code',
'voucher_no_sites' => 'No available sites found.',
'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_label'=> 'Quick Profile (optional)',
@ -286,4 +286,59 @@ return [
'never' => 'Never',
'unknown' => 'Unknown',
'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.',
// 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

@ -47,6 +47,7 @@ try {
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
$faviconUrl = $db->getSetting('favicon_url', '');
$m365ClientId = $db->getSetting('m365_client_id', '');
$m365ClientSecret = $db->getSetting('m365_client_secret', '');
@ -86,6 +87,9 @@ try {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<style>
@ -107,9 +111,6 @@ try {
.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 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:hover { text-decoration: underline; }
.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);
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>");
}
// Benutzer einloggen oder anlegen
$microsoftUser = [
'id' => $userData['id'],
'email' => $userData['mail'] ?? $userData['userPrincipalName'],
'name' => $userData['displayName'] ?? $userData['givenName'] . ' ' . $userData['surname']
'email' => $userEmail,
'name' => $userData['displayName'] ?? trim(($userData['givenName'] ?? '') . ' ' . ($userData['surname'] ?? ''))
];
try {

View file

@ -15,6 +15,7 @@ I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
$faviconUrl = $db->getSetting('favicon_url', '');
$token = trim($_GET['token'] ?? '');
$error = '';
@ -69,6 +70,9 @@ if ($valid && $_SERVER['REQUEST_METHOD'] === 'POST') {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<style>
@ -84,9 +88,6 @@ if ($valid && $_SERVER['REQUEST_METHOD'] === 'POST') {
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: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:hover { text-decoration: underline; }
.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
> 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;
/** @var UpdateManager */
private $manager;
/** @var AuditLogger */
private $audit;
public function __construct(\Database $db, \Auth $auth)
{
$this->db = $db;
$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

View file

@ -197,18 +197,33 @@ class UpdateManager
}
$stagingRoot = $this->resolveStagingRoot($stagingDir);
// 4) Staging -> Production (geschuetzte Pfade ueberspringen)
$this->setProgress(65, 'Wende Update an …');
$this->applyStaging($stagingRoot);
// 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);
// 5) Migrationen ausfuehren
$this->setProgress(80, 'Fuehre Datenbank-Migrationen aus …');
$runner = new MigrationRunner(
$this->db->getConnection(),
__DIR__ . '/migrations',
$this->storageDir
);
$runner->runPending(true);
try {
// 5) Staging -> Production (geschuetzte Pfade ueberspringen)
$this->setProgress(65, 'Wende Update an …');
$this->applyStaging($stagingRoot);
// 6) Migrationen ausfuehren
$this->setProgress(80, 'Fuehre Datenbank-Migrationen aus …');
$runner = new MigrationRunner(
$this->db->getConnection(),
__DIR__ . '/migrations',
$this->storageDir
);
$runner->runPending(true);
} catch (\Throwable $e) {
$this->setProgress(70, 'Fehler stelle vorherigen Stand wieder her …');
$this->restoreBackup($backupDir);
throw $e;
}
// 6) Caches leeren
$this->setProgress(90, 'Leere Caches …');
@ -280,6 +295,15 @@ class UpdateManager
if ($zip->open($zipPath) !== true) {
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)) {
@mkdir($dest, 0775, true);
}
@ -308,6 +332,10 @@ class UpdateManager
continue;
}
$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)));
if ($st !== 200) {
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
{
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;