Display-Seiten: Gäste holen sich den Zugang selbst

Für Empfang, Lobby oder Tagungsraum lässt sich je Site eine öffentliche
Seite anlegen (kiosk.php), die auf einem Bildschirm oder Tablet läuft:
ein großer Knopf, ein Klick, ein Zugangscode mit QR-Code. Nach einer
einstellbaren Anzeigedauer springt der Bildschirm zurück, damit der
nächste Gast nicht den Code seines Vorgängers sieht.

Verwaltung unter Administration → Display-Seiten:
- Site und optionales Voucher-Profil (bestimmt Laufzeit, Geräte, QoS)
- eigene Überschrift und Text für den Bildschirm
- Codes pro Tag, Wartezeit zwischen zwei Codes, Anzeigedauer
- geheimer Link zum Kopieren, als QR-Code anzeigbar und jederzeit
  erneuerbar (der alte Link gilt dann sofort nicht mehr)

Absicherung: der Link ist der Zugang, deshalb Tageslimit und Wartezeit
je Display, CSRF-Token am Formular, `noindex` im Kopf und ein Eintrag im
Audit-Log für jeden ausgegebenen Code. Webhooks werden für Kiosk-Codes
bewusst nicht ausgelöst – ein Empfangsdisplay würde den Kanal fluten.

Technik:
- neue Tabelle `kiosks`, `vouchers.kiosk_id` hält die Herkunft fest
  (Migration 0005, database.sql nachgezogen)
- includes/Kiosk.php kapselt Token, Limits und Profil-Auflösung
- includes/VoucherService.php bündelt die Voucher-Erstellung, die vorher
  in index.php lag und für den Kiosk ein zweites Mal nötig gewesen wäre
- Startbildschirm zeigt zusätzlich einen QR auf sich selbst, damit Gäste
  die Seite am eigenen Handy öffnen können

Tests: 9 neue Fälle für Token-Prüfung, Wartezeit, Tageslimit und
Profil-Auflösung (38 Tests gesamt), PHPStan deckt Kiosk.php mit ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-23 16:51:50 +00:00
parent 5d72febadc
commit 61810eb050
30 changed files with 1360 additions and 29 deletions

140
includes/Kiosk.php Normal file
View file

