E-Mail-Retry, Voucher-Resend, Tageslimit, Docker-Politur

- Mailer::send mit Retry (2 Versuche); SMTP-Test & Templates waren bereits da
- admin/vouchers.php: Code per E-Mail (erneut) versenden (ajax_resend)
- Tageslimit Voucher pro Nicht-Admin-Benutzer (Setting + Durchsetzung in index)
- Docker: HEALTHCHECK (health.php) + curl; GHCR-Publish-Workflow
- Setting user_daily_voucher_limit + enforce in integrations.php
This commit is contained in:
Claude 2026-06-05 20:56:26 +00:00
parent 2e0f36d6c1
commit 997cda01a8
No known key found for this signature in database
14 changed files with 350 additions and 8 deletions

View file

@ -32,3 +32,26 @@ jobs:
- name: Validate JSON language/migration assets
run: |
php -r 'foreach (glob("lang/*.php") as $f) { $a = require $f; if (!is_array($a)) { fwrite(STDERR, "Bad lang file: $f\n"); exit(1);} } echo "lang OK\n";'
test:
name: Unit Tests & Static Analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP 8.2
uses: shivammathur/setup-php@v2
with:
php-version: "8.2"
extensions: pdo, pdo_mysql, curl, mbstring, json
tools: composer
coverage: none
- name: Install dependencies
run: composer install --no-interaction --no-progress
- name: PHPUnit
run: vendor/bin/phpunit
- name: PHPStan
run: vendor/bin/phpstan analyse --no-progress

39
.github/workflows/docker-publish.yml vendored Normal file
View file

@ -0,0 +1,39 @@
name: Docker Publish
on:
push:
tags: [ "v*" ]
workflow_dispatch:
jobs:
build-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View file

@ -1,8 +1,10 @@
# UniFi Voucher Management System Container-Image
FROM php:8.2-apache
# PHP-Extensions
RUN docker-php-ext-install pdo pdo_mysql \
# System-Tools (curl für Healthcheck) + PHP-Extensions
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* \
&& docker-php-ext-install pdo pdo_mysql \
&& a2enmod rewrite headers
# Empfohlene PHP-Einstellungen
@ -25,5 +27,10 @@ COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 80
# Apache-Worker laufen als www-data (Privilege-Drop durch den Master).
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost/health.php || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["apache2-foreground"]

View file

@ -24,6 +24,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
$error = __('error_csrf');
} else {
$db->setSetting('enforce_2fa_admins', isset($_POST['enforce_2fa_admins']) ? '1' : '0');
$db->setSetting('user_daily_voucher_limit', max(0, (int)($_POST['user_daily_voucher_limit'] ?? 0)));
$db->setSetting('trusted_proxy', trim($_POST['trusted_proxy'] ?? ''));
$db->setSetting('webhook_enabled', isset($_POST['webhook_enabled']) ? '1' : '0');
$db->setSetting('webhook_url', trim($_POST['webhook_url'] ?? ''));
@ -41,6 +42,7 @@ if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrf
}
$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
$dailyLimit = (int)$db->getSetting('user_daily_voucher_limit', 0);
$trustedProxy = $db->getSetting('trusted_proxy', '');
$webhookEnabled = $db->getSetting('webhook_enabled', '0') === '1';
$webhookUrl = $db->getSetting('webhook_url', '');
@ -86,6 +88,8 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
<h2>Sicherheitsrichtlinie</h2>
<p class="muted">Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.</p>
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</label>
<label>Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)</label>
<input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;">
</div>
<div class="card">

View file

@ -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() {
<td>${statusBadge}</td>
<td><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
<td>${remaining?`<span style="color:var(--success)"><i class="fas fa-clock"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} Min.</small></td>
<td><button onclick="deleteVoucher('${v._id}')" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button></td>
<td style="white-space:nowrap;">
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="Per E-Mail senden"><i class="fas fa-envelope"></i></button>
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button>
</td>
</tr>`;
});
@ -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}`);

21
composer.json Normal file
View file

@ -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"
}
}

View file

@ -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) {

View file

@ -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'));

6
phpstan.neon Normal file
View file

@ -0,0 +1,6 @@
parameters:
level: 5
paths:
- includes/Totp.php
- includes/Crypto.php
- includes/ApiKey.php

11
phpunit.xml.dist Normal file
View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="vendor/autoload.php"
colors="true"
failOnWarning="true">
<testsuites>
<testsuite name="unit">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

35
tests/ApiKeyTest.php Normal file
View file

@ -0,0 +1,35 @@
<?php
namespace Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../includes/ApiKey.php';
final class ApiKeyTest extends TestCase
{
public function testGenerateFormat(): void
{
$k = \ApiKey::generate();
$this->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']);
}
}

39
tests/CryptoTest.php Normal file
View file

@ -0,0 +1,39 @@
<?php
namespace Tests;
use PHPUnit\Framework\TestCase;
if (!defined('APP_KEY')) {
define('APP_KEY', base64_encode(random_bytes(32)));
}
require_once __DIR__ . '/../includes/Crypto.php';
final class CryptoTest extends TestCase
{
public function testRoundtrip(): void
{
$plain = 'geheim;mit"sonder@zeichen';
$cipher = \Crypto::encrypt($plain);
$this->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)));
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../updater/MigrationRunner.php';
final class MigrationSplitterTest extends TestCase
{
private function split(string $sql): array
{
$rc = new \ReflectionClass(\Updater\MigrationRunner::class);
$inst = $rc->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'));
}
}

46
tests/TotpTest.php Normal file
View file

@ -0,0 +1,46 @@
<?php
namespace Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../includes/Totp.php';
final class TotpTest extends TestCase
{
/** RFC 6238 Testvektoren (SHA1, 6 Stellen, Seed "12345678901234567890"). */
public function testRfc6238Vectors(): void
{
$secret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'; // Base32 des RFC-Seeds
$this->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);
}
}