Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.
+
+
diff --git a/admin/vouchers.php b/admin/vouchers.php
index 46af577..020bb87 100644
--- a/admin/vouchers.php
+++ b/admin/vouchers.php
@@ -96,6 +96,25 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
exit;
}
+// Voucher-Code per E-Mail (erneut) versenden
+if (isset($_POST['ajax_resend']) && isset($_POST['voucher_id']) && isset($_POST['site_id'])) {
+ header('Content-Type: application/json');
+ if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { echo json_encode(['success'=>false,'message'=>__('error_csrf')]); exit; }
+ require_once __DIR__ . '/../includes/Mailer.php';
+ $email = trim($_POST['email'] ?? '');
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { echo json_encode(['success'=>false,'message'=>'Ungültige E-Mail-Adresse']); exit; }
+ $siteId = (int)$_POST['site_id'];
+ $site = $db->fetchOne("SELECT * FROM sites WHERE id=?", [$siteId]);
+ $v = $db->fetchOne("SELECT * FROM vouchers WHERE unifi_voucher_id=? AND site_id=?", [$_POST['voucher_id'], $siteId]);
+ if (!$site || !$v) { echo json_encode(['success'=>false,'message'=>'Voucher nicht gefunden']); exit; }
+ $mailer = new Mailer();
+ $code = strpos($v['voucher_code'], '-') !== false ? $v['voucher_code'] : implode('-', str_split($v['voucher_code'], 5));
+ $ok = $mailer->sendVoucherEmail($email, $code, $site['name'], (int)$v['max_uses']);
+ $auth->writeAuditLog($_SESSION['user_id'], 'voucher_resend', 'voucher', $v['id'], "Code an $email gesendet");
+ echo json_encode(['success'=>$ok, 'message'=>$ok ? 'E-Mail versendet.' : 'Versand fehlgeschlagen.']);
+ exit;
+}
+
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
$siteStats = [];
foreach ($sites as $site) {
@@ -383,7 +402,10 @@ function renderVouchers() {
${statusBadge}
${v.used}/${v.quota>0?v.quota:'∞'}${v.quota>0?`
`:''}
${remaining?` ${remaining} `:''}${v.duration} Min.
-
+
+
+
+
`;
});
@@ -407,6 +429,20 @@ function renderVouchers() {
function escapeHtml(t) { const d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
+async function resendVoucher(voucherId, code) {
+ const email = prompt('Code ' + code + ' senden an (E-Mail):');
+ if (!email) return;
+ const fd = new FormData();
+ fd.append('ajax_resend','1'); fd.append('voucher_id',voucherId);
+ fd.append('site_id', currentSiteId); fd.append('email', email);
+ fd.append('csrf_token', csrfToken);
+ try {
+ const r = await fetch('vouchers.php', {method:'POST', body:fd});
+ const d = await r.json();
+ (window.showToast ? showToast(d.message, d.success?'success':'error') : alert(d.message));
+ } catch(e){ alert('Fehler: '+e.message); }
+}
+
async function deleteVoucher(voucherId) {
if (!confirm('Voucher wirklich löschen?')) return;
const row = document.getElementById(`voucher-${voucherId}`);
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..945e17b
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,21 @@
+{
+ "name": "friloo/unifi-voucher-tool",
+ "description": "Webbasiertes WLAN-Voucher-Management für UniFi OS",
+ "license": "MIT",
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6",
+ "phpstan/phpstan": "^1.11"
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit",
+ "stan": "phpstan analyse"
+ }
+}
diff --git a/includes/Mailer.php b/includes/Mailer.php
index 9fe53de..866b2eb 100644
--- a/includes/Mailer.php
+++ b/includes/Mailer.php
@@ -31,12 +31,23 @@ class Mailer {
}
public function send($to, $subject, $body, $isHtml = false) {
- if (!$this->smtpEnabled || empty($this->smtpHost)) {
- // Fallback auf PHP mail()
- return $this->sendWithPhpMail($to, $subject, $body);
+ // Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
+ $attempts = 2;
+ for ($i = 1; $i <= $attempts; $i++) {
+ if (!$this->smtpEnabled || empty($this->smtpHost)) {
+ $ok = $this->sendWithPhpMail($to, $subject, $body);
+ } else {
+ $ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
+ }
+ if ($ok) {
+ return true;
+ }
+ if ($i < $attempts) {
+ usleep(500000); // 0,5s vor erneutem Versuch
+ }
}
-
- return $this->sendWithSmtp($to, $subject, $body, $isHtml);
+ error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
+ return false;
}
private function sendWithPhpMail($to, $subject, $body) {
diff --git a/index.php b/index.php
index d5743be..e90b7fc 100644
--- a/index.php
+++ b/index.php
@@ -46,6 +46,26 @@ function isVoucherRateLimited() {
return false;
}
+/**
+ * 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', '');
$instructionHeader = $db->getSetting('instruction_header', '');
@@ -146,6 +166,7 @@ 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'));
@@ -194,6 +215,7 @@ 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->isLoggedIn() && !$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'));
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..818df76
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,6 @@
+parameters:
+ level: 5
+ paths:
+ - includes/Totp.php
+ - includes/Crypto.php
+ - includes/ApiKey.php
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..c31cd71
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,11 @@
+
+
+
+
+ tests
+
+
+
diff --git a/tests/ApiKeyTest.php b/tests/ApiKeyTest.php
new file mode 100644
index 0000000..c7dbd73
--- /dev/null
+++ b/tests/ApiKeyTest.php
@@ -0,0 +1,35 @@
+assertStringStartsWith('uvt_', $k['plain']);
+ $this->assertSame(44, strlen($k['plain']));
+ $this->assertSame(hash('sha256', $k['plain']), $k['hash']);
+ $this->assertSame(substr($k['plain'], 4, 8), $k['prefix']);
+ }
+
+ public function testScopes(): void
+ {
+ $read = ['scope' => 'read'];
+ $write = ['scope' => 'write'];
+ $this->assertTrue(\ApiKey::hasScope($read, 'read'));
+ $this->assertFalse(\ApiKey::hasScope($read, 'write'));
+ $this->assertTrue(\ApiKey::hasScope($write, 'read'));
+ $this->assertTrue(\ApiKey::hasScope($write, 'write'));
+ }
+
+ public function testFromRequestBearer(): void
+ {
+ $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer uvt_testkey123';
+ $this->assertSame('uvt_testkey123', \ApiKey::fromRequest());
+ unset($_SERVER['HTTP_AUTHORIZATION']);
+ }
+}
diff --git a/tests/CryptoTest.php b/tests/CryptoTest.php
new file mode 100644
index 0000000..195d79b
--- /dev/null
+++ b/tests/CryptoTest.php
@@ -0,0 +1,39 @@
+assertNotSame($plain, $cipher);
+ $this->assertTrue(\Crypto::isEncrypted($cipher));
+ $this->assertSame($plain, \Crypto::decrypt($cipher));
+ }
+
+ public function testPlaintextPassthrough(): void
+ {
+ // Legacy-/Klartextwerte werden unverändert zurückgegeben.
+ $this->assertSame('altesKlartextPW', \Crypto::decrypt('altesKlartextPW'));
+ }
+
+ public function testEmptyValues(): void
+ {
+ $this->assertSame('', \Crypto::encrypt(''));
+ $this->assertNull(\Crypto::decrypt(null));
+ }
+
+ public function testGenerateKeyLength(): void
+ {
+ $key = \Crypto::generateKey();
+ $this->assertSame(32, strlen(base64_decode($key)));
+ }
+}
diff --git a/tests/MigrationSplitterTest.php b/tests/MigrationSplitterTest.php
new file mode 100644
index 0000000..c3129ff
--- /dev/null
+++ b/tests/MigrationSplitterTest.php
@@ -0,0 +1,42 @@
+newInstanceWithoutConstructor();
+ $m = $rc->getMethod('splitStatements');
+ $m->setAccessible(true);
+ $parts = $m->invoke($inst, $sql);
+ return array_values(array_filter(array_map('trim', $parts), fn($s) => $s !== ''));
+ }
+
+ public function testIgnoresSemicolonsInStringsAndComments(): void
+ {
+ $sql = "INSERT INTO t (a) VALUES (\";semi;colon\"); -- comment; not split\n"
+ . "CREATE TABLE x (id INT); /* block ; comment */ INSERT INTO y VALUES (1);";
+ $parts = $this->split($sql);
+ $this->assertCount(3, $parts);
+ }
+
+ public function testSingleStatement(): void
+ {
+ $parts = $this->split("ALTER TABLE users ADD COLUMN foo INT");
+ $this->assertCount(1, $parts);
+ }
+
+ public function testIgnorableErrorDetection(): void
+ {
+ $rc = new \ReflectionClass(\Updater\MigrationRunner::class);
+ $inst = $rc->newInstanceWithoutConstructor();
+ $this->assertTrue($inst->isIgnorableSqlError('Duplicate column name "x"', 'mysql'));
+ $this->assertTrue($inst->isIgnorableSqlError('Table already exists', 'mysql'));
+ $this->assertFalse($inst->isIgnorableSqlError('Syntax error near FROM', 'mysql'));
+ }
+}
diff --git a/tests/TotpTest.php b/tests/TotpTest.php
new file mode 100644
index 0000000..5185398
--- /dev/null
+++ b/tests/TotpTest.php
@@ -0,0 +1,46 @@
+assertSame('287082', \Totp::code($secret, intdiv(59, 30)));
+ $this->assertSame('081804', \Totp::code($secret, intdiv(1111111109, 30)));
+ $this->assertSame('005924', \Totp::code($secret, intdiv(1234567890, 30)));
+ }
+
+ public function testVerifyAcceptsCurrentCode(): void
+ {
+ $secret = \Totp::generateSecret();
+ $code = \Totp::code($secret);
+ $this->assertTrue(\Totp::verify($secret, $code));
+ }
+
+ public function testVerifyRejectsWrongCode(): void
+ {
+ $secret = \Totp::generateSecret();
+ $wrong = \Totp::code($secret) === '000000' ? '111111' : '000000';
+ $this->assertFalse(\Totp::verify($secret, $wrong));
+ }
+
+ public function testVerifyRejectsMalformed(): void
+ {
+ $secret = \Totp::generateSecret();
+ $this->assertFalse(\Totp::verify($secret, 'abcdef'));
+ $this->assertFalse(\Totp::verify($secret, '12345'));
+ }
+
+ public function testProvisioningUri(): void
+ {
+ $uri = \Totp::provisioningUri('ABC', 'user@example.com', 'My App');
+ $this->assertStringStartsWith('otpauth://totp/', $uri);
+ $this->assertStringContainsString('secret=ABC', $uri);
+ }
+}