@@ -214,6 +228,64 @@ 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.
+
+### Jede Seite eigenständig gestalten
+
+Jede Display-Seite bringt ihr eigenes Erscheinungsbild mit – das Hotel am
+Empfang sieht anders aus als der Tagungsraum nebenan:
+
+| Einstellung | Wirkung |
+|---|---|
+| Logo | eigenes Logo auf der Karte (leer = Logo aus den Einstellungen) |
+| Hintergrundbild | formatfĂĽllend hinter der Karte, z. B. ein Foto des Hauses |
+| Abdunklung | 0–90 % dunkle Ebene über dem Bild, damit die Karte lesbar bleibt |
+| Akzentfarbe | färbt den Knopf dieser Seite (leer = Farbe aus dem Design-Tab) |
+| Karte | hell oder dunkel – auf Fotos wirkt die dunkle Karte meist ruhiger |
+
+Logo und Hintergrund lassen sich direkt hochladen (PNG, JPG, WEBP, GIF, SVG bis
+3 MB) oder als URL hinterlegen; beim Löschen einer Display-Seite verschwinden
+die hochgeladenen Dateien mit.
+
+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`
@@ -561,7 +633,8 @@ entsteht daraus ein reguläres Release mit derselben Mechanik.
> verwenden, der genau diese Pfade schĂĽtzt.
Voraussetzung ist ein registrierter **Forgejo-Actions-Runner**; ohne Runner
-bleiben die Workflows in der Warteschlange stehen.
+bleiben die Workflows in der Warteschlange stehen. Compose-Datei und Anleitung
+dafĂĽr liegen in [`tools/runner/`](tools/runner/README.md).
### Mitwirken
@@ -604,12 +677,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
---
+
+
+
+
+
+
+
+
+
+
diff --git a/lang/de.php b/lang/de.php
index 3a84eab..7b07a87 100644
--- a/lang/de.php
+++ b/lang/de.php
@@ -470,6 +470,73 @@ 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',
+ 'kiosks_appearance' => 'Erscheinungsbild',
+ 'kiosks_logo' => 'Logo',
+ 'kiosks_logo_hint' => 'Leer = allgemeines Logo aus den Einstellungen.',
+ 'kiosks_background' => 'Hintergrundbild',
+ 'kiosks_background_hint' => 'Formatfüllend hinter der Karte – z.B. ein Foto des Hauses.',
+ 'kiosks_overlay' => 'Abdunklung des Bildes (%)',
+ 'kiosks_overlay_hint' => '0–90 %. Höhere Werte machen die Karte auf hellen Bildern lesbarer.',
+ 'kiosks_accent' => 'Akzentfarbe',
+ 'kiosks_accent_default' => 'Standardfarbe verwenden',
+ 'kiosks_accent_hint' => 'Färbt den Knopf auf dieser Seite. Leer = Farbe aus dem Design-Tab.',
+ 'kiosks_card_style' => 'Karte',
+ 'kiosks_card_light' => 'Hell',
+ 'kiosks_card_dark' => 'Dunkel',
+ 'kiosks_card_hint' => 'Auf Fotos wirkt die dunkle Karte meist ruhiger.',
'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..b3bf315 100644
--- a/lang/en.php
+++ b/lang/en.php
@@ -470,6 +470,73 @@ 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',
+ 'kiosks_appearance' => 'Appearance',
+ 'kiosks_logo' => 'Logo',
+ 'kiosks_logo_hint' => 'Empty = general logo from the settings.',
+ 'kiosks_background' => 'Background image',
+ 'kiosks_background_hint' => 'Displayed full-bleed behind the card – e.g. a photo of the building.',
+ 'kiosks_overlay' => 'Image dimming (%)',
+ 'kiosks_overlay_hint' => '0–90%. Higher values keep the card readable on bright images.',
+ 'kiosks_accent' => 'Accent colour',
+ 'kiosks_accent_default' => 'Use default colour',
+ 'kiosks_accent_hint' => 'Colours the button on this page. Empty = colour from the Design tab.',
+ 'kiosks_card_style' => 'Card',
+ 'kiosks_card_light' => 'Light',
+ 'kiosks_card_dark' => 'Dark',
+ 'kiosks_card_hint' => 'On photos the dark card usually looks calmer.',
'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..51280a9
--- /dev/null
+++ b/tests/KioskTest.php
@@ -0,0 +1,205 @@
+ */
+ 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 testAppearanceFallsBackToSystemLogo(): void
+ {
+ $db = new FakeKioskDb(['logo_url' => 'uploads/global.png']);
+
+ $look = \Kiosk::appearance($db, $this->kiosk());
+
+ $this->assertSame('uploads/global.png', $look['logo']);
+ $this->assertSame('', $look['background']);
+ $this->assertSame('', $look['accent'], 'Ohne eigene Farbe bleibt das Design-System zuständig');
+ $this->assertSame('light', $look['card']);
+ }
+
+ public function testAppearanceUsesOwnValues(): void
+ {
+ $db = new FakeKioskDb(['logo_url' => 'uploads/global.png']);
+ $kiosk = $this->kiosk([
+ 'logo_url' => 'uploads/hotel.svg',
+ 'background_url' => 'uploads/lobby.jpg',
+ 'bg_overlay' => 60,
+ 'accent_color' => '#0F766E',
+ 'card_style' => 'dark',
+ ]);
+
+ $look = \Kiosk::appearance($db, $kiosk);
+
+ $this->assertSame('uploads/hotel.svg', $look['logo']);
+ $this->assertSame('uploads/lobby.jpg', $look['background']);
+ $this->assertSame(0.6, $look['overlay']);
+ $this->assertSame('#0f766e', $look['accent']);
+ $this->assertSame('dark', $look['card']);
+ }
+
+ public function testAppearanceRejectsUnsafeColour(): void
+ {
+ $db = new FakeKioskDb();
+
+ // Der Wert landet in einem style-Attribut – nur echte Hex-Farben durch.
+ $look = \Kiosk::appearance($db, $this->kiosk(['accent_color' => 'red;background:url(evil)']));
+
+ $this->assertSame('', $look['accent']);
+ }
+
+ public function testAppearanceClampsOverlay(): void
+ {
+ $db = new FakeKioskDb();
+
+ $this->assertSame(0.9, \Kiosk::appearance($db, $this->kiosk(['bg_overlay' => 250]))['overlay']);
+ $this->assertSame(0.0, \Kiosk::appearance($db, $this->kiosk(['bg_overlay' => -10]))['overlay']);
+ }
+
+ 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/README.md b/tools/README.md
index b3ece02..f3f431f 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -38,5 +38,11 @@ php -S 127.0.0.1:8123 -t /tmp/uvt-demo &
CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py
```
+## Actions-Runner (`tools/runner/`)
+
+Compose-Datei und Anleitung, um einen Forgejo-Actions-Runner einzurichten.
+Ohne Runner bleiben `ci.yml` und `release.yml` in der Warteschlange stehen.
+Details: [`tools/runner/README.md`](runner/README.md).
+
Die Skripte sind Hilfsmittel für die Entwicklung – im Betrieb werden sie nicht
benötigt und sind per `.htaccess` nicht über HTTP erreichbar.
diff --git a/tools/demo/build.py b/tools/demo/build.py
index 565c069..27eb2e0 100755
--- a/tools/demo/build.py
+++ b/tools/demo/build.py
@@ -89,6 +89,21 @@ 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'] ?? ''))''')
+
+ # Kiosk-Verwaltung: Formular fuer den Screenshot geoeffnet zeigen
+ patch(os.path.join(target, 'admin', 'kiosks.php'),
+ '
',
+ '
')
+
# 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..9f988a1 100644
--- a/tools/demo/overlay/includes/Database.php
+++ b/tools/demo/overlay/includes/Database.php
@@ -58,6 +58,26 @@ 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.',
+ 'logo_url'=>'/demo-assets/logo.svg','background_url'=>'/demo-assets/background.svg',
+ 'bg_overlay'=>55,'accent_color'=>'#0f766e','card_style'=>'dark',
+ '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'=>'','logo_url'=>null,'background_url'=>null,'bg_overlay'=>45,
+ 'accent_color'=>null,'card_style'=>'light',
+ '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/runner/README.md b/tools/runner/README.md
new file mode 100644
index 0000000..23a4b95
--- /dev/null
+++ b/tools/runner/README.md
@@ -0,0 +1,98 @@
+# Forgejo-Actions-Runner einrichten
+
+Ohne registrierten Runner bleiben die Workflows (`ci.yml`, `release.yml`) in der
+Warteschlange stehen – Forgejo nimmt sie an, es holt sie nur niemand ab.
+
+Die folgenden Schritte laufen **als root auf dem Server** und legen den Runner in
+einem eigenen Verzeichnis an. `/opt/forgejo` und die dortigen Container bleiben
+dabei unberĂĽhrt.
+
+## 1. Verzeichnis anlegen
+
+```bash
+mkdir -p /opt/forgejo-runner/data
+cd /opt/forgejo-runner
+# docker-compose.yml und config.example.yml aus tools/runner/ hierher kopieren
+```
+
+## 2. Registrierungs-Token holen
+
+Entweder in der Weboberfläche unter
+**Repository → Einstellungen → Actions → Runner → „Runner erstellen"**,
+oder ĂĽber die API (Token mit Repo-Rechten vorausgesetzt):
+
+```bash
+curl -s -X POST -H "Authorization: token $FORGEJO_TOKEN" \
+ https://git.loheide.cloud/api/v1/repos/friloo/Unifi-Voucher-Tool/actions/runners/registration-token
+```
+
+Soll der Runner für **alle** Repositories zuständig sein, stattdessen
+`…/api/v1/admin/runners/registration-token` verwenden.
+
+> Das Token ist kurzlebig und wird nur einmal beim Registrieren gebraucht.
+
+## 3. Registrieren
+
+```bash
+docker compose run --rm runner forgejo-runner register --no-interactive \
+ --instance https://git.loheide.cloud \
+ --token "" \
+ --name "$(hostname)-runner" \
+ --labels 'ubuntu-latest:docker://node:20-bookworm,ubuntu-22.04:docker://node:20-bookworm'
+```
+
+Die Label-Zuordnung ist wichtig: die Workflows verwenden `runs-on: ubuntu-latest`,
+und dieses Label zeigt hier auf das Image `node:20-bookworm`. Darin sind Node
+(fĂĽr `actions/checkout`), Git, curl und unzip bereits enthalten.
+
+## 4. Konfiguration erzeugen und anpassen
+
+```bash
+docker compose run --rm runner forgejo-runner generate-config > data/config.yml
+```
+
+AnschlieĂźend mindestens diese Werte setzen (Vorlage: `config.example.yml`):
+
+| Wert | Empfehlung | Grund |
+|---|---|---|
+| `runner.capacity` | `1` | der Server hat 4 GB RAM |
+| `runner.timeout` | `30m` | die Jobs hier dauern wenige Minuten |
+| `container.force_pull` | `false` | spart Bandbreite und Plattenplatz |
+| `cache.enabled` | `true` | beschleunigt `composer install` |
+
+## 5. Starten
+
+```bash
+docker compose up -d
+docker compose logs -f # sollte "Runner registered successfully" zeigen
+```
+
+Danach erscheint der Runner unter **Repository → Einstellungen → Actions → Runner**
+als „idle", und der nächste Push auf `main` baut das Release-ZIP.
+
+## Was der Runner darf – bitte bewusst entscheiden
+
+Der Runner bekommt den **Docker-Socket des Hosts** gereicht. Damit kann jeder
+Workflow, der auf diesem Runner läuft, Container mit Root-Rechten starten – das
+entspricht faktisch Root auf dem Server. FĂĽr ein privates Repository, in dem nur
+eigene Workflows laufen, ist das ĂĽblich und vertretbar. Sobald Fremde Pull
+Requests öffnen können, sollte der Runner stattdessen auf einer separaten
+Maschine oder in einer VM laufen.
+
+Alternative ohne Docker-Socket: Runner im **Host-Modus** (`ubuntu-latest:host`).
+Dann laufen die Jobs direkt auf dem Server, ohne Container – dafür müssen Node,
+Git und PHP dort installiert sein, und die Jobs sehen das Dateisystem des Hosts.
+FĂĽr `release.yml` wĂĽrde das reichen, fĂĽr `ci.yml` (PHP 7.4 **und** 8.2 ĂĽber
+`shivammathur/setup-php`) nicht.
+
+## Speicherbedarf im Blick behalten
+
+`node:20-bookworm` belegt rund 1 GB auf der Platte, die Job-Container brauchen
+kurzzeitig einige hundert MB RAM. Bei 4 GB Gesamtspeicher sollte neben Forgejo,
+Caddy und der Datenbank nur **ein** Job gleichzeitig laufen (`capacity: 1`).
+Aufräumen gelegentlich mit:
+
+```bash
+docker image prune -f
+docker builder prune -f
+```
diff --git a/tools/runner/config.example.yml b/tools/runner/config.example.yml
new file mode 100644
index 0000000..53cc686
--- /dev/null
+++ b/tools/runner/config.example.yml
@@ -0,0 +1,27 @@
+# Auszug aus `forgejo-runner generate-config` mit den Anpassungen, die auf
+# einem kleinen Server sinnvoll sind. Vollständige Vorlage erzeugen mit:
+# docker compose run --rm runner forgejo-runner generate-config > data/config.yml
+
+log:
+ level: info
+
+runner:
+ file: .runner
+ # Nur ein Job gleichzeitig – der Server hat 4 GB RAM.
+ capacity: 1
+ timeout: 30m
+ # Labels bestimmen, welches Image ein `runs-on:` bekommt.
+ labels:
+ - "ubuntu-latest:docker://node:20-bookworm"
+ - "ubuntu-22.04:docker://node:20-bookworm"
+
+cache:
+ enabled: true
+ dir: "/data/cache"
+
+container:
+ network: "bridge"
+ privileged: false
+ # Images nur ziehen, wenn sie fehlen – spart Bandbreite und Platz.
+ force_pull: false
+ valid_volumes: []
diff --git a/tools/runner/docker-compose.yml b/tools/runner/docker-compose.yml
new file mode 100644
index 0000000..7bcfecb
--- /dev/null
+++ b/tools/runner/docker-compose.yml
@@ -0,0 +1,30 @@
+# Forgejo-Actions-Runner fĂĽr dieses Repository.
+#
+# Bewusst eigenständig: der Runner läuft in einem eigenen Verzeichnis und
+# fasst weder /opt/forgejo noch die dortigen Container an.
+#
+# Einrichtung siehe README.md in diesem Ordner.
+
+services:
+ runner:
+ image: data.forgejo.org/forgejo/runner:13.2.0
+ container_name: forgejo-runner
+ restart: unless-stopped
+ # Der Runner startet die Job-Container ĂĽber den Docker-Socket des Hosts.
+ user: root
+ working_dir: /data
+ volumes:
+ - ./data:/data
+ - /var/run/docker.sock:/var/run/docker.sock
+ environment:
+ DOCKER_HOST: unix:///var/run/docker.sock
+ TZ: Europe/Berlin
+ command: forgejo-runner daemon --config /data/config.yml
+ # Der Server hat 4 GB – der Runner selbst soll davon wenig belegen.
+ # Die Job-Container laufen daneben, nicht innerhalb dieses Limits.
+ mem_limit: 512m
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
diff --git a/tools/screenshots.py b/tools/screenshots.py
index 95938df..a57a6ee 100755
--- a/tools/screenshots.py
+++ b/tools/screenshots.py
@@ -35,6 +35,11 @@ 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=0f1e2d3c4b5a69788796a5b4c3d2e1f0', 1200, 900),
+ ('kiosk-code.png', 'kiosk.php?k=a1b2c3d4e5f60718293a4b5c6d7e8f90&demo=code', 1200, 900),
+ ('kiosk-branded.png', 'kiosk.php?k=a1b2c3d4e5f60718293a4b5c6d7e8f90', 1200, 900),
+ ('kiosks-admin.png', 'admin/kiosks.php', 1200, 780),
+ ('kiosks-form.png', 'admin/kiosks.php?demo=form', 1200, 1150),
('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`);
diff --git a/updater/migrations/0006_kiosk_branding.sql b/updater/migrations/0006_kiosk_branding.sql
new file mode 100644
index 0000000..0ac66bc
--- /dev/null
+++ b/updater/migrations/0006_kiosk_branding.sql
@@ -0,0 +1,8 @@
+-- Display-Seiten individuell gestalten: eigenes Logo, Hintergrundbild,
+-- Akzentfarbe und helle oder dunkle Karte je Kiosk.
+
+ALTER TABLE `kiosks` ADD COLUMN `logo_url` VARCHAR(500) NULL AFTER `subline`;
+ALTER TABLE `kiosks` ADD COLUMN `background_url` VARCHAR(500) NULL AFTER `logo_url`;
+ALTER TABLE `kiosks` ADD COLUMN `bg_overlay` TINYINT NOT NULL DEFAULT 45 AFTER `background_url`;
+ALTER TABLE `kiosks` ADD COLUMN `accent_color` VARCHAR(7) NULL AFTER `bg_overlay`;
+ALTER TABLE `kiosks` ADD COLUMN `card_style` ENUM('light','dark') NOT NULL DEFAULT 'light' AFTER `accent_color`;