@ -0,0 +1,140 @@
<?php
/**
* Öffentliche Display-Seiten ("Kiosk").
*
* Ein Kiosk gehört zu genau einer Site und ist über einen geheimen Link
* erreichbar. Gäste holen sich darüber mit einem Klick einen Zugangscode
* ohne Anmeldung, aber begrenzt durch Tageslimit und Wartezeit.
*/
class Kiosk
{
/** Vorgaben für neue Kiosk-Seiten. */
public const DEFAULT_DAILY_LIMIT = 100;
public const DEFAULT_COOLDOWN = 20; // Sekunden zwischen zwei Codes
public const DEFAULT_DISPLAY_SECONDS = 90; // Anzeigedauer des Codes
/** Unrat-freier Zufallstoken für den öffentlichen Link. */
public static function newToken(): string
{
return bin2hex(random_bytes(16));
}
/** Token aus einer Anfrage säubern (Länge und Zeichen fest vorgegeben). */
public static function sanitizeToken(?string $token): string
{
$token = strtolower(trim((string)$token));
return preg_match('/^[0-9a-f]{32}$/', $token) ? $token : '';
}
/**
* Kiosk samt Site und Profil laden. Liefert null, wenn der Token nicht
* passt, der Kiosk deaktiviert ist oder die Site nicht mehr aktiv ist.
*/
public static function findByToken($db, string $token): ?array
{
$token = self::sanitizeToken($token);
if ($token === '') {
return null;
}
$row = $db->fetchOne(
"SELECT k.*, s.name AS site_name, s.is_active AS site_active,
t.name AS template_name, t.max_uses AS tpl_max_uses, t.expire_minutes AS tpl_expire_minutes,
t.qos_rate_max_down, t.qos_rate_max_up, t.qos_usage_quota
FROM kiosks k
INNER JOIN sites s ON s.id = k.site_id
LEFT JOIN voucher_templates t ON t.id = k.template_id
WHERE k.token = ? AND k.is_active = 1",
[$token]
);
if (!$row || (int)$row['site_active'] !== 1) {
return null;
}
return $row;
}
/** Wie viele Codes hat dieser Kiosk heute schon ausgegeben? */
public static function usedToday($db, int $kioskId): int
{
$row = $db->fetchOne(
"SELECT COUNT(*) AS c FROM vouchers WHERE kiosk_id = ? AND DATE(created_at) = CURDATE()",
[$kioskId]
);
return (int)($row['c'] ?? 0);
}
/**
* Darf gerade ein Code geholt werden?
*
* @return array{allowed:bool,reason:string,wait:int}
* reason: '' | 'cooldown' | 'daily_limit'
*/
public static function checkLimits($db, array $kiosk): array
{
$cooldown = max(0, (int)$kiosk['cooldown_seconds']);
if ($cooldown > 0 && !empty($kiosk['last_used_at'])) {
$elapsed = time() - strtotime((string)$kiosk['last_used_at']);
if ($elapsed >= 0 && $elapsed < $cooldown) {
return ['allowed' => false, 'reason' => 'cooldown', 'wait' => $cooldown - $elapsed];
}
}
$limit = max(0, (int)$kiosk['daily_limit']);
if ($limit > 0 && self::usedToday($db, (int)$kiosk['id']) >= $limit) {
return ['allowed' => false, 'reason' => 'daily_limit', 'wait' => 0];
}
return ['allowed' => true, 'reason' => '', 'wait' => 0];
}
/** Nach erfolgreicher Ausgabe den Zeitstempel fortschreiben. */
public static function markUsed($db, int $kioskId): void
{
$db->execute("UPDATE kiosks SET last_used_at = NOW() WHERE id = ?", [$kioskId]);
}
/**
* Voucher-Eckdaten eines Kiosks: entweder aus dem verknüpften Profil
* oder aus den globalen Standardwerten.
*/
public static function voucherSettings($db, array $kiosk): array
{
$maxUses = (int)($kiosk['tpl_max_uses'] ?? 0);
$expire = (int)($kiosk['tpl_expire_minutes'] ?? 0);
if ($maxUses < 1) {
$maxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
}
if ($expire < 1) {
$expire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
}
return [
'max_uses' => $maxUses,
'expire_minutes' => $expire,
'qos' => [
'down' => max(0, (int)($kiosk['qos_rate_max_down'] ?? 0)),
'up' => max(0, (int)($kiosk['qos_rate_max_up'] ?? 0)),
'quota_mb' => max(0, (int)($kiosk['qos_usage_quota'] ?? 0)),
],
];
}
/** Öffentliche Adresse eines Kiosks. */
public static function publicUrl(string $token, string $baseUrl = ''): string
{
if ($baseUrl === '') {
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$path = dirname($_SERVER['SCRIPT_NAME'] ?? '/', 2);
$path = $path === '/' || $path === '\\' ? '' : $path;
$baseUrl = $protocol . '://' . $host . $path;
}
return rtrim($baseUrl, '/') . '/kiosk.php?k=' . $token;
}
}

View file

@ -0,0 +1,63 @@
<?php
/**
* Erstellt Voucher im UniFi-Controller und schreibt sie in die Datenbank.
*
* Bis hierher lag diese Logik doppelt in index.php und der REST-API; mit der
* Kiosk-Seite waere sie ein drittes Mal noetig gewesen.
*/
require_once __DIR__ . '/UniFiController.php';
require_once __DIR__ . '/Crypto.php';
class VoucherService
{
/**
* @param array $site Zeile aus `sites`
* @param array $qos ['down' => kbit, 'up' => kbit, 'quota_mb' => MB]
* @param int|null $userId angemeldeter Benutzer, sonst null
* @param int|null $kioskId Herkunft, falls ueber eine Display-Seite geholt
*
* @return array{code:string,site_name:string,max_uses:int,expire_min:int,expiry_date:string,expiry_time:string}
* @throws Exception wenn der Controller keinen gueltigen Voucher liefert
*/
public static function create(
$db,
array $site,
string $voucherName,
int $maxUses,
int $expireMinutes,
?int $userId = null,
array $qos = [],
?int $kioskId = null
): array {
$fullName = date('Y-m-d') . '_' . $voucherName;
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
throw new Exception(function_exists('__') ? __('error_voucher_invalid') : 'Ungueltige Antwort des Controllers');
}
$db->execute(
"INSERT INTO vouchers (site_id, user_id, kiosk_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[$site['id'], $userId, $kioskId, $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),
];
}
}

View file

@ -23,6 +23,7 @@ $navGroups = [
['vouchers', 'vouchers.php', 'fa-ticket', 'nav_vouchers'],
['templates', 'templates.php', 'fa-layer-group', 'nav_templates'],
['import', 'import.php', 'fa-file-arrow-up', 'nav_import'],
['kiosks', 'kiosks.php', 'fa-display', 'nav_kiosks'],
['sites', 'sites.php', 'fa-location-dot', 'nav_sites'],
['users', 'users.php', 'fa-users', 'nav_users'],
],