Compare commits
4 commits
main
...
claude/too
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5496f49fd5 | ||
|
|
1a2f86ed3d | ||
|
|
968afbb212 | ||
|
|
6e19958a37 |
30
.github/workflows/lint.yml
vendored
Normal 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";
|
||||
'
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -26,9 +26,12 @@ 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']);
|
||||
$ctrl = new UniFiController($site['unifi_controller_url'], $site['unifi_username'], Crypto::decrypt($site['unifi_password']), $site['site_id'], $site['ssl_verify'] ?? 0);
|
||||
$ctrl->syncVouchersToDatabase($db, $site['id']);
|
||||
} catch (Exception $e) {
|
||||
$syncErrors[$site['id']] = $e->getMessage();
|
||||
|
|
@ -122,11 +125,6 @@ $currentPage = 'dashboard';
|
|||
.table th { text-align: left; padding: 11px 14px; background: var(--bg-table-head); color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.table 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>
|
||||
|
|
|
|||
|
|
@ -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'] ?? '');
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +90,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
|
|||
$settings['smtp_password'] = trim($_POST['smtp_password']);
|
||||
}
|
||||
$settings['smtp_encryption'] = trim($_POST['smtp_encryption'] ?? 'tls');
|
||||
$settings['smtp_verify_ssl'] = isset($_POST['smtp_verify_ssl']) ? '1' : '0';
|
||||
$settings['smtp_from_email'] = trim($_POST['smtp_from_email'] ?? '');
|
||||
$settings['smtp_from_name'] = trim($_POST['smtp_from_name'] ?? '');
|
||||
}
|
||||
|
|
@ -106,7 +112,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 +133,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 +143,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 +157,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);
|
||||
|
|
@ -181,6 +205,7 @@ $cs = [
|
|||
'smtp_username' => $db->getSetting('smtp_username', ''),
|
||||
'smtp_password' => $db->getSetting('smtp_password', ''),
|
||||
'smtp_encryption' => $db->getSetting('smtp_encryption', 'tls'),
|
||||
'smtp_verify_ssl' => $db->getSetting('smtp_verify_ssl', '0'),
|
||||
'smtp_from_email' => $db->getSetting('smtp_from_email', ''),
|
||||
'smtp_from_name' => $db->getSetting('smtp_from_name', ''),
|
||||
'system_url' => $db->getSetting('system_url', $autoDetectedUrl),
|
||||
|
|
@ -215,9 +240,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 +372,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 +411,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>
|
||||
|
|
@ -407,6 +429,7 @@ $adminBase = '';
|
|||
<div class="form-group"><label>Port</label><input type="number" name="smtp_port" value="<?= htmlspecialchars($cs['smtp_port']) ?>"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Verschlüsselung</label><select name="smtp_encryption"><option value="tls" <?= $cs['smtp_encryption']==='tls'?'selected':'' ?>>TLS</option><option value="ssl" <?= $cs['smtp_encryption']==='ssl'?'selected':'' ?>>SSL</option><option value="none" <?= $cs['smtp_encryption']==='none'?'selected':'' ?>>Keine</option></select></div>
|
||||
<div class="checkbox-group" style="margin-bottom: 20px;"><input type="checkbox" name="smtp_verify_ssl" id="smtp_verify_ssl" <?= $cs['smtp_verify_ssl'] == '1' ? 'checked' : '' ?>><label for="smtp_verify_ssl" style="margin:0;"><?= __('smtp_verify_ssl') ?></label></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group"><label>Benutzername</label><input type="text" name="smtp_username" value="<?= htmlspecialchars($cs['smtp_username']) ?>"></div>
|
||||
<div class="form-group"><label>Passwort</label><input type="password" name="smtp_password" placeholder="Leer = nicht ändern"></div>
|
||||
|
|
|
|||
163
admin/sites.php
|
|
@ -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,32 @@ 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'],
|
||||
$site['ssl_verify'] ?? 0
|
||||
);
|
||||
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']??'')) {
|
||||
|
|
@ -31,18 +58,27 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) {
|
|||
$username = trim($_POST['username']);
|
||||
$password = $_POST['password'];
|
||||
$publicAccess = isset($_POST['public_access']) ? 1 : 0;
|
||||
$sslVerify = isset($_POST['ssl_verify']) ? 1 : 0;
|
||||
if (empty($name)||empty($siteIdStr)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
|
||||
if (!empty($password)) {
|
||||
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr);
|
||||
if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$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]);
|
||||
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr,$sslVerify);
|
||||
if ($test !== true) throw new Exception(__('site_test_fail').': '.$test);
|
||||
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,unifi_password=?,public_access=?,ssl_verify=? WHERE id=?",
|
||||
[$name,$siteIdStr,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$sslVerify,$siteId]);
|
||||
} else {
|
||||
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=? WHERE id=?",
|
||||
[$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$siteId]);
|
||||
// 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,$sslVerify);
|
||||
if ($test !== true) throw new Exception(__('site_test_fail').': '.$test);
|
||||
$db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=?,ssl_verify=? WHERE id=?",
|
||||
[$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$sslVerify,$siteId]);
|
||||
}
|
||||
$auth->writeAuditLog($_SESSION['user_id'],'site_edit','site',$siteId,"Site {$name} aktualisiert");
|
||||
$success = __('sites_updated');
|
||||
flashSet(__('sites_updated'));
|
||||
header('Location: sites.php');
|
||||
exit;
|
||||
} catch (Exception $e) { $error = $e->getMessage(); }
|
||||
}
|
||||
}
|
||||
|
|
@ -59,38 +95,49 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_site'])) {
|
|||
$username = trim($_POST['username']);
|
||||
$password = $_POST['password'];
|
||||
$publicAccess = isset($_POST['public_access']) ? 1 : 0;
|
||||
$sslVerify = isset($_POST['ssl_verify']) ? 1 : 0;
|
||||
if (empty($name)||empty($siteId)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all'));
|
||||
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId);
|
||||
if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$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]);
|
||||
$test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId,$sslVerify);
|
||||
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,ssl_verify) VALUES (?,?,?,?,?,?,?)",
|
||||
[$name,$siteId,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$sslVerify]);
|
||||
$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 +151,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 +160,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); }
|
||||
|
|
@ -200,20 +240,31 @@ $currentPage = 'sites';
|
|||
</div>
|
||||
</div>
|
||||
<div class="site-actions">
|
||||
<button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>)"
|
||||
<button onclick="openEditModal(<?= $site['id'] ?>, '<?= htmlspecialchars($site['name'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['site_id'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_controller_url'], ENT_QUOTES) ?>', '<?= htmlspecialchars($site['unifi_username'], ENT_QUOTES) ?>', <?= $site['public_access'] ?>, <?= (int)($site['ssl_verify'] ?? 0) ?>)"
|
||||
class="btn btn-secondary btn-sm">
|
||||
<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; ?>
|
||||
|
|
@ -261,6 +312,11 @@ $currentPage = 'sites';
|
|||
<input type="checkbox" id="add_public" name="public_access">
|
||||
<label for="add_public" style="margin:0;"><?= __('sites_public') ?></label>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="add_ssl_verify" name="ssl_verify">
|
||||
<label for="add_ssl_verify" style="margin:0;"><?= __('sites_ssl_verify') ?></label>
|
||||
</div>
|
||||
<small style="color:var(--text-muted);font-size:12px;display:block;margin-top:-10px;margin-bottom:14px;"><?= __('sites_ssl_verify_hint') ?></small>
|
||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||
<button type="submit" class="btn btn-primary" style="flex:1;" id="addSiteSubmitBtn">
|
||||
<i class="fas fa-save"></i> <?= __('sites_add') ?>
|
||||
|
|
@ -311,6 +367,11 @@ $currentPage = 'sites';
|
|||
<input type="checkbox" id="edit_public_access" name="public_access">
|
||||
<label for="edit_public_access" style="margin:0;"><?= __('sites_public') ?></label>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="edit_ssl_verify" name="ssl_verify">
|
||||
<label for="edit_ssl_verify" style="margin:0;"><?= __('sites_ssl_verify') ?></label>
|
||||
</div>
|
||||
<small style="color:var(--text-muted);font-size:12px;display:block;margin-top:-10px;margin-bottom:14px;"><?= __('sites_ssl_verify_hint') ?></small>
|
||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||
<button type="submit" class="btn btn-primary" style="flex:1;" id="editSiteSubmitBtn">
|
||||
<i class="fas fa-save"></i> <?= __('btn_save') ?>
|
||||
|
|
@ -328,7 +389,7 @@ $currentPage = 'sites';
|
|||
function openModal() { document.getElementById('addSiteModal').classList.add('active'); }
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
||||
|
||||
function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAccess) {
|
||||
function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAccess, sslVerify) {
|
||||
document.getElementById('edit_site_id').value = id;
|
||||
document.getElementById('edit_name').value = name;
|
||||
document.getElementById('edit_site_id_str').value = siteIdStr;
|
||||
|
|
@ -336,6 +397,7 @@ function openEditModal(id, name, siteIdStr, controllerUrl, username, publicAcces
|
|||
document.getElementById('edit_username').value = username;
|
||||
document.getElementById('edit_password').value = '';
|
||||
document.getElementById('edit_public_access').checked = publicAccess == 1;
|
||||
document.getElementById('edit_ssl_verify').checked = sslVerify == 1;
|
||||
document.getElementById('editSiteModal').classList.add('active');
|
||||
}
|
||||
|
||||
|
|
@ -355,6 +417,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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -41,7 +42,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_template'])) {
|
|||
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']]
|
||||
);
|
||||
$success = __('templates_added');
|
||||
flashSet(__('templates_added'));
|
||||
header('Location: templates.php');
|
||||
exit;
|
||||
} catch (Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
|
@ -71,23 +74,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) {
|
|||
"UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, qos_rate_max_down=?, qos_rate_max_up=?, qos_usage_quota=?, is_active=? WHERE id=?",
|
||||
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $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';
|
||||
|
|
@ -103,9 +114,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); }
|
||||
|
|
@ -114,9 +122,6 @@ $adminBase = '';
|
|||
.table td { padding: 14px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); font-size: 14px; }
|
||||
.table 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; }
|
||||
|
|
@ -214,9 +219,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'] ?>, <?= (int)($t['qos_rate_max_down'] ?? 0) ?>, <?= (int)($t['qos_rate_max_up'] ?? 0) ?>, <?= (int)($t['qos_usage_quota'] ?? 0) ?>)"
|
||||
class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button>
|
||||
<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; ?>
|
||||
|
|
|
|||
139
admin/users.php
|
|
@ -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,59 +114,72 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_user'])) {
|
|||
if (empty($email)||empty($name)||empty($password)) throw new Exception(__('error_fill_all'));
|
||||
if (!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'); }
|
||||
}
|
||||
|
||||
// 2FA eines Benutzers zurücksetzen (Admin-Hilfe bei verlorenem Authenticator)
|
||||
if (isset($_GET['reset_2fa']) && isset($_GET['token'])) {
|
||||
if ($auth->validateCsrfToken($_GET['token'])) {
|
||||
$auth->disableTotp((int)$_GET['reset_2fa']);
|
||||
$success = '2FA des Benutzers wurde zurückgesetzt.';
|
||||
// POST + PRG wie die uebrigen state-aendernden Aktionen
|
||||
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['reset_2fa'])) {
|
||||
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
$auth->disableTotp((int)$_POST['reset_2fa']);
|
||||
flashSet('2FA des Benutzers wurde zurückgesetzt.');
|
||||
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 = [];
|
||||
|
|
@ -177,19 +201,11 @@ $currentPage = 'users';
|
|||
.card { background: var(--bg-card); border-radius: 14px; box-shadow: 0 2px 10px var(--shadow); border: 1px solid var(--border-color); overflow: hidden; margin-bottom: 24px; }
|
||||
.card-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); }
|
||||
|
|
@ -301,29 +317,46 @@ $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; ?>
|
||||
<?php if (!empty($user['totp_enabled'])): ?>
|
||||
<a href="?reset_2fa=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||
class="btn btn-secondary btn-sm" title="2FA zurücksetzen"
|
||||
onclick="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
</a>
|
||||
<form method="post" style="display:inline;"
|
||||
onsubmit="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
<input type="hidden" name="reset_2fa" value="<?= $user['id'] ?>">
|
||||
<button type="submit" class="btn btn-secondary btn-sm"
|
||||
title="2FA zurücksetzen" aria-label="2FA zurücksetzen">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -44,7 +50,7 @@ if (isset($_GET['ajax_get_vouchers']) && isset($_GET['site_id'])) {
|
|||
if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; }
|
||||
if ($syncFirst) {
|
||||
try {
|
||||
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id']);
|
||||
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id'],$site['ssl_verify'] ?? 0);
|
||||
$ctrl->syncVouchersToDatabase($db,$siteId);
|
||||
$db->execute("INSERT INTO settings (setting_key,setting_value) VALUES ('last_cron_sync',NOW()) ON DUPLICATE KEY UPDATE setting_value=NOW()");
|
||||
} catch (Exception $e) { error_log("Sync error: ".$e->getMessage()); }
|
||||
|
|
@ -83,12 +89,12 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
|
|||
$siteId = (int)$_POST['site_id'];
|
||||
$site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]);
|
||||
if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; }
|
||||
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id']);
|
||||
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id'],$site['ssl_verify'] ?? 0);
|
||||
if ($ctrl->deleteVoucher($voucherId)) {
|
||||
$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()]);
|
||||
|
|
@ -170,11 +176,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; }
|
||||
|
|
@ -289,7 +290,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());
|
||||
|
|
@ -305,7 +306,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';
|
||||
|
|
@ -397,7 +398,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>
|
||||
|
|
@ -444,7 +445,7 @@ async function resendVoucher(voucherId, code) {
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 === */
|
||||
|
|
|
|||
|
|
@ -117,7 +117,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'
|
||||
|
|
@ -174,7 +175,8 @@ try {
|
|||
$site['unifi_controller_url'],
|
||||
$site['unifi_username'],
|
||||
Crypto::decrypt($site['unifi_password']),
|
||||
$site['site_id']
|
||||
$site['site_id'],
|
||||
$site['ssl_verify'] ?? 0
|
||||
);
|
||||
|
||||
$stats = $controller->syncVouchersToDatabase($db, $site['id']);
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
11
database.sql
|
|
@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `sites` (
|
|||
`unifi_password` VARCHAR(255) NOT NULL,
|
||||
`is_active` TINYINT(1) DEFAULT 1,
|
||||
`public_access` TINYINT(1) DEFAULT 0,
|
||||
`ssl_verify` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX `idx_active` (`is_active`)
|
||||
|
|
@ -147,6 +148,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,
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1,022 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 938 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 1,024 KiB After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 920 KiB |
|
Before Width: | Height: | Size: 972 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
|
@ -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 = '';
|
||||
|
||||
|
|
@ -34,7 +45,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||
} else {
|
||||
$rl[] = $now;
|
||||
$_SESSION['pwreset_times'] = $rl;
|
||||
$user = $db->fetchOne("SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL", [$email]);
|
||||
|
||||
// Zusaetzlich zum Session-Throttle: IP-Rate-Limit gegen Mail-Bombing
|
||||
// (max. 5 Reset-Anfragen / 15 Min., umgeht Cookie-Loeschen). Bei Limit
|
||||
// trotzdem die generische Erfolgsmeldung zeigen (keine Information
|
||||
// darueber preisgeben, ob das Konto existiert).
|
||||
$resetLimited = throttleHit($db, 'password_reset', 5, 15) === true;
|
||||
|
||||
$user = $resetLimited
|
||||
? null
|
||||
: $db->fetchOne("SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL", [$email]);
|
||||
|
||||
// Always show success (don't reveal whether email exists)
|
||||
if ($user) {
|
||||
|
|
@ -83,6 +103,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>
|
||||
|
|
@ -98,9 +121,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>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ require_once __DIR__ . '/Crypto.php';
|
|||
|
||||
class Auth {
|
||||
private $db;
|
||||
/** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */
|
||||
private $sessionUser = false;
|
||||
|
||||
public function __construct() {
|
||||
try {
|
||||
|
|
@ -18,6 +20,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);
|
||||
}
|
||||
|
||||
// Opt-in: Sessions in der DB ablegen (für "überall abmelden" / Skalierung)
|
||||
try {
|
||||
|
|
@ -243,6 +248,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]
|
||||
|
|
@ -309,6 +316,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'];
|
||||
|
|
@ -331,6 +344,7 @@ class Auth {
|
|||
|
||||
// Ausloggen
|
||||
public function logout() {
|
||||
$this->sessionUser = null;
|
||||
$_SESSION = [];
|
||||
|
||||
if (isset($_COOKIE[session_name()])) {
|
||||
|
|
@ -340,6 +354,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'])) {
|
||||
|
|
@ -354,12 +387,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
|
||||
|
|
@ -367,11 +412,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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ class Mailer {
|
|||
private $smtpUsername;
|
||||
private $smtpPassword;
|
||||
private $smtpEncryption;
|
||||
private $smtpVerifySsl;
|
||||
private $fromEmail;
|
||||
private $fromName;
|
||||
|
||||
|
|
@ -22,7 +23,8 @@ 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->smtpVerifySsl = $this->db->getSetting('smtp_verify_ssl', '0') === '1';
|
||||
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost'));
|
||||
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System'));
|
||||
}
|
||||
|
||||
|
|
@ -60,23 +62,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}>");
|
||||
|
|
@ -101,11 +109,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";
|
||||
|
|
@ -126,11 +135,12 @@ class Mailer {
|
|||
}
|
||||
|
||||
private function connectToSmtp() {
|
||||
// Zertifikatspruefung optional aktivierbar (Setting smtp_verify_ssl)
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true
|
||||
'verify_peer' => $this->smtpVerifySsl,
|
||||
'verify_peer_name' => $this->smtpVerifySsl,
|
||||
'allow_self_signed' => !$this->smtpVerifySsl
|
||||
]
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,15 @@ class UniFiController {
|
|||
private $csrfToken = null;
|
||||
private $sessionCookie = null;
|
||||
private $loggedIn = false;
|
||||
/** SSL-Zertifikat pruefen? Default aus, da UnifFi-Controller meist self-signed sind. */
|
||||
private $sslVerify = false;
|
||||
|
||||
public function __construct($controllerUrl, $username, $password, $siteId) {
|
||||
public function __construct($controllerUrl, $username, $password, $siteId, $sslVerify = false) {
|
||||
$this->controllerUrl = rtrim($controllerUrl, '/');
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
$this->siteId = $siteId;
|
||||
$this->sslVerify = (bool)$sslVerify;
|
||||
$this->cookieFile = tempnam(sys_get_temp_dir(), 'UNIFI_');
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +44,8 @@ class UniFiController {
|
|||
'password' => $this->password
|
||||
]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYPEER => $this->sslVerify,
|
||||
CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0,
|
||||
CURLOPT_COOKIEJAR => $this->cookieFile,
|
||||
CURLOPT_COOKIEFILE => $this->cookieFile,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
|
|
@ -116,7 +120,8 @@ class UniFiController {
|
|||
$options = [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYPEER => $this->sslVerify,
|
||||
CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_HTTPHEADER => $headers
|
||||
|
|
@ -155,13 +160,31 @@ class UniFiController {
|
|||
return json_decode($response, true);
|
||||
}
|
||||
|
||||
// Voucher erstellen
|
||||
// Einzelnen Voucher erstellen
|
||||
// $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
|
||||
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480, $options = []) {
|
||||
$vouchers = $this->createVouchers($voucherName, $maxUses, $expireMinutes, 1, $options);
|
||||
return $vouchers[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt $count Voucher in EINEM API-Call (UniFi 'n'-Parameter) statt
|
||||
* pro Voucher Login + Full-Fetch auszufuehren.
|
||||
*
|
||||
* Matching: Die create-voucher-Antwort liefert die create_time der neuen
|
||||
* Voucher; darueber (plus note) werden exakt die soeben erstellten Codes
|
||||
* identifiziert. Der fruehere Fallback "global neuester Voucher" konnte
|
||||
* bei parallelen Erstellungen fremde Codes liefern und wurde entfernt.
|
||||
*
|
||||
* @param array $options Optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
|
||||
* @return array Liste von ['code','formatted_code','unifi_id','create_time']
|
||||
*/
|
||||
public function createVouchers($voucherName, $maxUses, $expireMinutes = 480, $count = 1, $options = []) {
|
||||
$count = max(1, (int)$count);
|
||||
$data = [
|
||||
'cmd' => 'create-voucher',
|
||||
'expire' => (int)$expireMinutes,
|
||||
'n' => 1,
|
||||
'n' => $count,
|
||||
'note' => $voucherName,
|
||||
'quota' => (int)$maxUses
|
||||
];
|
||||
|
|
@ -182,51 +205,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
|
||||
|
|
@ -320,9 +343,9 @@ class UniFiController {
|
|||
}
|
||||
|
||||
// Verbindung testen
|
||||
public static function testConnection($controllerUrl, $username, $password, $siteId) {
|
||||
public static function testConnection($controllerUrl, $username, $password, $siteId, $sslVerify = false) {
|
||||
try {
|
||||
$controller = new self($controllerUrl, $username, $password, $siteId);
|
||||
$controller = new self($controllerUrl, $username, $password, $siteId, $sslVerify);
|
||||
$controller->login();
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
|
|
@ -351,17 +374,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']);
|
||||
|
|
|
|||
225
index.php
|
|
@ -20,6 +20,7 @@ require_once __DIR__ . '/includes/Notifier.php';
|
|||
require_once __DIR__ . '/includes/Captcha.php';
|
||||
require_once __DIR__ . '/includes/Sms.php';
|
||||
require_once __DIR__ . '/includes/I18n.php';
|
||||
require_once __DIR__ . '/includes/Helpers.php';
|
||||
|
||||
$auth = new Auth();
|
||||
$db = Database::getInstance();
|
||||
|
|
@ -27,27 +28,68 @@ $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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionales Tageslimit pro (Nicht-Admin-)Benutzer (Setting
|
||||
* user_daily_voucher_limit, 0 = aus). Verhindert übermäßige Erstellung.
|
||||
|
|
@ -70,6 +112,7 @@ function userDailyLimitExceeded($db, $auth, $additional = 1) {
|
|||
|
||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||
$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);
|
||||
|
|
@ -122,7 +165,8 @@ function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $us
|
|||
$site['unifi_controller_url'],
|
||||
$site['unifi_username'],
|
||||
Crypto::decrypt($site['unifi_password']),
|
||||
$site['site_id']
|
||||
$site['site_id'],
|
||||
$site['ssl_verify'] ?? 0
|
||||
);
|
||||
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
|
||||
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
|
||||
|
|
@ -151,8 +195,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
|||
} elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||
// 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');
|
||||
} elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) {
|
||||
$error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.';
|
||||
} else {
|
||||
|
|
@ -160,7 +204,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
|||
$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'] ?? ''));
|
||||
|
||||
|
|
@ -186,10 +230,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
|||
$voucherCreated = true;
|
||||
Notifier::voucherCreated(1, $site['name'], $_SESSION['user_name'] ?? null);
|
||||
|
||||
$success = 'Voucher erfolgreich erstellt!';
|
||||
if ($sendEmail && !empty($recipientEmail)) {
|
||||
$mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses);
|
||||
$success .= ' E-Mail versendet.';
|
||||
$success = __('voucher_created_mail');
|
||||
} else {
|
||||
$success = __('voucher_created_ok');
|
||||
}
|
||||
// Optional: Code per SMS (Twilio)
|
||||
$recipientPhone = trim((string)($_POST['recipient_phone'] ?? ''));
|
||||
|
|
@ -197,62 +242,128 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
|||
$smsText = ($appTitle ? $appTitle . ': ' : '') . 'WLAN-Code ' . $voucherCode;
|
||||
$success .= Sms::send($db, $recipientPhone, $smsText) ? ' SMS versendet.' : ' (SMS fehlgeschlagen)';
|
||||
}
|
||||
|
||||
$auth->writeAuditLog($userId, 'voucher_create', 'voucher', null,
|
||||
"Voucher '{$voucherName}' für {$site['name']}" . ($userId === null ? ' (öffentlich)' : ''));
|
||||
|
||||
// PRG-Pattern: Redirect nach erfolgreichem POST, damit ein Reload
|
||||
// (F5) keinen Duplikat-Voucher erzeugt. Ergebnis via Session-Flash.
|
||||
$_SESSION['voucher_flash'] = ['type' => 'single', 'data' => $voucherData, 'success' => $success];
|
||||
header('Location: index.php?created=1');
|
||||
exit;
|
||||
} catch (Exception $e) {
|
||||
$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.';
|
||||
} elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) {
|
||||
$error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.';
|
||||
} 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'));
|
||||
if (userDailyLimitExceeded($db, $auth, $bulkCount)) throw new Exception('Tageslimit für Voucher erreicht.');
|
||||
|
||||
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
|
||||
if (!$site) throw new Exception(__('error_site_not_found'));
|
||||
|
||||
$userId = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : null;
|
||||
$userId = $_SESSION['user_id'] ?? null;
|
||||
$qos = [
|
||||
'down' => max(0, (int)($_POST['qos_down'] ?? 0)),
|
||||
'up' => max(0, (int)($_POST['qos_up'] ?? 0)),
|
||||
'quota_mb' => max(0, (int)($_POST['qos_quota'] ?? 0)),
|
||||
];
|
||||
|
||||
for ($i = 0; $i < $bulkCount; $i++) {
|
||||
$bulkVouchers[] = doCreateVoucher($db, $site, $voucherName . '_' . ($i + 1), $maxUses, $expireMinutes, $userId, $qos);
|
||||
// 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'],
|
||||
$site['ssl_verify'] ?? 0
|
||||
);
|
||||
$created = $controller->createVouchers($fullName, $maxUses, $expireMinutes, $bulkCount, $qos);
|
||||
$expiryTs = time() + ($expireMinutes * 60);
|
||||
|
||||
foreach ($created as $i => $voucher) {
|
||||
$db->execute(
|
||||
"INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[$site['id'], $userId, $voucher['code'], $fullName . '_' . ($i + 1), $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
|
||||
);
|
||||
$bulkVouchers[] = [
|
||||
'code' => $voucher['formatted_code'],
|
||||
'site_name' => $site['name'],
|
||||
'max_uses' => $maxUses,
|
||||
'expire_min' => $expireMinutes,
|
||||
'expiry_date' => date('d.m.Y', $expiryTs),
|
||||
'expiry_time' => date('H:i', $expiryTs),
|
||||
];
|
||||
}
|
||||
|
||||
Notifier::voucherCreated($bulkCount, $site['name'], $_SESSION['user_name'] ?? null);
|
||||
$bulkCreated = true;
|
||||
$success = str_replace('{count}', $bulkCount, __('bulk_success'));
|
||||
Notifier::voucherCreated(count($created), $site['name'], $_SESSION['user_name'] ?? null);
|
||||
$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');
|
||||
|
||||
// Captcha nur für anonyme öffentliche Erstellung
|
||||
$captchaMode = !$auth->isLoggedIn() ? Captcha::mode($db) : 'off';
|
||||
$captchaQuestion = $captchaMode === 'math' ? Captcha::newMathChallenge() : '';
|
||||
|
|
@ -284,6 +395,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">
|
||||
<?php if ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== ''): ?>
|
||||
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
|
||||
|
|
@ -303,9 +417,6 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
|||
.container { max-width: 600px; margin: 0 auto; background: var(--bg-card); border-radius: 20px; box-shadow: 0 20px 60px var(--shadow-lg); padding: 40px; }
|
||||
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); }
|
||||
|
|
@ -411,11 +522,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>
|
||||
|
|
@ -463,8 +574,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>
|
||||
|
|
@ -472,7 +583,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;">
|
||||
|
|
@ -542,13 +653,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">
|
||||
|
|
@ -558,7 +670,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; ?>
|
||||
|
|
@ -602,7 +714,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">
|
||||
|
|
@ -616,20 +729,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">
|
||||
|
|
@ -639,7 +753,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; ?>
|
||||
|
|
@ -647,10 +761,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;">
|
||||
|
|
@ -667,16 +782,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; ?>
|
||||
|
||||
|
|
@ -699,8 +816,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');
|
||||
}
|
||||
|
|
@ -710,8 +830,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');
|
||||
|
|
@ -755,6 +877,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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
64
lang/de.php
|
|
@ -99,12 +99,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)',
|
||||
|
||||
|
|
@ -292,4 +292,64 @@ 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.',
|
||||
|
||||
// SSL-Verifizierung
|
||||
'sites_ssl_verify' => 'SSL-Zertifikat des Controllers prüfen',
|
||||
'sites_ssl_verify_hint' => 'Nur aktivieren, wenn der Controller ein gültiges Zertifikat besitzt (UniFi nutzt standardmäßig self-signed).',
|
||||
'smtp_verify_ssl' => 'SSL-Zertifikat des SMTP-Servers prüfen',
|
||||
|
||||
// Site connection test
|
||||
'site_test_btn' => 'Verbindung testen',
|
||||
'site_test_ok' => 'Verbindung erfolgreich',
|
||||
'site_test_fail' => 'Verbindung fehlgeschlagen',
|
||||
|
||||
// System warnings
|
||||
'crypto_warning' => 'Verschlüsselung inaktiv: In der config.php ist kein gültiger APP_KEY gesetzt. UniFi-Passwörter werden im Klartext gespeichert. Bei einem Serverumzug mit verändertem APP_KEY schlagen Controller-Logins still fehl.',
|
||||
];
|
||||
|
|
|
|||
64
lang/en.php
|
|
@ -99,12 +99,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)',
|
||||
|
||||
|
|
@ -292,4 +292,64 @@ 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.',
|
||||
|
||||
// SSL verification
|
||||
'sites_ssl_verify' => "Verify the controller's SSL certificate",
|
||||
'sites_ssl_verify_hint' => 'Only enable if the controller has a valid certificate (UniFi uses self-signed certificates by default).',
|
||||
'smtp_verify_ssl' => "Verify the SMTP server's SSL certificate",
|
||||
|
||||
// Site connection test
|
||||
'site_test_btn' => 'Test connection',
|
||||
'site_test_ok' => 'Connection successful',
|
||||
'site_test_fail' => 'Connection failed',
|
||||
|
||||
// System warnings
|
||||
'crypto_warning' => 'Encryption inactive: no valid APP_KEY is set in config.php. UniFi passwords are stored in plain text. If the APP_KEY changes (e.g. after a server move), controller logins will silently fail.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -67,6 +67,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', '');
|
||||
|
|
@ -127,6 +128,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>
|
||||
|
|
@ -148,9 +152,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; }
|
||||
|
|
|
|||
329
login_simple.php
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
11
updater/migrations/0005_request_throttle.sql
Normal 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;
|
||||
3
updater/migrations/0006_sites_ssl_verify.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- Opt-in SSL-Zertifikatspruefung pro Site (Default aus: UniFi-Controller
|
||||
-- nutzen meist self-signed Zertifikate).
|
||||
ALTER TABLE `sites` ADD COLUMN `ssl_verify` TINYINT(1) NOT NULL DEFAULT 0;
|
||||