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) + $voucherCount > $maxVouchers) {
$_SESSION['voucher_create_times'] = $timestamps;
return true;
}
for ($i = 0; $i < $voucherCount; $i++) {
$timestamps[] = $now;
}
$_SESSION['voucher_create_times'] = $timestamps;
return false;
}
/**
* Validiert die Voucher-Gueltigkeit (Minuten). Anonyme Nutzer duerfen nur den
* konfigurierten Default oder Werte aktiver Templates verwenden – das Feld ist
* ein Hidden-Input und damit beliebig manipulierbar. Eingeloggte Nutzer werden
* auf maximal 1 Jahr begrenzt.
*/
function sanitizeExpireMinutes($expireMinutes, $isLoggedIn, $templates, $defaultExpire) {
$expireMinutes = (int)$expireMinutes;
if ($isLoggedIn) {
return max(1, min(525600, $expireMinutes));
}
$allowed = array_map(function ($t) { return (int)$t['expire_minutes']; }, $templates);
$allowed[] = $defaultExpire;
return in_array($expireMinutes, $allowed, true) ? $expireMinutes : $defaultExpire;
}
/** Minuten menschenlesbar formatieren (z.B. 480 -> "8 Stunden"). */
function formatDuration($minutes) {
$minutes = (int)$minutes;
if ($minutes >= 1440 && $minutes % 1440 === 0) {
$days = $minutes / 1440;
return $days === 1 ? __('dur_day_one') : __('dur_days', ['n' => $days]);
}
if ($minutes >= 60 && $minutes % 60 === 0) {
$hours = $minutes / 60;
return $hours === 1 ? __('dur_hour_one') : __('dur_hours', ['n' => $hours]);
}
return __('dur_minutes', ['n' => $minutes]);
}
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
$faviconUrl = $db->getSetting('favicon_url', '');
$instructionHeader = $db->getSetting('instruction_header', '');
$instructionText = $db->getSetting('instruction_text', '');
$publicAccess = $db->getSetting('public_access', 0);
$smtpEnabled = $db->getSetting('smtp_enabled', '0') === '1';
$printTemplate = $db->getSetting('print_template', '
{APP_TITLE}
WLAN Zugangscode
{VOUCHER_CODE}
Gültig bis: {EXPIRY_DATE} {EXPIRY_TIME}
Standort: {SITE_NAME}
Maximale Geräte: {MAX_USES}
{INSTRUCTIONS}
');
$defaultExpire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
$defaultMaxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
$maxUsesLimit = max(1, (int)$db->getSetting('max_uses_limit', 10));
if (!$publicAccess && !$auth->isLoggedIn()) {
header('Location: login.php');
exit;
}
try {
$templates = $db->fetchAll("SELECT * FROM voucher_templates WHERE is_active = 1 ORDER BY name");
} catch (Exception $e) {
$templates = [];
}
$error = '';
$success = '';
$voucherCode = '';
$voucherCreated = false;
$voucherData = [];
$bulkVouchers = [];
$bulkCreated = false;
if ($auth->isLoggedIn()) {
if ($auth->isAdmin()) {
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active = 1 ORDER BY name");
} else {
$sites = $db->fetchAll(
"SELECT s.* FROM sites s INNER JOIN user_site_access usa ON s.id = usa.site_id
WHERE s.is_active = 1 AND usa.user_id = ? ORDER BY s.name",
[$_SESSION['user_id']]
);
}
} else {
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active = 1 AND public_access = 1 ORDER BY name");
}
$autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
// Helper: create one voucher and save to DB
function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId) {
$datum = date('Y-m-d');
$fullName = $datum . '_' . $voucherName;
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
Crypto::decrypt($site['unifi_password']),
$site['site_id'],
$site['ssl_verify'] ?? 0
);
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes);
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
throw new Exception(__('error_voucher_invalid'));
}
$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, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
);
$expiryTs = time() + ($expireMinutes * 60);
return [
'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),
];
}
// Single voucher
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
if (!$publicAccess && !$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($db)) {
$error = __('error_rate_limited');
} else {
try {
$siteId = (int)($_POST['site_id'] ?? 0);
$voucherName = trim((string)($_POST['voucher_name'] ?? ''));
$maxUses = (int)($_POST['max_uses'] ?? $defaultMaxUses);
$expireMinutes = sanitizeExpireMinutes($_POST['expire_minutes'] ?? $defaultExpire, $auth->isLoggedIn(), $templates, $defaultExpire);
$sendEmail = isset($_POST['send_email']) && !empty($_POST['recipient_email']);
$recipientEmail= trim((string)($_POST['recipient_email'] ?? ''));
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 ($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'));
$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);
$voucherCode = $voucherData['code'];
$voucherCreated = true;
if ($sendEmail && !empty($recipientEmail)) {
$mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses);
$success = __('voucher_created_mail');
} else {
$success = __('voucher_created_ok');
}
$auth->writeAuditLog($userId, 'voucher_create', 'voucher', null,
"Voucher '{$voucherName}' für {$site['name']}" . ($userId === null ? ' (öffentlich)' : ''));
// PRG-Pattern: Redirect nach erfolgreichem POST, damit ein Reload
// (F5) keinen Duplikat-Voucher erzeugt. Ergebnis via Session-Flash.
$_SESSION['voucher_flash'] = ['type' => 'single', 'data' => $voucherData, 'success' => $success];
header('Location: index.php?created=1');
exit;
} catch (Exception $e) {
$error = 'Fehler: ' . $e->getMessage();
}
}
}
// Bulk voucher creation – 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 (!$auth->isLoggedIn()) {
$error = __('error_login_req');
} elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
try {
$siteId = (int)($_POST['site_id'] ?? 0);
$voucherName = trim((string)($_POST['voucher_name'] ?? ''));
$maxUses = (int)($_POST['max_uses'] ?? $defaultMaxUses);
$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->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
if (!$site) throw new Exception(__('error_site_not_found'));
$userId = $_SESSION['user_id'] ?? null;
// 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);
$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),
];
}
$auth->writeAuditLog($userId, 'voucher_bulk', 'voucher', null,
count($created) . " Vouchers '{$voucherName}' für {$site['name']}");
$success = str_replace('{count}', count($created), __('bulk_success'));
// PRG-Pattern: Reload darf die Bulk-Erstellung nicht wiederholen.
$_SESSION['voucher_flash'] = ['type' => 'bulk', 'data' => $bulkVouchers, 'success' => $success];
header('Location: index.php?created=1');
exit;
} catch (Exception $e) {
$error = 'Fehler: ' . $e->getMessage();
}
}
}
// PRG: Ergebnis nach Redirect aus dem Session-Flash wiederherstellen.
// Der Flash bleibt fuer Reloads der Ergebnisseite erhalten und wird beim
// Zurueckkehren zum Formular (GET ohne ?created) verworfen.
if (isset($_GET['created']) && !empty($_SESSION['voucher_flash'])) {
$flash = $_SESSION['voucher_flash'];
if (($flash['type'] ?? '') === 'bulk') {
$bulkVouchers = $flash['data'];
$bulkCreated = true;
} else {
$voucherData = $flash['data'];
$voucherCode = $voucherData['code'];
$voucherCreated = true;
}
$success = $flash['success'] ?? '';
} elseif ($_SERVER['REQUEST_METHOD'] !== 'POST') {
unset($_SESSION['voucher_flash']);
}
$currentUser = $auth->isLoggedIn() ? $auth->getCurrentUser() : null;
// Bei Validierungsfehlern: eingegebene Werte und aktiven Tab erhalten
$activeMode = ($error && isset($_POST['create_bulk'])) ? 'bulk' : 'single';
$stickyName = $error ? trim((string)($_POST['voucher_name'] ?? '')) : '';
$stickyMaxUses = $error ? (int)($_POST['max_uses'] ?? $defaultMaxUses) : $defaultMaxUses;
$stickyBulkCount = $error ? max(1, min(20, (int)($_POST['bulk_count'] ?? 5))) : 5;
$stickySiteId = $error ? (int)($_POST['site_id'] ?? 0) : 0;
if ($stickyMaxUses < 1 || $stickyMaxUses > $maxUsesLimit) $stickyMaxUses = $defaultMaxUses;
// Anonyme Gaeste wissen oft nicht, was sie als Namen eintragen sollen -> Default
if ($stickyName === '' && !$auth->isLoggedIn()) $stickyName = __('voucher_name_default');
// Build print HTML for each voucher
function buildPrintCard($template, $data, $instructionHeader, $instructionText, $appTitle) {
$instructions = $instructionHeader || $instructionText
? htmlspecialchars($instructionHeader) . "\n" . $instructionText
: '';
return str_replace(
['{VOUCHER_CODE}', '{SITE_NAME}', '{MAX_USES}', '{APP_TITLE}', '{INSTRUCTIONS}', '{EXPIRY_DATE}', '{EXPIRY_TIME}'],
[$data['code'], htmlspecialchars($data['site_name']), $data['max_uses'], htmlspecialchars($appTitle), $instructions, $data['expiry_date'], $data['expiry_time']],
$template
);
}
?>
= htmlspecialchars($appTitle) ?>
$label): ?>
= htmlspecialchars($appTitle) ?>
= htmlspecialchars($error) ?>
= buildPrintCard($printTemplate, $voucherData, $instructionHeader, $instructionText, $appTitle) ?>
= __('voucher_success_title') ?>
= htmlspecialchars($voucherCode) ?>
= str_replace('{duration}', formatDuration($voucherData['expire_min']), __('voucher_validity')) ?>
= __('voucher_qr_label') ?>
= htmlspecialchars($instructionHeader) ?>
= $instructionText ?>
= str_replace('{count}', count($bulkVouchers), __('bulk_results')) ?>
$bv): ?>
= buildPrintCard($printTemplate, $bv, $instructionHeader, $instructionText, $appTitle) ?>
| # |
= __('label_code') ?> |
= __('label_site') ?> |
= __('label_expires') ?> |
$bv): ?>
| = $i + 1 ?> |
= htmlspecialchars($bv['code']) ?>
|
= htmlspecialchars($bv['site_name']) ?> |
= $bv['expiry_date'] ?> = $bv['expiry_time'] ?> |
= __('copy_hint') ?>
isLoggedIn()): ?>
isLoggedIn()): ?>
= htmlspecialchars($instructionHeader) ?>
= $instructionText ?>