diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7e20944 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Zeilenenden nicht anfassen – einige Dateien liegen bewusst mit CRLF vor. +* -text + +# Dateien, die nicht ins Release-ZIP gehoeren. +# `git archive` (siehe .github/workflows/release.yml) wertet export-ignore aus. +/.gitattributes export-ignore +/.gitignore export-ignore +/.dockerignore export-ignore +/.github export-ignore +/docs export-ignore +/tests export-ignore +/tools export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon export-ignore +/composer.lock export-ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3fc3001 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,152 @@ +name: Release-Paket + +# Bei jedem Merge nach main entsteht ein installierbares ZIP und landet als +# Vorab-Release "latest-main" im Repository. Wird ein Tag v* gepusht, wird +# daraus ein regulaeres Release mit derselben Mechanik. +on: + push: + branches: [ main ] + tags: [ 'v*' ] + workflow_dispatch: + +jobs: + package: + name: ZIP bauen und veröffentlichen + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Version und Dateinamen bestimmen + id: meta + run: | + set -eu + VERSION="$(tr -d ' \r\n' < VERSION)" + SHORT_SHA="$(git rev-parse --short HEAD)" + BUILD_DATE="$(date -u +%Y-%m-%d)" + + if [ "${GITHUB_REF_TYPE:-branch}" = "tag" ]; then + TAG="${GITHUB_REF_NAME}" + NAME="unifi-voucher-tool-${TAG}" + TITLE="Version ${TAG}" + PRERELEASE="false" + else + TAG="latest-main" + NAME="unifi-voucher-tool-${VERSION}+${BUILD_DATE}.${SHORT_SHA}" + TITLE="Aktueller Stand von main – ${VERSION} (${SHORT_SHA})" + PRERELEASE="true" + fi + + { + echo "version=${VERSION}" + echo "short_sha=${SHORT_SHA}" + echo "build_date=${BUILD_DATE}" + echo "tag=${TAG}" + echo "name=${NAME}" + echo "title=${TITLE}" + echo "prerelease=${PRERELEASE}" + } >> "$GITHUB_OUTPUT" + + - name: ZIP erzeugen + run: | + set -eu + mkdir -p dist + # git archive wertet die export-ignore-Regeln aus .gitattributes aus, + # docs/, tests/, tools/ und CI-Dateien bleiben also draußen. + git archive --format=zip -9 \ + --prefix="unifi-voucher-tool/" \ + -o "dist/${{ steps.meta.outputs.name }}.zip" HEAD + cd dist + sha256sum "${{ steps.meta.outputs.name }}.zip" > "${{ steps.meta.outputs.name }}.zip.sha256" + ls -lh + + - name: Inhalt kurz prüfen + run: | + set -eu + # Ein paar Dateien muessen enthalten sein, sonst ist das Paket kaputt. + for required in \ + unifi-voucher-tool/index.php \ + unifi-voucher-tool/install.php \ + unifi-voucher-tool/database.sql \ + unifi-voucher-tool/assets/global.css \ + unifi-voucher-tool/assets/vendor/inter/inter.css \ + unifi-voucher-tool/includes/Ui.php + do + if ! unzip -l "dist/${{ steps.meta.outputs.name }}.zip" | grep -q "$required"; then + echo "Fehlt im Paket: $required" >&2 + exit 1 + fi + done + echo "Paket vollständig." + + - name: Als Build-Artefakt sichern + uses: actions/upload-artifact@v3 + continue-on-error: true # Artefakt-Speicher ist optional + with: + name: ${{ steps.meta.outputs.name }} + path: dist/* + retention-days: 30 + + - name: Release anlegen bzw. auffrischen + env: + # Forgejo stellt den Token automatisch bereit; FORGEJO_TOKEN + # (persönlicher Token) dient als Ausweichweg. + TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.FORGEJO_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + NAME: ${{ steps.meta.outputs.name }} + TITLE: ${{ steps.meta.outputs.title }} + PRERELEASE: ${{ steps.meta.outputs.prerelease }} + VERSION: ${{ steps.meta.outputs.version }} + SHORT_SHA: ${{ steps.meta.outputs.short_sha }} + BUILD_DATE: ${{ steps.meta.outputs.build_date }} + run: | + set -eu + if [ -z "${TOKEN:-}" ]; then + echo "Kein Token vorhanden – Release wird übersprungen." >&2 + exit 0 + fi + AUTH="Authorization: token ${TOKEN}" + + # Rollendes Vorab-Release durch ein frisches ersetzen, damit der + # Download-Link stabil bleibt und auf den aktuellen Stand zeigt. + if [ "$TAG" = "latest-main" ]; then + OLD_ID="$(curl -sf -H "$AUTH" "${API}/releases/tags/${TAG}" \ + | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)" + if [ -n "${OLD_ID:-}" ]; then + curl -sf -X DELETE -H "$AUTH" "${API}/releases/${OLD_ID}" || true + curl -sf -X DELETE -H "$AUTH" "${API}/tags/${TAG}" || true + fi + fi + + # Release-Text bewusst ohne Anfuehrungszeichen und Backslashes, + # damit er ohne jq direkt in den JSON-Body passt (\n bleibt literal). + BODY="Automatisch gebaut aus Commit ${SHORT_SHA}.\n\n" + BODY="${BODY}| | |\n|---|---|\n" + BODY="${BODY}| Version | ${VERSION} |\n" + BODY="${BODY}| Commit | ${SHORT_SHA} |\n" + BODY="${BODY}| Gebaut am | ${BUILD_DATE} |\n\n" + BODY="${BODY}**Neuinstallation:** ZIP entpacken, Dateien auf den Webserver legen, install.php aufrufen.\n\n" + BODY="${BODY}**Update einer bestehenden Installation:** config.php, uploads/ und updater/storage/ nicht ueberschreiben " + BODY="${BODY}- oder gleich den eingebauten Updater unter Administration, System-Update verwenden.\n\n" + BODY="${BODY}Pruefsumme: siehe beigelegte .sha256-Datei." + + RELEASE_ID="$(curl -sf -X POST -H "$AUTH" -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"${TITLE}\",\"body\":\"${BODY}\",\"draft\":false,\"prerelease\":${PRERELEASE}}" \ + "${API}/releases" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)" + + if [ -z "${RELEASE_ID:-}" ]; then + echo "Release konnte nicht angelegt werden." >&2 + exit 1 + fi + + for file in "dist/${NAME}.zip" "dist/${NAME}.zip.sha256"; do + curl -sf -X POST -H "$AUTH" \ + -F "attachment=@${file}" \ + "${API}/releases/${RELEASE_ID}/assets?name=$(basename "$file")" > /dev/null + echo "Angehängt: $(basename "$file")" + done + + echo "Release ${TITLE} steht bereit: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}" diff --git a/Readme.md b/Readme.md index e6f1973..46a8a84 100644 --- a/Readme.md +++ b/Readme.md @@ -10,8 +10,9 @@ 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) -![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg) +![Version](https://img.shields.io/badge/Version-2.8.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) @@ -27,6 +28,8 @@ 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 +- 🎨 **Jede Display-Seite eigenständig gestaltbar** – Logo, Hintergrundbild, Akzentfarbe, helle oder dunkle Karte - 📦 **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 @@ -82,6 +85,18 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** Einstellungen der Login-Seite +### Display-Seite für Gäste + +
+ Display-Seite im Ruhezustand + Display-Seite mit eigenem Bild und Farben +
+ +
+ Ausgegebener Zugangscode auf dem Display + Display-Seite einrichten +
+
Voucher-Ergebnis mit QR-Code Bulk-Voucher-Erstellung @@ -142,8 +157,8 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** ## 🚀 Installation ```bash -git clone https://github.com/friloo/unifi-voucher-tool.git -cd unifi-voucher-tool +git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git +cd Unifi-Voucher-Tool ``` 1. Dateien auf den Webserver hochladen @@ -213,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* + +
+ 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. + +### 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` @@ -514,10 +587,78 @@ Tests und statische Analyse: ```bash composer install -vendor/bin/phpunit -vendor/bin/phpstan analyse +vendor/bin/phpunit # 29 Tests (Crypto, TOTP, API-Keys, Upload, Ui) +vendor/bin/phpstan analyse # Level 5 ``` +Die Versionsnummer steht in der Datei **`VERSION`** im Projektstamm. Sie wird +im Admin-Bereich unten in der Seitenleiste angezeigt und benennt das +Release-Paket – für eine neue Version also dort (und im Badge oben) anheben. + +Die Pipeline (`.github/workflows/ci.yml`) führt zusätzlich einen +Syntax-Check über alle PHP-Dateien aus und prüft, ob `lang/de.php` und +`lang/en.php` dieselben Schlüssel enthalten und jeder im Code verwendete +Schlüssel existiert. Dieselben Schritte lassen sich lokal ausführen. + +--- + +## 📦 Repository & Mitwirken + +Der Quellcode liegt auf der eigenen Forgejo-Instanz – **nicht** auf GitHub: + +**** + +```bash +# HTTPS +git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git + +# SSH (Port 2222) +git clone ssh://git@git.loheide.cloud:2222/friloo/Unifi-Voucher-Tool.git +``` + +### Fertige Pakete + +Jeder Merge nach `main` erzeugt automatisch ein installierbares ZIP +(`.github/workflows/release.yml`) und hängt es an das rollende Vorab-Release +**`latest-main`**: + +**** + +Das Paket enthält nur die Laufzeit-Dateien – `docs/`, `tests/`, `tools/` und die +CI-Konfiguration bleiben draußen (rund 1,4 MB). Wird ein Tag `v*` gepusht, +entsteht daraus ein reguläres Release mit derselben Mechanik. + +> Beim **Update einer bestehenden Installation** `config.php`, `uploads/` und +> `updater/storage/` nicht überschreiben – oder gleich den eingebauten Updater +> verwenden, der genau diese Pfade schützt. + +Voraussetzung ist ein registrierter **Forgejo-Actions-Runner**; ohne Runner +bleiben die Workflows in der Warteschlange stehen. Compose-Datei und Anleitung +dafür liegen in [`tools/runner/`](tools/runner/README.md). + +### Mitwirken + +Fehlerberichte und Änderungsvorschläge laufen über die **Issues** und **Pull +Requests** dort. Für die Kommandozeile eignet sich [`tea`](https://gitea.com/gitea/tea), +die Gitea-/Forgejo-CLI: + +```bash +tea pr create # Pull Request öffnen +tea issues ls # offene Tickets ansehen +``` + +> Die Workflows unter `.github/workflows/` werden von Forgejo Actions +> mitgelesen; das Badge oben ist bewusst statisch, solange kein Runner +> registriert ist. `docker-publish.yml` veröffentlicht nach `ghcr.io` und +> stammt noch aus der GitHub-Zeit – für den Forgejo-Betrieb entweder auf die +> eigene Registry umstellen oder entfernen. + +Der **Auto-Updater** ist davon unabhängig: er zieht seine Pakete über +`update.loheide.eu` (Channels `stable` und `development`) und nicht direkt aus +dem Git-Hoster. + +--- + ## 🗺️ Roadmap - [x] Voucher-Templates (vordefinierte Laufzeiten) @@ -536,12 +677,13 @@ vendor/bin/phpstan analyse - [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.8.0** · Autor: **Friederich Loheide** · Lizenz: **MIT** Entwickelt von **[Loheide.eu](https://loheide.eu)** diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..834f262 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.8.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..50019af --- /dev/null +++ b/admin/kiosks.php @@ -0,0 +1,500 @@ +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, + 'bg_overlay' => max(0, min(90, (int)($_POST['bg_overlay'] ?? 45))), + 'accent_color' => self_accent($_POST['accent_color'] ?? ''), + 'card_style' => ($_POST['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light', + ]; +} + +/** Nur echte Hex-Farben durchlassen – der Wert landet in einem style-Attribut. */ +function self_accent($value): ?string +{ + $value = strtolower(trim((string)$value)); + + return preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : null; +} + +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')); + + $logo = Upload::resolveField('logo_url', '', 'image'); + $bg = Upload::resolveField('background_url', '', 'image'); + + $db->execute( + "INSERT INTO kiosks (site_id, template_id, name, token, headline, subline, + logo_url, background_url, bg_overlay, accent_color, card_style, + 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'], $logo, $bg, $in['bg_overlay'], + $in['accent_color'], $in['card_style'], $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')); + + $current = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [$id]) ?: []; + $logo = Upload::resolveField('logo_url', (string)($current['logo_url'] ?? ''), 'image'); + $bg = Upload::resolveField('background_url', (string)($current['background_url'] ?? ''), 'image'); + + $db->execute( + "UPDATE kiosks SET site_id=?, template_id=?, name=?, headline=?, subline=?, + logo_url=?, background_url=?, bg_overlay=?, accent_color=?, card_style=?, + daily_limit=?, cooldown_seconds=?, display_seconds=?, is_active=? + WHERE id=?", + [$in['site_id'], $in['template_id'], $in['name'], $in['headline'], $in['subline'], + $logo, $bg, $in['bg_overlay'], $in['accent_color'], $in['card_style'], + $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'])) { + $old = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [(int)$_GET['delete']]); + if ($old) { + Upload::delete((string)($old['logo_url'] ?? '')); + Upload::delete((string)($old['background_url'] ?? '')); + } + $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/admin/settings.php b/admin/settings.php index 1f191b4..d5763aa 100644 --- a/admin/settings.php +++ b/admin/settings.php @@ -42,33 +42,6 @@ if (isset($_POST['ajax_smtp_test'])) { $error = ''; $success = ''; -/** - * Liefert den neuen Wert eines Bildfeldes: Upload schlaegt URL, und ein - * gesetzter Entfernen-Schalter loescht die bisherige Datei. - */ -function resolveImageField(string $name, Database $db, string $kind): string -{ - $current = (string)$db->getSetting($name, ''); - - $uploaded = Upload::store($_FILES[$name . '_file'] ?? [], $kind); - if ($uploaded !== '') { - Upload::delete($current); - return $uploaded; - } - - if (!empty($_POST[$name . '_remove'])) { - Upload::delete($current); - return ''; - } - - $value = trim($_POST[$name] ?? ''); - if ($value !== $current && Upload::isLocal($current) && !Upload::isLocal($value)) { - Upload::delete($current); - } - - return $value; -} - // Einstellungen speichern if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { @@ -80,8 +53,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if ($formType === 'general') { $settings['app_title'] = trim($_POST['app_title'] ?? ''); - $settings['logo_url'] = resolveImageField('logo_url', $db, 'image'); - $settings['favicon_url'] = resolveImageField('favicon_url', $db, 'favicon'); + $settings['logo_url'] = Upload::resolveField('logo_url', (string)$db->getSetting('logo_url', ''), 'image'); + $settings['favicon_url'] = Upload::resolveField('favicon_url', (string)$db->getSetting('favicon_url', ''), 'favicon'); $settings['instruction_header'] = trim($_POST['instruction_header'] ?? ''); $settings['instruction_text'] = $_POST['instruction_text'] ?? ''; $settings['public_access'] = isset($_POST['public_access']) ? '1' : '0'; @@ -99,12 +72,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if ($formType === 'login') { $settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0'; $settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? ''); - $settings['login_logo_url'] = resolveImageField('login_logo_url', $db, 'image'); + $settings['login_logo_url'] = Upload::resolveField('login_logo_url', (string)$db->getSetting('login_logo_url', ''), 'image'); $settings['login_claim_title'] = trim($_POST['login_claim_title'] ?? ''); $settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? ''); $settings['login_features'] = trim($_POST['login_features'] ?? ''); $settings['login_footer'] = trim($_POST['login_footer'] ?? ''); - $settings['login_bg_image'] = resolveImageField('login_bg_image', $db, 'image'); + $settings['login_bg_image'] = Upload::resolveField('login_bg_image', (string)$db->getSetting('login_bg_image', ''), 'image'); $settings['login_bg_from'] = trim($_POST['login_bg_from'] ?? ''); $settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? ''); $overlay = (int)($_POST['login_bg_overlay'] ?? 40); @@ -263,41 +236,6 @@ $cs = [ 'last_cron_sync' => $db->getSetting('last_cron_sync', ''), ]; -/** - * Bildfeld: Vorschau, Upload, alternativ URL – plus Entfernen-Schalter. - */ -function imageField(string $name, string $label, ?string $value, string $hint = '', string $accept = 'image/*'): void -{ - $value = (string)$value; - $preview = Ui::mediaUrl($value, '../'); - ?> -
- -
-
- - - - - -
-
- - - - - -
-
-
-
- @@ -350,8 +288,8 @@ $adminBase = '';
- - + +

