diff --git a/Readme.md b/Readme.md index a9725d3..8cc7143 100644 --- a/Readme.md +++ b/Readme.md @@ -10,7 +10,7 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** ![MySQL](https://img.shields.io/badge/MySQL-5.7%2B%20%2F%20MariaDB-4479A1?logo=mysql&logoColor=white) ![UniFi OS](https://img.shields.io/badge/UniFi%20OS-7.0%2B-0559C9?logo=ubiquiti&logoColor=white) ![License](https://img.shields.io/badge/Lizenz-MIT-green) -![Version](https://img.shields.io/badge/Version-2.6.0-blueviolet) +![Version](https://img.shields.io/badge/Version-2.7.0-blueviolet) ![Tests](https://img.shields.io/badge/Tests-PHPUnit%20%2B%20PHPStan-brightgreen) [![Repository](https://img.shields.io/badge/Code-git.loheide.cloud-4b3ec4)](https://git.loheide.cloud/friloo/Unifi-Voucher-Tool) @@ -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)** Einstellungen der Login-Seite +### Display-Seite für Gäste + +
+ Display-Seite im Ruhezustand + Ausgegebener Zugangscode auf dem Display +
+
Voucher-Ergebnis mit QR-Code Bulk-Voucher-Erstellung @@ -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* + +
+ Verwaltung der Display-Seiten +
+ +| 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) ?> + + + + + +
+
+ + +
+
+

+ + + +
+ +
+
+

+ +
+ +
+ + +
+
+
+
+
+
+ + + +
+ +
+
+ + +
+
+ + 0 ? ' / ' . (int)$k['daily_limit'] : '' ?> + +
+
+ + +
+
+ + + + +
+ + + + + +
+ + + +
+ + + +
+
+ +
+ + + + + + + + + + +
+ + + + 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) ?> + + + + + + + + +
+
+
+
+

+

+
+
+
+ +
+
+

+
+
+ + + +
+
+
+

+
+

+ ' . $display . '', __('kiosk_reset_in')) ?> +

+ +
+
+ +
+
+ + + + + + +

+

+ + +
+ + +
+ + +
+ +
+
+

+
+
+
+ + +
+ + + + + + 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`);