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

155
tests/KioskTest.php Normal file
View file

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../includes/Kiosk.php';
/**
* Datenbank-Attrappe: liefert feste Zählwerte und Einstellungen.
*/
class FakeKioskDb
{
public int $usedToday = 0;
/** @var array<string, string> */
private array $settings;
/** @param array<string, string> $settings */
public function __construct(array $settings = [])
{
$this->settings = $settings;
}
public function fetchOne($sql, $params = [])
{
return ['c' => $this->usedToday];
}
public function getSetting($key, $default = null)
{
return $this->settings[$key] ?? $default;
}
}
/**
* Die Grenzen der Kiosk-Seite sind das, was sie vor Missbrauch schützt
* der Link ist öffentlich, also muss diese Logik stimmen.
*/
class KioskTest extends TestCase
{
/** @param array<string, mixed> $overrides */
private function kiosk(array $overrides = []): array
{
return array_merge([
'id' => 1,
'daily_limit' => 100,
'cooldown_seconds' => 20,
'last_used_at' => null,
], $overrides);
}
public function testTokenHasFixedShape(): void
{
$token = \Kiosk::newToken();
$this->assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $token);
$this->assertNotSame($token, \Kiosk::newToken(), 'Tokens dürfen sich nicht wiederholen');
}
public function testSanitizeTokenRejectsAnythingElse(): void
{
$valid = \Kiosk::newToken();
$this->assertSame($valid, \Kiosk::sanitizeToken($valid));
$this->assertSame($valid, \Kiosk::sanitizeToken(strtoupper($valid)));
$this->assertSame('', \Kiosk::sanitizeToken('kurz'));
$this->assertSame('', \Kiosk::sanitizeToken("' OR 1=1 --"));
$this->assertSame('', \Kiosk::sanitizeToken(null));
$this->assertSame('', \Kiosk::sanitizeToken($valid . 'ff'));
}
public function testCooldownBlocksSecondCode(): void
{
$db = new FakeKioskDb();
$kiosk = $this->kiosk(['last_used_at' => date('Y-m-d H:i:s', time() - 5)]);
$result = \Kiosk::checkLimits($db, $kiosk);
$this->assertFalse($result['allowed']);
$this->assertSame('cooldown', $result['reason']);
$this->assertGreaterThan(0, $result['wait']);
$this->assertLessThanOrEqual(20, $result['wait']);
}
public function testCooldownExpires(): void
{
$db = new FakeKioskDb();
$kiosk = $this->kiosk(['last_used_at' => date('Y-m-d H:i:s', time() - 60)]);
$this->assertTrue(\Kiosk::checkLimits($db, $kiosk)['allowed']);
}
public function testDailyLimitBlocks(): void
{
$db = new FakeKioskDb();
$db->usedToday = 100;
$result = \Kiosk::checkLimits($db, $this->kiosk());
$this->assertFalse($result['allowed']);
$this->assertSame('daily_limit', $result['reason']);
}
public function testZeroMeansUnlimited(): void
{
$db = new FakeKioskDb();
$db->usedToday = 5000;
$kiosk = $this->kiosk(['daily_limit' => 0, 'cooldown_seconds' => 0]);
$this->assertTrue(\Kiosk::checkLimits($db, $kiosk)['allowed']);
}
public function testVoucherSettingsPreferTemplate(): void
{
$db = new FakeKioskDb(['default_max_uses' => '1', 'default_expire_minutes' => '480']);
$kiosk = $this->kiosk([
'tpl_max_uses' => 5,
'tpl_expire_minutes' => 240,
'qos_rate_max_down' => 20000,
'qos_rate_max_up' => 5000,
'qos_usage_quota' => 1024,
]);
$settings = \Kiosk::voucherSettings($db, $kiosk);
$this->assertSame(5, $settings['max_uses']);
$this->assertSame(240, $settings['expire_minutes']);
$this->assertSame(20000, $settings['qos']['down']);
$this->assertSame(1024, $settings['qos']['quota_mb']);
}
public function testVoucherSettingsFallBackToDefaults(): void
{
$db = new FakeKioskDb(['default_max_uses' => '3', 'default_expire_minutes' => '120']);
$settings = \Kiosk::voucherSettings($db, $this->kiosk());
$this->assertSame(3, $settings['max_uses']);
$this->assertSame(120, $settings['expire_minutes']);
$this->assertSame(0, $settings['qos']['down']);
}
public function testPublicUrl(): void
{
$token = \Kiosk::newToken();
$this->assertSame(
'https://wlan.example.com/kiosk.php?k=' . $token,
\Kiosk::publicUrl($token, 'https://wlan.example.com/')
);
}
}