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

View file

@ -28,6 +28,7 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
## ✨ Features
- 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand
- 🖥️ **Display-Seiten (Kiosk)** öffentliche Seite je Site, an der Gäste sich mit einem Klick selbst einen Zugang holen
- 📦 **Bulk-Erstellung** bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout
- 🧩 **Voucher-Profile/Templates** vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl
- 🏢 **Multi-Site-Support** beliebig viele UniFi-Standorte zentral verwalten
@ -83,6 +84,13 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)**
<img src="docs/screenshots/settings-login.png" alt="Einstellungen der Login-Seite" width="48%">
</div>
### Display-Seite für Gäste
<div align="center">
<img src="docs/screenshots/kiosk-display.png" alt="Display-Seite im Ruhezustand" width="48%">
<img src="docs/screenshots/kiosk-code.png" alt="Ausgegebener Zugangscode auf dem Display" width="48%">
</div>
<div align="center">
<img src="docs/screenshots/voucher-result.png" alt="Voucher-Ergebnis mit QR-Code" width="48%">
<img src="docs/screenshots/bulk-vouchers.png" alt="Bulk-Voucher-Erstellung" width="48%">
@ -214,6 +222,47 @@ Während eines Updates wird die Anwendung kurz in den **Wartungsmodus** versetzt
---
## 🖥️ Display-Seiten für Gäste
Für Empfang, Lobby oder Tagungsraum lässt sich je Site eine **öffentliche Seite**
anlegen, die auf einem Bildschirm oder Tablet läuft. Gäste tippen auf einen
Knopf und bekommen sofort einen eigenen Zugangscode ohne Anmeldung, ohne
Personal am Tresen.
**Anlegen:** Administration → **Display-Seiten** → *Display-Seite anlegen*
<div align="center">
<img src="docs/screenshots/kiosks-admin.png" alt="Verwaltung der Display-Seiten" width="80%">
</div>
| Einstellung | Wirkung |
|---|---|
| Site | für welchen Standort die Codes erzeugt werden |
| Voucher-Profil | Laufzeit, Geräteanzahl und Bandbreite der Codes (leer = Standardwerte) |
| Überschrift / Text | was auf dem Bildschirm steht |
| Codes pro Tag | Obergrenze je Kalendertag (0 = unbegrenzt) |
| Wartezeit | Abstand zwischen zwei Codes an diesem Display |
| Anzeigedauer | danach springt der Bildschirm automatisch zurück |
Jede Seite hat einen **eigenen, geheimen Link** (`kiosk.php?k=…`). Er lässt sich
kopieren, als QR-Code anzeigen (praktisch, um ihn am Tablet zu öffnen) und
jederzeit erneuern der alte Link ist dann sofort ungültig. Den Link nicht
öffentlich verbreiten: wer ihn hat, kann im Rahmen der Limits Codes ziehen.
Auf dem Startbildschirm steht zusätzlich ein QR-Code, der auf dieselbe Seite
zeigt. Gäste können sie damit **am eigenen Handy** öffnen praktisch bei
Bildschirmen ohne Touch.
Die ausgegebenen Codes erscheinen normal in *Live Vouchers*, im *Reporting* und
im *Audit-Log* (Aktion „Voucher am Display geholt"), sodass jederzeit
nachvollziehbar bleibt, woher ein Zugang stammt.
> Display-Seiten funktionieren unabhängig vom globalen öffentlichen Modus der
> geheime Link ist der Zugang. Webhook-Benachrichtigungen werden für diese Codes
> bewusst **nicht** ausgelöst, sonst wäre der Slack-Kanal voll.
---
## ⚙️ Konfiguration
### `config.php`
@ -605,6 +654,7 @@ dem Git-Hoster.
- [x] Branding über die Oberfläche (Farben, Logo, Login-Seite)
- [x] Assets lokal ausliefern (keine Drittanbieter-CDNs)
- [x] Vollständige englische Übersetzung des Admin-Bereichs
- [x] Display-Seiten: Selbstbedienung für Gäste am Bildschirm
---

View file

@ -50,7 +50,8 @@ $actionLabels = [];
foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created',
'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted',
'settings_saved', 'password_reset', 'template_created', 'template_updated',
'template_deleted'] as $action) {
'template_deleted', 'voucher_kiosk', 'kiosk_created', 'kiosk_updated',
'kiosk_deleted'] as $action) {
$actionLabels[$action] = __('audit_action_' . $action);
}
?>

411
admin/kiosks.php Normal file
View file

