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:
parent
5d72febadc
commit
61810eb050
30 changed files with 1360 additions and 29 deletions
140
includes/Kiosk.php
Normal file
140
includes/Kiosk.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue