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

@ -5,6 +5,7 @@ ini_set('display_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Mailer.php';
$auth = new Auth();
$auth->requireAdmin();
@ -12,6 +13,27 @@ $auth->requireAdmin();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
// AJAX: SMTP-Test-E-Mail senden
if (isset($_POST['ajax_smtp_test'])) {
header('Content-Type: application/json');
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
echo json_encode(['success' => false, 'message' => 'Ungültiges Token']);
exit;
}
$to = trim($_POST['test_email'] ?? '');
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
echo json_encode(['success' => false, 'message' => 'Ungültige E-Mail-Adresse']);
exit;
}
$mailer = new Mailer();
$ok = $mailer->sendTestEmail($to);
echo json_encode([
'success' => $ok,
'message' => $ok ? "Test-E-Mail wurde an {$to} gesendet." : 'Versand fehlgeschlagen. Prüfen Sie die SMTP-Einstellungen und den PHP-Fehlerlog.'
]);
exit;
}
$error = '';
$success = '';
@ -542,6 +564,20 @@ $faviconUrl = $db->getSetting('favicon_url', '');
</div>
<button type="submit" name="save_settings" class="btn btn-primary"><i class="fas fa-save"></i> Speichern</button>
</form>
<div style="margin-top: 25px; padding-top: 25px; border-top: 1px solid #e0e0e0;">
<h3 style="margin-bottom: 15px;">SMTP testen</h3>
<div style="display: flex; gap: 10px; align-items: flex-end;">
<div style="flex: 1;">
<label for="smtpTestEmail">Test-E-Mail senden an</label>
<input type="email" id="smtpTestEmail" placeholder="empfaenger@example.com" style="margin-top: 6px;">
</div>
<button onclick="testSmtp()" class="btn btn-secondary" id="smtpTestBtn">
<i class="fas fa-paper-plane"></i> Testen
</button>
</div>
<span id="smtpTestResult" style="display: block; margin-top: 10px; font-size: 13px;"></span>
</div>
</div>
<!-- TEIL 1 ENDET HIER - Fortsetzung in TEIL 2 -->
@ -749,6 +785,32 @@ $faviconUrl = $db->getSetting('favicon_url', '');
});
}
// SMTP testen
async function testSmtp() {
const email = document.getElementById('smtpTestEmail').value.trim();
const btn = document.getElementById('smtpTestBtn');
const result = document.getElementById('smtpTestResult');
if (!email) { result.textContent = 'Bitte eine E-Mail-Adresse eingeben.'; result.style.color = '#c33'; return; }
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Sende...';
result.textContent = '';
const fd = new FormData();
fd.append('ajax_smtp_test', '1');
fd.append('csrf_token', '<?= $auth->getCsrfToken() ?>');
fd.append('test_email', email);
try {
const res = await fetch('settings.php', { method: 'POST', body: fd });
const data = await res.json();
result.textContent = data.message;
result.style.color = data.success ? '#3c3' : '#c33';
} catch(e) {
result.textContent = 'Fehler beim Senden.';
result.style.color = '#c33';
}
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Testen';
}
// Cron-Job testen
async function testCronJob() {
const btn = document.getElementById('testCronBtn');

View file

@ -654,6 +654,22 @@ $currentUser = $auth->getCurrentUser();
document.getElementById('editSiteModal').classList.add('active');
}
// Loading-State bei Formular-Absenden (verhindert Freeze-Eindruck)
document.getElementById('addSiteForm').addEventListener('submit', function() {
const btn = this.querySelector('button[name="add_site"]');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Verbindung wird getestet...';
}
});
document.getElementById('editSiteForm').addEventListener('submit', function() {
const btn = this.querySelector('button[name="edit_site"]');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Verbindung wird getestet...';
}
});
// Modal schließen bei Klick außerhalb
document.getElementById('addSiteModal').addEventListener('click', function(e) {
if (e.target === this) {

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;
}