@ -0,0 +1,411 @@
<?php
/**
* Verwaltung der öffentlichen Display-Seiten ("Kiosk").
*
* Jeder Kiosk gehört zu einer Site, hat einen geheimen Link und gibt über
* kiosk.php Zugangscodes aus ohne Anmeldung, aber mit Tageslimit und
* Wartezeit zwischen zwei Codes.
*/
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';
$auth = new Auth();
$auth->requireAdmin();
I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$error = '';
$success = '';
/** Formularwerte einsammeln für Anlegen und Bearbeiten identisch. */
function kioskInput(): array
{
return [
'site_id' => (int)($_POST['site_id'] ?? 0),
'template_id' => (int)($_POST['template_id'] ?? 0) ?: null,
'name' => trim((string)($_POST['name'] ?? '')),
'headline' => trim((string)($_POST['headline'] ?? '')),
'subline' => trim((string)($_POST['subline'] ?? '')),
'daily_limit' => max(0, (int)($_POST['daily_limit'] ?? Kiosk::DEFAULT_DAILY_LIMIT)),
'cooldown_seconds' => max(0, min(3600, (int)($_POST['cooldown_seconds'] ?? Kiosk::DEFAULT_COOLDOWN))),
'display_seconds' => max(10, min(600, (int)($_POST['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS))),
'is_active' => isset($_POST['is_active']) ? 1 : 0,
];
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_kiosk'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
try {
$in = kioskInput();
if ($in['name'] === '') throw new Exception(__('error_name_req'));
if ($in['site_id'] <= 0) throw new Exception(__('error_site_req'));
$db->execute(
"INSERT INTO kiosks (site_id, template_id, name, token, headline, subline, daily_limit, cooldown_seconds, display_seconds, is_active, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)",
[$in['site_id'], $in['template_id'], $in['name'], Kiosk::newToken(),
$in['headline'], $in['subline'], $in['daily_limit'], $in['cooldown_seconds'],
$in['display_seconds'], $_SESSION['user_id']]
);
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_created', 'kiosk', null, $in['name']);
$success = __('kiosks_added');
} catch (Exception $e) {
$error = $e->getMessage();
}
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_kiosk'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
try {
$id = (int)($_POST['kiosk_id'] ?? 0);
$in = kioskInput();
if ($in['name'] === '') throw new Exception(__('error_name_req'));
if ($in['site_id'] <= 0) throw new Exception(__('error_site_req'));
$db->execute(
"UPDATE kiosks SET site_id=?, template_id=?, name=?, headline=?, subline=?,
daily_limit=?, cooldown_seconds=?, display_seconds=?, is_active=?
WHERE id=?",
[$in['site_id'], $in['template_id'], $in['name'], $in['headline'], $in['subline'],
$in['daily_limit'], $in['cooldown_seconds'], $in['display_seconds'], $in['is_active'], $id]
);
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, $in['name']);
$success = __('kiosks_updated');
} catch (Exception $e) {
$error = $e->getMessage();
}
}
}
// Neuen Link erzeugen der alte gilt damit sofort nicht mehr.
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['renew_token'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
$id = (int)($_POST['kiosk_id'] ?? 0);
$db->execute("UPDATE kiosks SET token = ? WHERE id = ?", [Kiosk::newToken(), $id]);
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, 'Link erneuert');
$success = __('kiosks_token_renewed');
}
}
if (isset($_GET['delete'], $_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$db->execute("DELETE FROM kiosks WHERE id = ?", [(int)$_GET['delete']]);
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_deleted', 'kiosk', (int)$_GET['delete'], '');
$success = __('kiosks_deleted');
} else {
$error = __('error_csrf');
}
}
$sites = $db->fetchAll("SELECT id, name FROM sites WHERE is_active = 1 ORDER BY name");
$templates = $db->fetchAll("SELECT id, name, max_uses, expire_minutes FROM voucher_templates WHERE is_active = 1 ORDER BY name");
$kiosks = $db->fetchAll(
"SELECT k.*, s.name AS site_name, t.name AS template_name,
(SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id) AS total_vouchers,
(SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id AND DATE(v.created_at) = CURDATE()) AS today_vouchers
FROM kiosks k
INNER JOIN sites s ON s.id = k.site_id
LEFT JOIN voucher_templates t ON t.id = k.template_id
ORDER BY k.is_active DESC, k.name"
);
$csrf = $auth->getCsrfToken();
$currentPage = 'kiosks';
$adminBase = '';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('kiosks_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js', '../') ?>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header">
<div>
<h1 class="page-title"><?= __('kiosks_title') ?></h1>
<p class="page-subtitle"><?= __('kiosks_subtitle') ?></p>
</div>
<button onclick="openAddModal()" class="btn btn-primary">
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('kiosks_add') ?>
</button>
</div>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
<?php if (empty($sites)): ?>
<div class="empty-card">
<div class="empty-icon"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
<p><?= __('kiosks_no_sites') ?></p>
<a href="sites.php" class="btn btn-primary" style="margin-top:16px;">
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?>
</a>
</div>
<?php elseif (empty($kiosks)): ?>
<div class="empty-card">
<div class="empty-icon"><i class="fas fa-display" aria-hidden="true"></i></div>
<p><?= __('kiosks_empty') ?></p>
<button onclick="openAddModal()" class="btn btn-primary" style="margin-top:16px;">
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('kiosks_add') ?>
</button>
</div>
<?php else: ?>
<div class="sites-grid">
<?php foreach ($kiosks as $k): ?>
<?php $url = Kiosk::publicUrl($k['token']); ?>
<div class="site-card">
<div class="site-card-header">
<div>
<div class="site-name"><?= htmlspecialchars($k['name']) ?></div>
<div class="site-id-label"><?= htmlspecialchars($k['site_name']) ?></div>
</div>
<span class="badge <?= $k['is_active'] ? 'badge-success' : 'badge-neutral' ?>">
<?= $k['is_active'] ? __('status_active') : __('status_inactive') ?>
</span>
</div>
<div class="site-info">
<div class="site-info-item">
<i class="fas fa-layer-group" aria-hidden="true"></i>
<?= $k['template_name'] ? htmlspecialchars($k['template_name']) : __('kiosks_no_template') ?>
</div>
<div class="site-info-item">
<i class="fas fa-gauge-high" aria-hidden="true"></i>
<?= (int)$k['today_vouchers'] ?><?= (int)$k['daily_limit'] > 0 ? ' / ' . (int)$k['daily_limit'] : '' ?>
<?= __('kiosks_today') ?>
</div>
<div class="site-info-item">
<i class="fas fa-ticket" aria-hidden="true"></i>
<?= (int)$k['total_vouchers'] ?> <?= __('kiosks_total') ?>
</div>
</div>
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('kiosks_link') ?></label>
<div class="kiosk-link-row">
<input type="text" class="input" readonly value="<?= htmlspecialchars($url) ?>"
id="link-<?= (int)$k['id'] ?>" onclick="this.select()">
<button class="btn btn-secondary" type="button"
onclick="copyToClipboard('<?= htmlspecialchars($url, ENT_QUOTES) ?>', '<?= __('js_copied') ?>')"
title="<?= __('js_copy') ?>" aria-label="<?= __('js_copy') ?>">
<i class="fas fa-copy" aria-hidden="true"></i>
</button>
</div>
<div class="site-actions">
<a class="btn btn-secondary btn-sm" href="<?= htmlspecialchars($url) ?>" target="_blank" rel="noopener">
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> <?= __('kiosks_open') ?>
</a>
<button class="btn btn-secondary btn-sm" type="button"
onclick="showQr('<?= htmlspecialchars($url, ENT_QUOTES) ?>', '<?= htmlspecialchars($k['name'], ENT_QUOTES) ?>')">
<i class="fas fa-qrcode" aria-hidden="true"></i> <?= __('kiosks_qr') ?>
</button>
<button class="btn btn-secondary btn-sm" type="button"
onclick='openEditModal(<?= json_encode([
"id" => (int)$k["id"], "site_id" => (int)$k["site_id"],
"template_id" => (int)$k["template_id"], "name" => $k["name"],
"headline" => $k["headline"], "subline" => $k["subline"],
"daily_limit" => (int)$k["daily_limit"],
"cooldown_seconds" => (int)$k["cooldown_seconds"],
"display_seconds" => (int)$k["display_seconds"],
"is_active" => (int)$k["is_active"],
], JSON_HEX_APOS | JSON_HEX_QUOT) ?>)'>
<i class="fas fa-edit" aria-hidden="true"></i> <?= __('btn_edit') ?>
</button>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= __('kiosks_renew_confirm') ?>');">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="kiosk_id" value="<?= (int)$k['id'] ?>">
<button class="btn btn-secondary btn-sm" type="submit" name="renew_token">
<i class="fas fa-rotate" aria-hidden="true"></i> <?= __('kiosks_renew') ?>
</button>
</form>
<a class="btn btn-danger-soft btn-sm"
href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"
onclick="return confirm('<?= __('kiosks_delete_confirm') ?>');"
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
<i class="fas fa-trash" aria-hidden="true"></i>
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Anlegen / Bearbeiten -->
<div class="modal" id="kioskModal">
<div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="kioskModalTitle">
<div class="modal-header">
<h2 class="modal-title" id="kioskModalTitle"><?= __('kiosks_add') ?></h2>
<button class="modal-close" type="button" onclick="closeModal()" aria-label="<?= __('btn_cancel') ?>">&times;</button>
</div>
<form method="post">
<div class="modal-body">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="kiosk_id" id="kiosk_id" value="">
<div class="form-group">
<label for="name"><?= __('kiosks_name') ?></label>
<input type="text" id="name" name="name" required placeholder="<?= __('kiosks_name_placeholder') ?>">
<div class="help-text"><?= __('kiosks_name_hint') ?></div>
</div>
<div class="form-grid">
<div class="form-group">
<label for="site_id"><?= __('label_site') ?></label>
<select id="site_id" name="site_id" required>
<?php foreach ($sites as $s): ?>
<option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="template_id"><?= __('kiosks_template') ?></label>
<select id="template_id" name="template_id">
<option value="0"><?= __('kiosks_no_template') ?></option>
<?php foreach ($templates as $t): ?>
<option value="<?= (int)$t['id'] ?>">
<?= htmlspecialchars($t['name']) ?> <?= (int)$t['max_uses'] ?> <?= __('label_devices') ?>,
<?= (int)$t['expire_minutes'] ?> <?= __('minutes_short') ?>
</option>
<?php endforeach; ?>
</select>
<div class="help-text"><?= __('kiosks_template_hint') ?></div>
</div>
</div>
<hr class="section-divider">
<div class="form-group">
<label for="headline"><?= __('kiosks_headline') ?></label>
<input type="text" id="headline" name="headline" placeholder="<?= htmlspecialchars(__('kiosk_default_headline')) ?>">
</div>
<div class="form-group">
<label for="subline"><?= __('kiosks_subline') ?></label>
<textarea id="subline" name="subline" rows="2" placeholder="<?= htmlspecialchars(__('kiosk_default_subline')) ?>"></textarea>
</div>
<hr class="section-divider">
<div class="form-grid">
<div class="form-group">
<label for="daily_limit"><?= __('kiosks_daily_limit') ?></label>
<input type="number" id="daily_limit" name="daily_limit" min="0" value="<?= Kiosk::DEFAULT_DAILY_LIMIT ?>">
<div class="help-text"><?= __('kiosks_daily_limit_hint') ?></div>
</div>
<div class="form-group">
<label for="cooldown_seconds"><?= __('kiosks_cooldown') ?></label>
<input type="number" id="cooldown_seconds" name="cooldown_seconds" min="0" max="3600" value="<?= Kiosk::DEFAULT_COOLDOWN ?>">
<div class="help-text"><?= __('kiosks_cooldown_hint') ?></div>
</div>
<div class="form-group">
<label for="display_seconds"><?= __('kiosks_display') ?></label>
<input type="number" id="display_seconds" name="display_seconds" min="10" max="600" value="<?= Kiosk::DEFAULT_DISPLAY_SECONDS ?>">
<div class="help-text"><?= __('kiosks_display_hint') ?></div>
</div>
</div>
<div class="checkbox-group" id="activeRow" style="display:none;">
<input type="checkbox" id="is_active" name="is_active" checked>
<label for="is_active"><?= __('kiosks_active') ?></label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal()"><?= __('btn_cancel') ?></button>
<button type="submit" name="add_kiosk" id="submitAdd" class="btn btn-primary">
<i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?>
</button>
<button type="submit" name="edit_kiosk" id="submitEdit" class="btn btn-primary" style="display:none;">
<i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?>
</button>
</div>
</form>
</div>
</div>
<!-- QR-Code des Links -->
<div class="modal" id="qrModal">
<div class="modal-content" style="max-width:420px;" role="dialog" aria-modal="true">
<div class="modal-header">
<h2 class="modal-title" id="qrTitle"><?= __('kiosks_qr') ?></h2>
<button class="modal-close" type="button" onclick="closeQr()" aria-label="<?= __('btn_cancel') ?>">&times;</button>
</div>
<div class="modal-body" style="text-align:center;">
<div id="qrTarget" style="display:inline-block;padding:14px;background:#fff;border-radius:12px;line-height:0;"></div>
<p class="help-text" style="margin-top:14px;"><?= __('kiosks_qr_hint') ?></p>
</div>
</div>
</div>
</main>
<div id="toast-container" role="status" aria-live="polite"></div>
<script src="../assets/global.js"></script>
<script>
function openAddModal() {
document.getElementById('kioskModalTitle').textContent = <?= json_encode(__('kiosks_add')) ?>;
document.querySelector('#kioskModal form').reset();
document.getElementById('kiosk_id').value = '';
document.getElementById('submitAdd').style.display = '';
document.getElementById('submitEdit').style.display = 'none';
document.getElementById('activeRow').style.display = 'none';
document.getElementById('kioskModal').classList.add('active');
}
function openEditModal(data) {
document.getElementById('kioskModalTitle').textContent = <?= json_encode(__('kiosks_edit')) ?>;
document.getElementById('kiosk_id').value = data.id;
document.getElementById('name').value = data.name || '';
document.getElementById('site_id').value = data.site_id;
document.getElementById('template_id').value = data.template_id || 0;
document.getElementById('headline').value = data.headline || '';
document.getElementById('subline').value = data.subline || '';
document.getElementById('daily_limit').value = data.daily_limit;
document.getElementById('cooldown_seconds').value = data.cooldown_seconds;
document.getElementById('display_seconds').value = data.display_seconds;
document.getElementById('is_active').checked = data.is_active === 1;
document.getElementById('submitAdd').style.display = 'none';
document.getElementById('submitEdit').style.display = '';
document.getElementById('activeRow').style.display = '';
document.getElementById('kioskModal').classList.add('active');
}
function closeModal() { document.getElementById('kioskModal').classList.remove('active'); }
function showQr(url, name) {
var target = document.getElementById('qrTarget');
target.innerHTML = '';
document.getElementById('qrTitle').textContent = name;
new QRCode(target, { text: url, width: 260, height: 260, colorDark: '#101625', colorLight: '#ffffff' });
document.getElementById('qrModal').classList.add('active');
}
function closeQr() { document.getElementById('qrModal').classList.remove('active'); }
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeModal(); closeQr(); }
});
document.querySelectorAll('.modal').forEach(function (m) {
m.addEventListener('click', function (e) { if (e.target === m) m.classList.remove('active'); });
});
</script>
</body>
</html>

