Merge origin/main: Review-Branch mit v2.4.0-Features zusammenführen

Konfliktauflösung kombiniert beide Seiten:
- Auth: Secure-Cookie-Flag + DB-Session-Handler (main)
- UniFiController: createVouchers (n-Parameter, 1 API-Call) + QoS-Optionen (main)
- index.php: IP-Rate-Limit/PRG/Sticky-Forms + CAPTCHA/SMS/QoS/Tageslimit (main)
- users.php: 2FA-Reset (main) auf POST+PRG umgestellt wie übrige Aktionen
- forgot_password: Session-Throttle (main) + IP-Throttle kombiniert
- Eigene Migrationen wegen Nummernkollision auf 0005/0006 umbenannt

https://claude.ai/code/session_01KKVpVPJjrTKGoRgpJcySD4
This commit is contained in:
Claude 2026-06-09 20:03:32 +00:00
commit 1a2f86ed3d
No known key found for this signature in database
65 changed files with 3136 additions and 32 deletions

100
index.php
View file

@ -16,6 +16,9 @@ require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/UniFiController.php';
require_once __DIR__ . '/includes/Mailer.php';
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';
@ -87,6 +90,26 @@ function formatDuration($minutes) {
return __('dur_minutes', ['n' => $minutes]);
}
/**
* Optionales Tageslimit pro (Nicht-Admin-)Benutzer (Setting
* user_daily_voucher_limit, 0 = aus). Verhindert übermäßige Erstellung.
*/
function userDailyLimitExceeded($db, $auth, $additional = 1) {
if (!$auth->isLoggedIn() || $auth->isAdmin()) {
return false;
}
$limit = (int)$db->getSetting('user_daily_voucher_limit', 0);
if ($limit <= 0) {
return false;
}
$uid = $_SESSION['user_id'] ?? 0;
$today = (int)($db->fetchOne(
"SELECT COUNT(*) c FROM vouchers WHERE user_id=? AND DATE(created_at)=CURDATE()",
[$uid]
)['c'] ?? 0);
return ($today + $additional) > $limit;
}
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
$faviconUrl = $db->getSetting('favicon_url', '');
@ -135,7 +158,7 @@ if ($auth->isLoggedIn()) {
$autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
// Helper: create one voucher and save to DB
function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId) {
function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos = []) {
$datum = date('Y-m-d');
$fullName = $datum . '_' . $voucherName;
$controller = new UniFiController(
@ -145,7 +168,7 @@ function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $us
$site['site_id'],
$site['ssl_verify'] ?? 0
);
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes);
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
throw new Exception(__('error_voucher_invalid'));
}
@ -174,6 +197,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
$error = __('error_csrf');
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited($db)) {
$error = __('error_rate_limited');
} elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) {
$error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.';
} else {
try {
$siteId = (int)($_POST['site_id'] ?? 0);
@ -189,14 +214,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
if ($sendEmail && !filter_var($recipientEmail, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid'));
if ($auth->isLoggedIn() && !$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
if (userDailyLimitExceeded($db, $auth, 1)) 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;
$voucherData = doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId);
$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)),
];
$voucherData = doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos);
$voucherCode = $voucherData['code'];
$voucherCreated = true;
Notifier::voucherCreated(1, $site['name'], $_SESSION['user_name'] ?? null);
if ($sendEmail && !empty($recipientEmail)) {
$mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses);
@ -204,6 +236,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
} else {
$success = __('voucher_created_ok');
}
// Optional: Code per SMS (Twilio)
$recipientPhone = trim((string)($_POST['recipient_phone'] ?? ''));
if (isset($_POST['send_sms']) && $recipientPhone !== '' && Sms::enabled($db)) {
$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)' : ''));
@ -239,11 +277,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
if ($maxUses < 1 || $maxUses > $maxUsesLimit) throw new Exception(__('error_devices_range', ['max' => $maxUsesLimit]));
if ($siteId <= 0) throw new Exception(__('error_site_req'));
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 = $_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)),
];
// Alle Voucher in EINEM UniFi-API-Call erstellen ('n'-Parameter)
// statt pro Voucher Login + Voucherliste abzurufen.
@ -255,7 +299,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
$site['site_id'],
$site['ssl_verify'] ?? 0
);
$created = $controller->createVouchers($fullName, $maxUses, $expireMinutes, $bulkCount);
$created = $controller->createVouchers($fullName, $maxUses, $expireMinutes, $bulkCount, $qos);
$expiryTs = time() + ($expireMinutes * 60);
foreach ($created as $i => $voucher) {
@ -274,6 +318,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
];
}
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']}");
@ -319,6 +364,19 @@ if ($stickyMaxUses < 1 || $stickyMaxUses > $maxUsesLimit) $stickyMaxUses = $defa
// 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() : '';
$hcaptchaSiteKey = $captchaMode === 'hcaptcha' ? $db->getSetting('captcha_site_key', '') : '';
$smsEnabled = Sms::enabled($db);
$captchaHtml = '';
if ($captchaMode === 'math') {
$captchaHtml = '<div class="form-group"><label>Sicherheitsfrage: Wie viel ist ' . htmlspecialchars($captchaQuestion) . '?</label>'
. '<input type="text" name="captcha" inputmode="numeric" required></div>';
} elseif ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== '') {
$captchaHtml = '<div class="form-group"><div class="h-captcha" data-sitekey="' . htmlspecialchars($hcaptchaSiteKey) . '"></div></div>';
}
// Build print HTML for each voucher
function buildPrintCard($template, $data, $instructionHeader, $instructionText, $appTitle) {
$instructions = $instructionHeader || $instructionText
@ -341,6 +399,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<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>
<?php endif; ?>
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<?php if ($voucherCreated): ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSe2keRB6Q5pBUtIxCY7bQMsVB0ANBpd6JDg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
@ -564,6 +625,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<option value="<?= (int)$tpl['id'] ?>"
data-max-uses="<?= (int)$tpl['max_uses'] ?>"
data-expire="<?= (int)$tpl['expire_minutes'] ?>"
data-qos-down="<?= (int)($tpl['qos_rate_max_down'] ?? 0) ?>"
data-qos-up="<?= (int)($tpl['qos_rate_max_up'] ?? 0) ?>"
data-qos-quota="<?= (int)($tpl['qos_usage_quota'] ?? 0) ?>"
data-desc="<?= htmlspecialchars($tpl['description'] ?? '') ?>">
<?= htmlspecialchars($tpl['name']) ?>
<?= (int)$tpl['max_uses'] ?> <?= __('label_devices') ?>,
@ -581,6 +645,10 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<input type="hidden" name="create_voucher" value="1">
<input type="hidden" name="expire_minutes" id="expire_minutes" value="<?= $defaultExpire ?>">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
<input type="hidden" name="qos_down" class="qos-down-field" value="0">
<input type="hidden" name="qos_up" class="qos-up-field" value="0">
<input type="hidden" name="qos_quota" class="qos-quota-field" value="0">
<?= $captchaHtml ?>
<div class="form-group">
<label for="voucher_name"><?= __('voucher_name_label') ?></label>
@ -627,6 +695,19 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
</div>
<?php endif; ?>
<?php if ($smsEnabled): ?>
<div class="email-option">
<div class="email-checkbox-wrapper">
<input type="checkbox" id="send_sms" name="send_sms" onchange="document.getElementById('sms_field').style.display=this.checked?'block':'none'">
<label for="send_sms">📱 Code per SMS versenden</label>
</div>
<div id="sms_field" style="display:none;margin-top:12px;">
<label for="recipient_phone">Telefonnummer (international, z.B. +49170)</label>
<input type="tel" id="recipient_phone" name="recipient_phone" placeholder="+49170123456">
</div>
</div>
<?php endif; ?>
<button type="submit" class="btn" id="submitBtn">
<?= __('voucher_create_btn') ?>
</button>
@ -640,6 +721,10 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<input type="hidden" name="create_bulk" value="1">
<input type="hidden" name="expire_minutes" id="bulk_expire_minutes" value="<?= $defaultExpire ?>">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
<input type="hidden" name="qos_down" class="qos-down-field" value="0">
<input type="hidden" name="qos_up" class="qos-up-field" value="0">
<input type="hidden" name="qos_quota" class="qos-quota-field" value="0">
<?= $captchaHtml ?>
<div class="form-group">
<label for="bulk_count"><?= __('bulk_quantity') ?></label>
@ -754,6 +839,13 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
const bmuEl = document.getElementById('bulk_max_uses');
if (bmuEl) bmuEl.value = maxUses;
const qosDown = opt.value ? (parseInt(opt.dataset.qosDown) || 0) : 0;
const qosUp = opt.value ? (parseInt(opt.dataset.qosUp) || 0) : 0;
const qosQuota = opt.value ? (parseInt(opt.dataset.qosQuota) || 0) : 0;
document.querySelectorAll('.qos-down-field').forEach(el => el.value = qosDown);
document.querySelectorAll('.qos-up-field').forEach(el => el.value = qosUp);
document.querySelectorAll('.qos-quota-field').forEach(el => el.value = qosQuota);
const descEl = document.getElementById('template_desc');
if (descEl) descEl.textContent = opt.dataset.desc || '';
}