Fix sites freeze bug, add features and shorten README

Bug fixes:
- UniFiController: add CURLOPT_TIMEOUT (10s) and CURLOPT_CONNECTTIMEOUT (5s)
  to login() and apiRequest() — prevents page freeze when controller unreachable
- UniFiController: fix login response validation for UniFi OS API which returns
  a user object instead of meta.rc=ok
- admin/sites.php: add JS loading state on form submit to give visual feedback
- login.php: handle new 'rate_limited' return value from Auth::login()

New features:
- Database: in-memory settings cache eliminates redundant DB queries per request
- Auth: login rate limiting (10 attempts per 10 min per IP/email) via login_attempts table
- admin/vouchers.php: CSV export with UTF-8 BOM for Excel compatibility
- admin/vouchers.php: client-side pagination (50 per page)
- index.php: QR code display after voucher creation (qrcodejs CDN)
- Mailer: sendTestEmail() method
- admin/settings.php: SMTP test button with AJAX handler
- database.sql: add login_attempts and audit_log tables

Readme: condensed from ~420 to ~220 lines, removed duplicated sections,
M365 Azure Portal walkthrough, contribution guidelines, update/migration section

https://claude.ai/code/session_01UsuvFAmmeagtQa14QA4iaq
This commit is contained in:
Claude 2026-04-21 16:08:08 +00:00
parent 3fd9b2190a
commit 73967caefa
No known key found for this signature in database
11 changed files with 460 additions and 450 deletions

View file

@ -13,6 +13,41 @@ $auth->requireAdmin();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
// CSV-Export
if (isset($_GET['export_csv']) && isset($_GET['site_id'])) {
if (!$auth->validateCsrfToken($_GET['token'] ?? '')) {
http_response_code(403);
exit('Ungültiges Token');
}
$siteId = (int)$_GET['site_id'];
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
if (!$site) { http_response_code(404); exit('Site nicht gefunden'); }
$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';
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Cache-Control: no-cache');
$out = fopen('php://output', 'w');
fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF)); // UTF-8 BOM für Excel
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'] ?? ''
], ';');
}
fclose($out);
exit;
}
// AJAX: Voucher abrufen (immer aus DB, optional vorher Live-Sync)
if (isset($_GET['ajax_get_vouchers']) && isset($_GET['site_id'])) {
header('Content-Type: application/json');
@ -672,6 +707,9 @@ $faviconUrl = $db->getSetting('favicon_url', '');
<div class="card">
<div class="card-header">
<h2 class="card-title" id="voucherListTitle">Vouchers</h2>
<a id="csvExportBtn" style="display:none;" class="btn btn-secondary btn-small" href="#">
<i class="fas fa-download"></i> CSV exportieren
</a>
</div>
<div class="card-body" style="padding: 0;">
<div id="voucherContent">
@ -694,6 +732,8 @@ $faviconUrl = $db->getSetting('favicon_url', '');
let currentSiteId = null;
let allVouchers = [];
let currentFilter = 'all';
let currentPage = 1;
const PAGE_SIZE = 50;
// Toast Notification anzeigen
function showToast(type, title, message) {
@ -761,11 +801,17 @@ $faviconUrl = $db->getSetting('favicon_url', '');
if (result.success) {
allVouchers = result.vouchers;
currentPage = 1;
document.getElementById('voucherListTitle').textContent = `Vouchers - ${result.site_name} (${result.count})`;
updateStats();
renderVouchers();
document.getElementById('statsContainer').style.display = 'block';
// CSV-Button aktualisieren
const csvBtn = document.getElementById('csvExportBtn');
csvBtn.style.display = 'inline-flex';
csvBtn.href = `vouchers.php?export_csv=1&site_id=${siteId}&token=${csrfToken}`;
// Sync-Info anzeigen
if (result.last_sync) {
showToast('success', syncFirst ? 'Synchronisiert' : 'Geladen',
@ -810,12 +856,19 @@ $faviconUrl = $db->getSetting('favicon_url', '');
// Filter setzen
function setFilter(filter) {
currentFilter = filter;
currentPage = 1;
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === filter);
});
renderVouchers();
}
function setPage(page) {
currentPage = page;
renderVouchers();
document.querySelector('.card:last-of-type')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// Vouchers rendern
function renderVouchers() {
let vouchers = allVouchers;
@ -824,6 +877,11 @@ $faviconUrl = $db->getSetting('favicon_url', '');
vouchers = allVouchers.filter(v => v.status === currentFilter);
}
const totalPages = Math.ceil(vouchers.length / PAGE_SIZE);
if (currentPage > totalPages && totalPages > 0) currentPage = totalPages;
const pageStart = (currentPage - 1) * PAGE_SIZE;
const pageVouchers = vouchers.slice(pageStart, pageStart + PAGE_SIZE);
if (vouchers.length === 0) {
document.getElementById('voucherContent').innerHTML = `
<div class="empty-state">
@ -867,7 +925,7 @@ $faviconUrl = $db->getSetting('favicon_url', '');
<tbody>
`;
vouchers.forEach(voucher => {
pageVouchers.forEach(voucher => {
const createDate = new Date(voucher.create_time * 1000);
const expireDate = new Date(voucher.expire_time * 1000);
const now = new Date();
@ -938,11 +996,20 @@ $faviconUrl = $db->getSetting('favicon_url', '');
`;
});
html += `
</tbody>
</table>
</div>
`;
html += `</tbody></table></div>`;
// Paginierung
if (totalPages > 1) {
html += `<div style="display:flex;align-items:center;justify-content:space-between;padding:15px 25px;border-top:1px solid #e0e0e0;">`;
html += `<span style="font-size:13px;color:#666;">Seite ${currentPage} von ${totalPages} (${vouchers.length} Einträge)</span>`;
html += `<div style="display:flex;gap:6px;">`;
html += `<button class="btn btn-secondary btn-small" onclick="setPage(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}><i class="fas fa-chevron-left"></i></button>`;
for (let p = Math.max(1, currentPage - 2); p <= Math.min(totalPages, currentPage + 2); p++) {
html += `<button class="btn btn-small ${p === currentPage ? 'btn-primary' : 'btn-secondary'}" onclick="setPage(${p})">${p}</button>`;
}
html += `<button class="btn btn-secondary btn-small" onclick="setPage(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}><i class="fas fa-chevron-right"></i></button>`;
html += `</div></div>`;
}
document.getElementById('voucherContent').innerHTML = html;
}