View file

@ -1610,6 +1610,128 @@ input.search-bar, .site-selector .search-bar { min-width: 240px; width: auto; fl
}
}
/* =========================================================================
16. KIOSK öffentliche Display-Seite
Große Typografie: der Code muss aus einigen Metern Entfernung lesbar sein.
========================================================================= */
.kiosk-body {
min-height: 100vh;
display: flex;
flex-direction: column;
background:
radial-gradient(900px 500px at 12% -10%, var(--accent-soft), transparent 62%),
radial-gradient(700px 460px at 100% 0%, rgba(139,92,246,.10), transparent 64%),
var(--bg-body);
}
.kiosk-stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
}
.kiosk-card {
width: 100%;
max-width: 680px;
padding: 48px 44px;
text-align: center;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--r-xl);
box-shadow: var(--shadow-xl);
}
.kiosk-logo { max-height: 84px; max-width: 320px; margin: 0 auto 26px; display: block; }
.kiosk-mark { width: 62px; height: 62px; margin: 0 auto 26px; font-size: 26px; }
.kiosk-headline {
font-size: clamp(30px, 4.6vw, 46px);
line-height: 1.15;
letter-spacing: -0.03em;
}
.kiosk-subline {
margin-top: 14px;
font-size: clamp(16px, 1.8vw, 20px);
color: var(--text-secondary);
}
.kiosk-alert { justify-content: center; margin: 24px 0 0; text-align: left; }
.kiosk-button {
width: 100%;
margin-top: 34px;
padding: 26px 32px;
gap: 14px;
font-size: clamp(20px, 2.4vw, 26px);
font-weight: 620;
border-radius: var(--r-lg);
}
.kiosk-button i { font-size: 0.95em; }
.kiosk-phone {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 34px;
padding-top: 26px;
border-top: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 13.5px;
}
.kiosk-phone-qr { line-height: 0; }
.kiosk-phone-qr img, .kiosk-phone-qr canvas { border-radius: var(--r-sm); }
.kiosk-result { max-width: 880px; }
.kiosk-eyebrow {
display: inline-flex; align-items: center; gap: 9px;
padding: 6px 16px;
border-radius: var(--r-pill);
background: var(--success-soft);
border: 1px solid var(--success-border);
color: var(--success);
font-size: 14px; font-weight: 600;
}
.kiosk-code {
margin: 26px 0 18px;
font-family: var(--font-mono);
/* Muss aus einigen Metern lesbar sein, aber in einer Zeile bleiben. */
font-size: clamp(38px, 7vw, 76px);
font-weight: 700;
letter-spacing: .06em;
white-space: nowrap;
line-height: 1.05;
color: var(--text-primary);
word-break: break-word;
}
.kiosk-meta {
display: flex; flex-wrap: wrap; justify-content: center; gap: 10px;
font-size: 15px; color: var(--text-secondary);
}
.kiosk-meta span {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 14px;
background: var(--bg-subtle);
border: 1px solid var(--border-color);
border-radius: var(--r-pill);
}
.kiosk-qr { margin: 30px 0 8px; }
.kiosk-qr #qrcode {
display: inline-block;
padding: 16px;
background: #fff;
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
line-height: 0;
}
.kiosk-qr-label { margin-top: 14px; font-size: 15px; color: var(--text-secondary); }
.kiosk-countdown { margin: 18px 0 22px; font-size: 13.5px; color: var(--text-muted); }
.kiosk-footer { padding: 0 24px 22px; text-align: center; }
/* Link-Zeile in der Kiosk-Verwaltung */
.kiosk-link-row { display: flex; gap: 8px; align-items: center; }
.kiosk-link-row .input { font-family: var(--font-mono); font-size: 12px; }
@media (max-width: 560px) {
.kiosk-card { padding: 32px 22px; }
.kiosk-phone { flex-direction: column; }
}
/* =========================================================================
13. RESPONSIVE
========================================================================= */

