@@ -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*
+
+
+
+| 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,12 +654,13 @@ 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
---
-**Version 2.6.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
+**Version 2.7.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
Entwickelt von **[Loheide.eu](https://loheide.eu)**
diff --git a/VERSION b/VERSION
index e70b452..24ba9a3 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.6.0
+2.7.0
diff --git a/admin/audit_log.php b/admin/audit_log.php
index 52d2937..8edff87 100644
--- a/admin/audit_log.php
+++ b/admin/audit_log.php
@@ -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);
}
?>
diff --git a/admin/kiosks.php b/admin/kiosks.php
new file mode 100644
index 0000000..5597422
--- /dev/null
+++ b/admin/kiosks.php
@@ -0,0 +1,411 @@
+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 = '';
+?>
+
+
+
+
+
+
= __('kiosks_title') ?> – = htmlspecialchars($appTitle) ?>
+= Ui::script('assets/vendor/qrcodejs/qrcode.min.js', '../') ?>
+
+
+
+
+
= htmlspecialchars($error) ?>
+
= htmlspecialchars($success) ?>
+
+
+
+
+
+
+
= __('kiosks_empty') ?>
+
+ = __('kiosks_add') ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ = $k['template_name'] ? htmlspecialchars($k['template_name']) : __('kiosks_no_template') ?>
+
+
+
+ = (int)$k['today_vouchers'] ?>= (int)$k['daily_limit'] > 0 ? ' / ' . (int)$k['daily_limit'] : '' ?>
+ = __('kiosks_today') ?>
+
+
+
+ = (int)$k['total_vouchers'] ?> = __('kiosks_total') ?>
+
+
+
+
= __('kiosks_link') ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= __('kiosks_qr_hint') ?>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/global.css b/assets/global.css
index ce41678..4de34b5 100644
--- a/assets/global.css
+++ b/assets/global.css
@@ -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
========================================================================= */
diff --git a/database.sql b/database.sql
index 6dfa5d4..4d7fcc0 100644
--- a/database.sql
+++ b/database.sql
@@ -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` (
diff --git a/docs/screenshots/admin-dashboard-dark.png b/docs/screenshots/admin-dashboard-dark.png
index 24cb92e..e81cd9e 100644
Binary files a/docs/screenshots/admin-dashboard-dark.png and b/docs/screenshots/admin-dashboard-dark.png differ
diff --git a/docs/screenshots/admin-dashboard.png b/docs/screenshots/admin-dashboard.png
index 006348c..b7e1fe6 100644
Binary files a/docs/screenshots/admin-dashboard.png and b/docs/screenshots/admin-dashboard.png differ
diff --git a/docs/screenshots/api-keys.png b/docs/screenshots/api-keys.png
index c8664c7..0594942 100644
Binary files a/docs/screenshots/api-keys.png and b/docs/screenshots/api-keys.png differ
diff --git a/docs/screenshots/integrations.png b/docs/screenshots/integrations.png
index 57edb8d..62c4171 100644
Binary files a/docs/screenshots/integrations.png and b/docs/screenshots/integrations.png differ
diff --git a/docs/screenshots/kiosk-code.png b/docs/screenshots/kiosk-code.png
new file mode 100644
index 0000000..f961cfc
Binary files /dev/null and b/docs/screenshots/kiosk-code.png differ
diff --git a/docs/screenshots/kiosk-display.png b/docs/screenshots/kiosk-display.png
new file mode 100644
index 0000000..b786146
Binary files /dev/null and b/docs/screenshots/kiosk-display.png differ
diff --git a/docs/screenshots/kiosks-admin.png b/docs/screenshots/kiosks-admin.png
new file mode 100644
index 0000000..19d8595
Binary files /dev/null and b/docs/screenshots/kiosks-admin.png differ
diff --git a/docs/screenshots/settings-branding.png b/docs/screenshots/settings-branding.png
index b826df4..aa99af6 100644
Binary files a/docs/screenshots/settings-branding.png and b/docs/screenshots/settings-branding.png differ
diff --git a/docs/screenshots/settings-login.png b/docs/screenshots/settings-login.png
index cdc97f9..a69a747 100644
Binary files a/docs/screenshots/settings-login.png and b/docs/screenshots/settings-login.png differ
diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png
index aabd60f..fe7f67f 100644
Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ
diff --git a/docs/screenshots/two-factor.png b/docs/screenshots/two-factor.png
index 809a849..0507434 100644
Binary files a/docs/screenshots/two-factor.png and b/docs/screenshots/two-factor.png differ
diff --git a/docs/screenshots/vouchers.png b/docs/screenshots/vouchers.png
index 38d79d9..a81b909 100644
Binary files a/docs/screenshots/vouchers.png and b/docs/screenshots/vouchers.png differ
diff --git a/includes/Kiosk.php b/includes/Kiosk.php
new file mode 100644
index 0000000..847da0d
--- /dev/null
+++ b/includes/Kiosk.php
@@ -0,0 +1,140 @@
+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;
+ }
+}
diff --git a/includes/VoucherService.php b/includes/VoucherService.php
new file mode 100644
index 0000000..9ddcaa7
--- /dev/null
+++ b/includes/VoucherService.php
@@ -0,0 +1,63 @@
+ 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),
+ ];
+ }
+}
diff --git a/includes/admin_nav.php b/includes/admin_nav.php
index 5a5a5a1..167c9ff 100644
--- a/includes/admin_nav.php
+++ b/includes/admin_nav.php
@@ -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'],
],
diff --git a/index.php b/index.php
index 5b0b516..020c796 100644
--- a/index.php
+++ b/index.php
@@ -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
diff --git a/kiosk.php b/kiosk.php
new file mode 100644
index 0000000..665651d
--- /dev/null
+++ b/kiosk.php
@@ -0,0 +1,222 @@
+
+ *
+ * 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']) : '';
+?>
+
+
+
+
+
+
+
= htmlspecialchars($appTitle) ?>
+ = Ui::head($db) ?>
+
+ = Ui::script('assets/vendor/qrcodejs/qrcode.min.js') ?>
+
+
+
+
+
+
+
+
+
+
= __('kiosk_unknown') ?>
+
= __('kiosk_unknown_hint') ?>
+
+
+
+
+
+
+
= __('kiosk_ready') ?>
+
= htmlspecialchars($voucher['code']) ?>
+
+ = htmlspecialchars($voucher['site_name']) ?>
+ = (int)$voucher['expire_min'] ?> = __('minutes_short') ?>
+ = (int)$voucher['max_uses'] ?> = __('label_devices') ?>
+
+
+
+
= __('kiosk_scan_code') ?>
+
+
+ = str_replace('{seconds}', '' . $display . ' ', __('kiosk_reset_in')) ?>
+
+
= __('kiosk_done') ?>
+
+
+
+
+
+
+
+
+
+
+
+
= htmlspecialchars($headline) ?>
+
= htmlspecialchars($subline) ?>
+
+
+
= htmlspecialchars($error) ?>
+
+
+
+
+ 0 ? 'disabled' : '' ?>>
+
+ = __('kiosk_button') ?>
+
+
+
+
+
+
= __('kiosk_phone_hint') ?>
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lang/de.php b/lang/de.php
index 3a84eab..c83e6a5 100644
--- a/lang/de.php
+++ b/lang/de.php
@@ -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',
diff --git a/lang/en.php b/lang/en.php
index 7a0ee39..11f1eeb 100644
--- a/lang/en.php
+++ b/lang/en.php
@@ -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',
diff --git a/phpstan.neon b/phpstan.neon
index 95edbd1..2d305ab 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -9,3 +9,4 @@ parameters:
- includes/ApiKey.php
- includes/Ui.php
- includes/Upload.php
+ - includes/Kiosk.php
diff --git a/tests/KioskTest.php b/tests/KioskTest.php
new file mode 100644
index 0000000..bdc19f3
--- /dev/null
+++ b/tests/KioskTest.php
@@ -0,0 +1,155 @@
+ */
+ private array $settings;
+
+ /** @param array
$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 $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/')
+ );
+ }
+}
diff --git a/tools/demo/build.py b/tools/demo/build.py
index 565c069..b01afca 100755
--- a/tools/demo/build.py
+++ b/tools/demo/build.py
@@ -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");',
diff --git a/tools/demo/overlay/includes/Database.php b/tools/demo/overlay/includes/Database.php
index 6552806..9f2729e 100644
--- a/tools/demo/overlay/includes/Database.php
+++ b/tools/demo/overlay/includes/Database.php
@@ -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]];
diff --git a/tools/screenshots.py b/tools/screenshots.py
index 95938df..6f4338b 100755
--- a/tools/screenshots.py
+++ b/tools/screenshots.py
@@ -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),
]
diff --git a/updater/migrations/0005_kiosk.sql b/updater/migrations/0005_kiosk.sql
new file mode 100644
index 0000000..9f48aa8
--- /dev/null
+++ b/updater/migrations/0005_kiosk.sql
@@ -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`);