@@ -485,7 +423,7 @@ $adminBase = '';
- +

@@ -510,7 +448,7 @@ $adminBase = '';
- +
diff --git a/assets/global.css b/assets/global.css index ce41678..9923e3b 100644 --- a/assets/global.css +++ b/assets/global.css @@ -1610,6 +1610,174 @@ 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-logo-sm { max-height: 52px; margin-bottom: 20px; } + +/* Eigenes Hintergrundbild der Display-Seite: Bild formatfüllend, darüber + eine abdunkelnde Ebene, damit die Karte sich abhebt. */ +.kiosk-body.has-background { + --kiosk-overlay: 0.45; + position: relative; + background: var(--kiosk-bg) center / cover no-repeat fixed; +} +.kiosk-body.has-background::before { + content: ''; + position: fixed; + inset: 0; + background: rgba(8, 11, 18, var(--kiosk-overlay)); + pointer-events: none; +} +.kiosk-body.has-background > * { position: relative; z-index: 1; } +.kiosk-body.has-background .kiosk-card { box-shadow: 0 40px 80px -30px rgba(0,0,0,.6); } +.kiosk-body.has-background .kiosk-footer .app-credit, +.kiosk-body.has-background .kiosk-footer .app-credit a { color: rgba(255,255,255,.65); } + +/* Dunkle Karte – wirkt auf Fotos oft ruhiger als eine weiße Fläche. */ +.kiosk-dark .kiosk-card { + background: rgba(12,15,22,.86); + border-color: rgba(255,255,255,.12); + backdrop-filter: blur(6px); + color: #f4f6fb; +} +.kiosk-dark .kiosk-card h1, +.kiosk-dark .kiosk-code { color: #ffffff; } +.kiosk-dark .kiosk-subline, +.kiosk-dark .kiosk-qr-label { color: rgba(255,255,255,.76); } +.kiosk-dark .kiosk-countdown { color: rgba(255,255,255,.5); } +.kiosk-dark .kiosk-meta span { + background: rgba(255,255,255,.10); + border-color: rgba(255,255,255,.16); + color: rgba(255,255,255,.88); +} +.kiosk-dark .kiosk-phone { border-top-color: rgba(255,255,255,.14); color: rgba(255,255,255,.7); } +.kiosk-dark .kiosk-eyebrow { background: rgba(74,222,128,.16); border-color: rgba(74,222,128,.32); color: #86efac; } +.kiosk-dark .btn-secondary { + background: rgba(255,255,255,.10); + border-color: rgba(255,255,255,.18); + color: #f4f6fb; +} +.kiosk-dark .btn-secondary:hover { background: rgba(255,255,255,.18); color: #fff; } +.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/composer.json b/composer.json index 945e17b..72b3941 100644 --- a/composer.json +++ b/composer.json @@ -2,6 +2,17 @@ "name": "friloo/unifi-voucher-tool", "description": "Webbasiertes WLAN-Voucher-Management für UniFi OS", "license": "MIT", + "homepage": "https://git.loheide.cloud/friloo/Unifi-Voucher-Tool", + "authors": [ + { + "name": "Friederich Loheide", + "homepage": "https://loheide.eu" + } + ], + "support": { + "issues": "https://git.loheide.cloud/friloo/Unifi-Voucher-Tool/issues", + "source": "https://git.loheide.cloud/friloo/Unifi-Voucher-Tool" + }, "require": { "php": ">=7.4" }, diff --git a/database.sql b/database.sql index 6dfa5d4..52e6a0b 100644 --- a/database.sql +++ b/database.sql @@ -66,6 +66,34 @@ 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, + `logo_url` VARCHAR(500) NULL, + `background_url` VARCHAR(500) NULL, + `bg_overlay` TINYINT NOT NULL DEFAULT 45, + `accent_color` VARCHAR(7) NULL, + `card_style` ENUM('light','dark') NOT NULL DEFAULT 'light', + `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 +121,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 +138,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 b615914..bddf25e 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 bfb3012..d2d2073 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 98e3dd0..e625ae6 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 41fb539..060706d 100644 Binary files a/docs/screenshots/integrations.png and b/docs/screenshots/integrations.png differ diff --git a/docs/screenshots/kiosk-branded.png b/docs/screenshots/kiosk-branded.png new file mode 100644 index 0000000..06cca2a Binary files /dev/null and b/docs/screenshots/kiosk-branded.png differ diff --git a/docs/screenshots/kiosk-code.png b/docs/screenshots/kiosk-code.png new file mode 100644 index 0000000..4a6d7a8 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..06cca2a 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..7cbb1f4 Binary files /dev/null and b/docs/screenshots/kiosks-admin.png differ diff --git a/docs/screenshots/kiosks-form.png b/docs/screenshots/kiosks-form.png new file mode 100644 index 0000000..4654091 Binary files /dev/null and b/docs/screenshots/kiosks-form.png differ diff --git a/docs/screenshots/settings-branding.png b/docs/screenshots/settings-branding.png index cec6e04..c27f6d2 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 3c7d00f..2004b02 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 cb6f489..4a468cf 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 7d9704b..d743781 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 230dc97..e878ec8 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..ef25a56 --- /dev/null +++ b/includes/Kiosk.php @@ -0,0 +1,169 @@ +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)), + ], + ]; + } + + /** + * Gestaltung einer Display-Seite: eigene Werte, sonst die des Systems. + * + * @return array{logo:string,background:string,overlay:float,accent:string,card:string} + */ + public static function appearance($db, array $kiosk): array + { + $accent = strtolower(trim((string)($kiosk['accent_color'] ?? ''))); + if (!preg_match('/^#[0-9a-f]{6}$/', $accent)) { + $accent = ''; + } + + $overlay = (int)($kiosk['bg_overlay'] ?? 45); + $overlay = max(0, min(90, $overlay)); + + $logo = trim((string)($kiosk['logo_url'] ?? '')); + if ($logo === '' && $db) { + $logo = (string)$db->getSetting('logo_url', ''); + } + + return [ + 'logo' => $logo, + 'background' => trim((string)($kiosk['background_url'] ?? '')), + 'overlay' => (float)$overlay / 100, // immer float, auch bei 0 + 'accent' => $accent, + 'card' => ($kiosk['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light', + ]; + } + + /** Ö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/Ui.php b/includes/Ui.php index 40bff32..b7faa7a 100644 --- a/includes/Ui.php +++ b/includes/Ui.php @@ -116,6 +116,61 @@ class Ui . ''; } + /** + * Bildfeld mit Vorschau, Upload und URL-Eingabe. + * Wird von den Einstellungen und der Kiosk-Verwaltung genutzt. + */ + public static function imageField( + string $name, + string $label, + ?string $value, + string $hint = '', + string $accept = 'image/*', + string $base = '../' + ): string { + $value = (string)$value; + $preview = self::mediaUrl($value, $base); + $esc = static fn ($text) => htmlspecialchars((string)$text, ENT_QUOTES); + $t = static fn ($key, $fallback) => function_exists('__') ? __($key) : $fallback; + + $thumb = $preview !== '' + ? '' + : ''; + + $remove = $value !== '' + ? '' + : ''; + + return '
' + . '' + . '
' + . '
' . $thumb . '
' + . '
' + . '' + . '' + . $remove + . '
' + . ($hint !== '' ? '
' . $esc($hint) . '
' : '') + . '
'; + } + + /** + * Version aus der Datei VERSION im Projektstamm. + * Damit tragen Oberfläche und Release-Paket dieselbe Nummer. + */ + public static function version(): string + { + static $version = null; + if ($version === null) { + $file = self::root() . '/VERSION'; + $version = is_file($file) ? trim((string)file_get_contents($file)) : ''; + } + + return $version; + } + /** Entwicklerhinweis – bewusst an einer Stelle gepflegt. */ public const CREDIT_NAME = 'Loheide.eu'; public const CREDIT_URL = 'https://loheide.eu'; @@ -123,11 +178,16 @@ class Ui /** * Dezenter Hinweis auf den Entwickler, wie er im Seitenfuß erscheint. */ - public static function credit(): string + public static function credit(bool $withVersion = false): string { $label = function_exists('__') ? __('credit_by') : 'Entwickelt von'; - return '

' . htmlspecialchars($label) . ' ' + $prefix = ''; + if ($withVersion && self::version() !== '') { + $prefix = 'v' . htmlspecialchars(self::version()) . ' · '; + } + + return '

' . $prefix . htmlspecialchars($label) . ' ' . '' . self::CREDIT_NAME . '

'; } diff --git a/includes/Upload.php b/includes/Upload.php index 39713bf..2a97b90 100644 --- a/includes/Upload.php +++ b/includes/Upload.php @@ -120,6 +120,39 @@ class Upload return 'uploads/' . $name; } + /** + * Neuer Wert eines Bildfeldes aus dem Formular. + * + * Reihenfolge: hochgeladene Datei schlaegt alles, danach der + * Entfernen-Schalter, sonst gilt das URL-Feld. Wird eine zuvor + * hochgeladene Datei ersetzt oder entfernt, verschwindet sie auch + * von der Platte. + * + * @param string $name Feldname (erwartet , _file, _remove) + * @param string $current bisher gespeicherter Wert + * @param string $kind 'image' oder 'favicon' + */ + public static function resolveField(string $name, string $current, string $kind = 'image'): string + { + $uploaded = self::store($_FILES[$name . '_file'] ?? [], $kind); + if ($uploaded !== '') { + self::delete($current); + return $uploaded; + } + + if (!empty($_POST[$name . '_remove'])) { + self::delete($current); + return ''; + } + + $value = trim((string)($_POST[$name] ?? '')); + if ($value !== $current && self::isLocal($current) && !self::isLocal($value)) { + self::delete($current); + } + + return $value; + } + /** * Entfernt aktive Inhalte aus SVG-Dateien (Skripte, Event-Handler, * externe Verweise). Lieber eine Grafik verlieren als eine XSS-Luecke. 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 6d4b671..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'], ], @@ -89,7 +90,7 @@ foreach ($navGroups as $items) {
- +
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..9118ce4 --- /dev/null +++ b/kiosk.php @@ -0,0 +1,245 @@ + + * + * 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'); +$display = max(10, (int)($kiosk['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS)); +$selfUrl = $kiosk ? Kiosk::publicUrl($kiosk['token']) : ''; + +// Gestaltung dieser Display-Seite (eigene Werte, sonst die des Systems) +$look = $kiosk ? Kiosk::appearance($db, $kiosk) : ['logo'=>'','background'=>'','overlay'=>0.45,'accent'=>'','card'=>'light']; +$logoUrl = $look['logo']; +$bodyClass = 'kiosk-body'; +$bodyStyle = ''; +if ($look['background'] !== '') { + $bodyClass .= ' has-background'; + $bodyStyle .= "--kiosk-bg:url('" . htmlspecialchars(Ui::mediaUrl($look['background']), ENT_QUOTES) . "');" + . '--kiosk-overlay:' . $look['overlay'] . ';'; +} +if ($look['card'] === 'dark') { + $bodyClass .= ' kiosk-dark'; +} +if ($look['accent'] !== '') { + // Nur die Akzentfarbe dieser Seite überschreiben – der Rest bleibt Design-System. + $bodyStyle .= '--accent:' . $look['accent'] . ';' + . '--accent-hover:color-mix(in srgb, ' . $look['accent'] . ' 84%, #000);' + . '--accent-soft:color-mix(in srgb, ' . $look['accent'] . ' 14%, #fff);' + . '--accent-border:color-mix(in srgb, ' . $look['accent'] . ' 32%, #fff);'; +} +?> + + + + + + + <?= htmlspecialchars($appTitle) ?> + + + + + +> + + +
+
+
+
+

+

+
+
+
+ +
+
+ + + +

+
+
+ + + +
+
+
+

+
+

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

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

+

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

+
+
+
+ + + + + + + + + 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'), + '