View file

@ -66,6 +66,29 @@ CREATE TABLE IF NOT EXISTS `voucher_templates` (
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `kiosks` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`site_id` INT NOT NULL,
`template_id` INT NULL,
`name` VARCHAR(255) NOT NULL,
`token` VARCHAR(64) NOT NULL,
`headline` VARCHAR(255) NULL,
`subline` VARCHAR(500) NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`daily_limit` INT NOT NULL DEFAULT 100,
`cooldown_seconds` INT NOT NULL DEFAULT 20,
`display_seconds` INT NOT NULL DEFAULT 90,
`last_used_at` TIMESTAMP NULL,
`created_by` INT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uniq_token` (`token`),
INDEX `idx_site` (`site_id`),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`template_id`) REFERENCES `voucher_templates`(`id`) ON DELETE SET NULL,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `api_keys` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
@ -93,6 +116,7 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`site_id` INT NOT NULL,
`user_id` INT,
`kiosk_id` INT NULL,
`voucher_code` VARCHAR(50) NOT NULL,
`voucher_name` VARCHAR(255) NOT NULL,
`max_uses` INT NOT NULL,
@ -109,7 +133,8 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
INDEX `idx_site` (`site_id`),
INDEX `idx_created` (`created_at`),
INDEX `idx_unifi_id` (`unifi_voucher_id`),
INDEX `idx_status` (`status`)
INDEX `idx_status` (`status`),
INDEX `idx_kiosk` (`kiosk_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `sessions` (

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 396 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 390 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 319 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 319 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 315 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

After

Width:  |  Height:  |  Size: 422 KiB

Before After
Before After

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'],
],

View file

@ -20,6 +20,7 @@ require_once __DIR__ . '/includes/Notifier.php';
require_once __DIR__ . '/includes/Captcha.php';
require_once __DIR__ . '/includes/Sms.php';
require_once __DIR__ . '/includes/Ui.php';
require_once __DIR__ . '/includes/VoucherService.php';
require_once __DIR__ . '/includes/I18n.php';
$auth = new Auth();
@ -115,34 +116,9 @@ if ($auth->isLoggedIn()) {
$autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
// Helper: create one voucher and save to DB
// Voucher-Erstellung liegt gebuendelt in includes/VoucherService.php.
function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos = []) {
$datum = date('Y-m-d');
$fullName = $datum . '_' . $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(__('error_voucher_invalid'));
}
$db->execute(
"INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
VALUES (?, ?, ?, ?, ?, ?, ?)",
[$site['id'], $userId, $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),
];
return VoucherService::create($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos);
}
// Single voucher

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>

