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:
commit
1a2f86ed3d
65 changed files with 3136 additions and 32 deletions
100
index.php
100
index.php
|
|
@ -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 || '';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue