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

222
kiosk.php Normal file
View file

@ -0,0 +1,222 @@
<?php
/**
* Öffentliche Display-Seite ("Kiosk").
*
* Aufruf: kiosk.php?k=<token>
*
* Gedacht für ein Tablet oder einen Bildschirm im Empfangsbereich: ein großer
* Knopf, ein Klick, ein Zugangscode. Gäste ohne Zugriff auf den Bildschirm
* können denselben Link über den QR-Code am Handy öffnen.
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/I18n.php';
require_once __DIR__ . '/includes/Ui.php';
require_once __DIR__ . '/includes/Kiosk.php';
require_once __DIR__ . '/includes/VoucherService.php';
I18n::init();
try {
$db = Database::getInstance();
$auth = new Auth();
} catch (Exception $e) {
http_response_code(500);
die('Datenbankfehler');
}
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$token = Kiosk::sanitizeToken($_GET['k'] ?? '');
$kiosk = $token !== '' ? Kiosk::findByToken($db, $token) : null;
if (!$kiosk) {
http_response_code(404);
$notFound = true;
} else {
$notFound = false;
}
$voucher = null; // erzeugter Code
$error = '';
$waitSecs = 0;
if (!$notFound && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
$limits = Kiosk::checkLimits($db, $kiosk);
if (!$limits['allowed']) {
$waitSecs = (int)$limits['wait'];
$error = $limits['reason'] === 'cooldown'
? str_replace('{seconds}', (string)$waitSecs, __('kiosk_error_cooldown'))
: __('kiosk_error_limit');
} else {
try {
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [(int)$kiosk['site_id']]);
if (!$site) {
throw new Exception(__('error_site_not_found'));
}
$settings = Kiosk::voucherSettings($db, $kiosk);
$voucher = VoucherService::create(
$db,
$site,
$kiosk['name'],
$settings['max_uses'],
$settings['expire_minutes'],
null,
$settings['qos'],
(int)$kiosk['id']
);
Kiosk::markUsed($db, (int)$kiosk['id']);
$auth->writeAuditLog(null, 'voucher_kiosk', 'kiosk', (int)$kiosk['id'],
$kiosk['name'] . ' · ' . $voucher['code']);
} catch (Exception $e) {
error_log('Kiosk-Fehler: ' . $e->getMessage());
$error = __('kiosk_error_generic');
}
}
}
}
$headline = trim((string)($kiosk['headline'] ?? '')) ?: __('kiosk_default_headline');
$subline = trim((string)($kiosk['subline'] ?? '')) ?: __('kiosk_default_subline');
$logoUrl = $db->getSetting('logo_url', '');
$display = max(10, (int)($kiosk['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS));
$selfUrl = $kiosk ? Kiosk::publicUrl($kiosk['token']) : '';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title><?= htmlspecialchars($appTitle) ?></title>
<?= Ui::head($db) ?>
<?php if (!$notFound): ?>
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js') ?>
<?php endif; ?>
</head>
<body class="kiosk-body">
<?php if ($notFound): ?>
<main class="kiosk-stage">
<div class="kiosk-card">
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-link-slash" aria-hidden="true"></i></div>
<h1><?= __('kiosk_unknown') ?></h1>
<p><?= __('kiosk_unknown_hint') ?></p>
</div>
</div>
</main>
<?php elseif ($voucher): ?>
<main class="kiosk-stage">
<div class="kiosk-card kiosk-result">
<p class="kiosk-eyebrow"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('kiosk_ready') ?></p>
<div class="kiosk-code" id="voucherCode"><?= htmlspecialchars($voucher['code']) ?></div>
<div class="kiosk-meta">
<span><i class="fas fa-location-dot" aria-hidden="true"></i> <?= htmlspecialchars($voucher['site_name']) ?></span>
<span><i class="fas fa-clock" aria-hidden="true"></i> <?= (int)$voucher['expire_min'] ?> <?= __('minutes_short') ?></span>
<span><i class="fas fa-mobile-screen" aria-hidden="true"></i> <?= (int)$voucher['max_uses'] ?> <?= __('label_devices') ?></span>
</div>
<div class="kiosk-qr">
<div id="qrcode"></div>
<p class="kiosk-qr-label"><?= __('kiosk_scan_code') ?></p>
</div>
<p class="kiosk-countdown">
<?= str_replace('{seconds}', '<span id="countdown">' . $display . '</span>', __('kiosk_reset_in')) ?>
</p>
<a class="btn btn-secondary btn-lg" href="?k=<?= htmlspecialchars($kiosk['token']) ?>"><?= __('kiosk_done') ?></a>
</div>
</main>
<?php else: ?>
<main class="kiosk-stage">
<div class="kiosk-card">
<?php if ($logoUrl): ?>
<img class="kiosk-logo" src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="<?= htmlspecialchars($appTitle) ?>">
<?php else: ?>
<span class="brand-mark kiosk-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
<?php endif; ?>
<h1 class="kiosk-headline"><?= htmlspecialchars($headline) ?></h1>
<p class="kiosk-subline"><?= htmlspecialchars($subline) ?></p>
<?php if ($error): ?>
<div class="alert alert-error kiosk-alert"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="post" id="kioskForm">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
<button type="submit" class="btn btn-primary kiosk-button" id="kioskButton"
<?= $waitSecs > 0 ? 'disabled' : '' ?>>
<i class="fas fa-wifi" aria-hidden="true"></i>
<span><?= __('kiosk_button') ?></span>
</button>
</form>
<div class="kiosk-phone">
<div id="selfQr" class="kiosk-phone-qr"></div>
<p><?= __('kiosk_phone_hint') ?></p>
</div>
</div>
</main>
<?php endif; ?>
<footer class="kiosk-footer"><?= Ui::credit() ?></footer>
<?php if (!$notFound): ?>
<script>
(function () {
<?php if ($voucher): ?>
// Code als QR lokal erzeugt, ohne externen Dienst.
new QRCode(document.getElementById('qrcode'), {
text: <?= json_encode(str_replace('-', '', $voucher['code'])) ?>,
width: 240, height: 240,
colorDark: '#101625', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
// Nach der Anzeigedauer zurück zum Startbildschirm, damit der nächste
// Gast nicht den Code seines Vorgängers sieht.
var left = <?= $display ?>;
var out = document.getElementById('countdown');
setInterval(function () {
left -= 1;
if (out) out.textContent = left > 0 ? left : 0;
if (left <= 0) location.href = '?k=<?= htmlspecialchars($kiosk['token'], ENT_QUOTES) ?>';
}, 1000);
<?php else: ?>
// QR auf diese Seite selbst: Gäste öffnen sie am eigenen Handy.
new QRCode(document.getElementById('selfQr'), {
text: <?= json_encode($selfUrl) ?>,
width: 150, height: 150,
colorDark: '#101625', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
// Doppelklicks auf dem Touch-Display verhindern.
var form = document.getElementById('kioskForm');
var button = document.getElementById('kioskButton');
if (form && button) {
form.addEventListener('submit', function () {
button.disabled = true;
button.querySelector('span').textContent = <?= json_encode(__('kiosk_working')) ?>;
});
}
<?php if ($waitSecs > 0): ?>
// Nach der Wartezeit wieder freigeben.
setTimeout(function () { location.href = '?k=<?= htmlspecialchars($kiosk['token'], ENT_QUOTES) ?>'; }, <?= $waitSecs * 1000 ?>);
<?php endif; ?>
<?php endif; ?>
})();
</script>
<?php endif; ?>
</body>
</html>