View file

@ -470,6 +470,59 @@ return [
'print_valid_until' => 'Gültig bis',
'print_devices' => 'Geräte',
'credit_by' => 'Entwickelt von',
'kiosk_default_headline' => 'Kostenloses Gäste-WLAN',
'kiosk_default_subline' => 'Tippen Sie auf den Knopf Sie erhalten sofort einen persönlichen Zugangscode.',
'kiosk_button' => 'Zugangscode holen',
'kiosk_working' => 'Einen Moment …',
'kiosk_ready' => 'Ihr Zugangscode',
'kiosk_scan_code' => 'QR-Code scannen oder Code eintippen',
'kiosk_reset_in' => 'Der Bildschirm wird in {seconds} Sekunden zurückgesetzt.',
'kiosk_done' => 'Fertig',
'kiosk_phone_hint' => 'Oder mit dem Handy scannen und dort öffnen',
'kiosk_error_cooldown' => 'Gerade wurde ein Code ausgegeben. Bitte {seconds} Sekunden warten.',
'kiosk_error_limit' => 'Für heute sind keine Zugänge mehr verfügbar. Bitte wenden Sie sich an den Empfang.',
'kiosk_error_generic' => 'Der Zugang konnte gerade nicht erstellt werden. Bitte erneut versuchen.',
'kiosk_unknown' => 'Diese Seite ist nicht verfügbar',
'kiosk_unknown_hint' => 'Der Link ist ungültig oder wurde deaktiviert.',
'audit_action_voucher_kiosk' => 'Voucher am Display geholt',
'nav_kiosks' => 'Display-Seiten',
'kiosks_title' => 'Display-Seiten',
'kiosks_subtitle' => 'Öffentliche Seiten für Bildschirme und Tablets Gäste holen sich den Zugang selbst.',
'kiosks_add' => 'Display-Seite anlegen',
'kiosks_edit' => 'Display-Seite bearbeiten',
'kiosks_empty' => 'Noch keine Display-Seite angelegt.',
'kiosks_no_sites' => 'Legen Sie zuerst eine Site an, dann können Sie dafür eine Display-Seite erstellen.',
'kiosks_name' => 'Bezeichnung',
'kiosks_name_placeholder' => 'z.B. Empfang Erdgeschoss',
'kiosks_name_hint' => 'Erscheint im Audit-Log und als Voucher-Name.',
'kiosks_template' => 'Voucher-Profil',
'kiosks_no_template' => 'Standardwerte verwenden',
'kiosks_template_hint' => 'Bestimmt Laufzeit, Geräteanzahl und Bandbreite der ausgegebenen Codes.',
'kiosks_headline' => 'Überschrift auf dem Bildschirm',
'kiosks_subline' => 'Text darunter',
'kiosks_daily_limit' => 'Codes pro Tag',
'kiosks_daily_limit_hint' => '0 = unbegrenzt. Schützt vor Missbrauch, wenn der Link weitergegeben wird.',
'kiosks_cooldown' => 'Wartezeit (Sekunden)',
'kiosks_cooldown_hint' => 'Abstand zwischen zwei Codes an diesem Display.',
'kiosks_display' => 'Anzeigedauer (Sekunden)',
'kiosks_display_hint' => 'Danach springt der Bildschirm zurück auf den Startbildschirm.',
'kiosks_active' => 'Display-Seite aktiv',
'kiosks_link' => 'Öffentlicher Link',
'kiosks_open' => 'Öffnen',
'kiosks_qr' => 'QR-Code',
'kiosks_qr_hint' => 'Am Bildschirm aufhängen oder abfotografieren, um die Seite auf einem Tablet zu öffnen.',
'kiosks_today' => 'heute',
'kiosks_total' => 'insgesamt',
'kiosks_renew' => 'Link erneuern',
'kiosks_renew_confirm' => 'Neuen Link erzeugen? Der bisherige Link funktioniert danach nicht mehr.',
'kiosks_delete_confirm' => 'Display-Seite wirklich löschen?',
'kiosks_added' => 'Display-Seite angelegt.',
'kiosks_updated' => 'Display-Seite gespeichert.',
'kiosks_deleted' => 'Display-Seite gelöscht.',
'kiosks_token_renewed' => 'Neuer Link erzeugt der alte ist ab sofort ungültig.',
'audit_action_kiosk_created' => 'Display-Seite angelegt',
'audit_action_kiosk_updated' => 'Display-Seite geändert',
'audit_action_kiosk_deleted' => 'Display-Seite gelöscht',
'settings_tab_general' => 'Allgemein',
'settings_tab_defaults' => 'Voucher-Standards',
'settings_tab_cron' => 'Cron-Sync',

View file

@ -470,6 +470,59 @@ return [
'print_valid_until' => 'Valid until',
'print_devices' => 'devices',
'credit_by' => 'Developed by',
'kiosk_default_headline' => 'Free guest Wi-Fi',
'kiosk_default_subline' => 'Tap the button you will get your personal access code right away.',
'kiosk_button' => 'Get access code',
'kiosk_working' => 'One moment…',
'kiosk_ready' => 'Your access code',
'kiosk_scan_code' => 'Scan the QR code or type the code',
'kiosk_reset_in' => 'This screen resets in {seconds} seconds.',
'kiosk_done' => 'Done',
'kiosk_phone_hint' => 'Or scan with your phone and open it there',
'kiosk_error_cooldown' => 'A code was just issued. Please wait {seconds} seconds.',
'kiosk_error_limit' => 'No more access codes available today. Please ask at the reception desk.',
'kiosk_error_generic' => 'The access code could not be created. Please try again.',
'kiosk_unknown' => 'This page is not available',
'kiosk_unknown_hint' => 'The link is invalid or has been deactivated.',
'audit_action_voucher_kiosk' => 'Voucher taken at display',
'nav_kiosks' => 'Display pages',
'kiosks_title' => 'Display pages',
'kiosks_subtitle' => 'Public pages for screens and tablets guests get their access themselves.',
'kiosks_add' => 'Add display page',
'kiosks_edit' => 'Edit display page',
'kiosks_empty' => 'No display page created yet.',
'kiosks_no_sites' => 'Create a site first, then you can add a display page for it.',
'kiosks_name' => 'Label',
'kiosks_name_placeholder' => 'e.g. reception ground floor',
'kiosks_name_hint' => 'Appears in the audit log and as the voucher name.',
'kiosks_template' => 'Voucher profile',
'kiosks_no_template' => 'Use default values',
'kiosks_template_hint' => 'Defines duration, device count and bandwidth of the codes issued.',
'kiosks_headline' => 'Headline on screen',
'kiosks_subline' => 'Text below',
'kiosks_daily_limit' => 'Codes per day',
'kiosks_daily_limit_hint' => '0 = unlimited. Protects against misuse if the link gets shared.',
'kiosks_cooldown' => 'Cooldown (seconds)',
'kiosks_cooldown_hint' => 'Delay between two codes on this display.',
'kiosks_display' => 'Display duration (seconds)',
'kiosks_display_hint' => 'After that the screen returns to the start screen.',
'kiosks_active' => 'Display page active',
'kiosks_link' => 'Public link',
'kiosks_open' => 'Open',
'kiosks_qr' => 'QR code',
'kiosks_qr_hint' => 'Put it up next to the screen or photograph it to open the page on a tablet.',
'kiosks_today' => 'today',
'kiosks_total' => 'in total',
'kiosks_renew' => 'Renew link',
'kiosks_renew_confirm' => 'Generate a new link? The previous link will stop working.',
'kiosks_delete_confirm' => 'Really delete this display page?',
'kiosks_added' => 'Display page created.',
'kiosks_updated' => 'Display page saved.',
'kiosks_deleted' => 'Display page deleted.',
'kiosks_token_renewed' => 'New link generated the old one is no longer valid.',
'audit_action_kiosk_created' => 'Display page created',
'audit_action_kiosk_updated' => 'Display page updated',
'audit_action_kiosk_deleted' => 'Display page deleted',
'settings_tab_general' => 'General',
'settings_tab_defaults' => 'Voucher Defaults',
'settings_tab_cron' => 'Cron Sync',

View file

@ -9,3 +9,4 @@ parameters:
- includes/ApiKey.php
- includes/Ui.php
- includes/Upload.php
- includes/Kiosk.php

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/')
);
}
}

View file

@ -89,6 +89,16 @@ document.addEventListener('DOMContentLoaded', function () {
patch(os.path.join(target, 'admin', 'api_keys.php'), '$keys = $db->fetchAll(',
"if (($_GET['demo'] ?? '') === 'new') { $newKey = 'uvt_3f9a2c7d41e8b60592af18cc4d7e0b3a95f2617c'; }\n$keys = $db->fetchAll(")
# Kiosk: ausgegebenen Code zeigen, ohne echten Controller (?demo=code)
patch(os.path.join(target, 'kiosk.php'),
"$headline = trim((string)($kiosk['headline'] ?? ''))",
'''if (($_GET['demo'] ?? '') === 'code' && $kiosk) {
$voucher = ['code' => '4829-17364', 'site_name' => $kiosk['site_name'], 'max_uses' => 2,
'expire_min' => 480, 'expiry_date' => '24.09.2026', 'expiry_time' => '08:00'];
}
$headline = trim((string)($kiosk['headline'] ?? ''))''')
# Theme per Query-Parameter erzwingen (fuer Dark-Mode-Screenshots)
ui = os.path.join(target, 'includes', 'Ui.php')
patch(ui, 'var s=localStorage.getItem("theme");',

View file

@ -58,6 +58,22 @@ class Database {
public function fetchAll($sql, $params = []) {
$s = preg_replace('/\s+/', ' ', strtolower($sql));
if (str_contains($s, 'from kiosks')) {
return [
['id'=>1,'site_id'=>1,'template_id'=>1,'name'=>'Empfang Erdgeschoss','token'=>'a1b2c3d4e5f60718293a4b5c6d7e8f90',
'headline'=>'Willkommen im Hotel Seeblick','subline'=>'Tippen Sie auf den Knopf Ihr WLAN-Code erscheint sofort.',
'is_active'=>1,'daily_limit'=>150,'cooldown_seconds'=>15,'display_seconds'=>90,'last_used_at'=>null,
'site_name'=>'Hauptstandort Nord','site_active'=>1,'template_name'=>'Tagesgast',
'tpl_max_uses'=>2,'tpl_expire_minutes'=>480,'qos_rate_max_down'=>20000,'qos_rate_max_up'=>5000,'qos_usage_quota'=>0,
'total_vouchers'=>412,'today_vouchers'=>23,'created_at'=>'2026-06-01 10:00:00'],
['id'=>2,'site_id'=>2,'template_id'=>null,'name'=>'Tagungsraum West','token'=>'0f1e2d3c4b5a69788796a5b4c3d2e1f0',
'headline'=>'','subline'=>'','is_active'=>1,'daily_limit'=>0,'cooldown_seconds'=>30,'display_seconds'=>60,
'last_used_at'=>null,'site_name'=>'Campus West','site_active'=>1,'template_name'=>null,
'tpl_max_uses'=>0,'tpl_expire_minutes'=>0,'qos_rate_max_down'=>0,'qos_rate_max_up'=>0,'qos_usage_quota'=>0,
'total_vouchers'=>87,'today_vouchers'=>4,'created_at'=>'2026-07-12 09:30:00'],
];
}
if (str_contains($s, 'count(*) as count from sites')) return [['count'=>4]];
if (str_contains($s, 'count(*) as count from users')) return [['count'=>12]];
if (str_contains($s, 'count(*) as count from vouchers where date(created_at)=curdate()')) return [['count'=>18]];

View file

@ -35,6 +35,9 @@ SHOTS = [
('two-factor.png', 'admin/security.php', 1200, 900),
('updater.png', 'admin/update.php', 1200, 780),
('updater-available.png', 'admin/update.php?demo=available', 1200, 780),
('kiosk-display.png', 'kiosk.php?k=a1b2c3d4e5f60718293a4b5c6d7e8f90', 1200, 900),
('kiosk-code.png', 'kiosk.php?k=a1b2c3d4e5f60718293a4b5c6d7e8f90&demo=code', 1200, 900),
('kiosks-admin.png', 'admin/kiosks.php', 1200, 780),
('mobile-vouchers.png', 'admin/users.php', 430, 860),
('maintenance.png', 'updater/templates/maintenance.html', 1200, 700),
]

View file

@ -0,0 +1,29 @@
-- Öffentliche Display-Seiten ("Kiosk"): pro Site eine Seite mit festem Link,
-- über die Gäste sich mit einem Klick selbst einen Zugangscode holen.
CREATE TABLE IF NOT EXISTS `kiosks` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`site_id` INT NOT NULL,
`template_id` INT NULL,
`name` VARCHAR(255) NOT NULL,
`token` VARCHAR(64) NOT NULL,
`headline` VARCHAR(255) NULL,
`subline` VARCHAR(500) NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`daily_limit` INT NOT NULL DEFAULT 100,
`cooldown_seconds` INT NOT NULL DEFAULT 20,
`display_seconds` INT NOT NULL DEFAULT 90,
`last_used_at` TIMESTAMP NULL,
`created_by` INT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uniq_token` (`token`),
INDEX `idx_site` (`site_id`),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`template_id`) REFERENCES `voucher_templates`(`id`) ON DELETE SET NULL,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Herkunft eines Vouchers festhalten: über welchen Kiosk wurde er geholt?
ALTER TABLE `vouchers` ADD COLUMN `kiosk_id` INT NULL AFTER `user_id`;
ALTER TABLE `vouchers` ADD INDEX `idx_kiosk` (`kiosk_id`);