Compare commits

...
Sign in to create a new pull request.

14 commits

Author SHA1 Message Date
3a09e35097 Merge pull request 'Display-Seiten individuell gestalten' (#8) from feature/kiosk-branding into main
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
Release-Paket / ZIP bauen und veröffentlichen (push) Waiting to run
2026-09-23 16:59:11 +00:00
4159b92268 Display-Seiten individuell gestalten
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
Jede Display-Seite bringt jetzt ihr eigenes Erscheinungsbild mit – der
Empfang sieht anders aus als der Tagungsraum nebenan:

- eigenes Logo (leer = Logo aus den Einstellungen)
- formatfüllendes Hintergrundbild mit einstellbarer Abdunklung (0–90 %),
  damit die Karte auf hellen Fotos lesbar bleibt
- eigene Akzentfarbe für den Knopf (leer = Farbe aus dem Design-Tab)
- Karte wahlweise hell oder dunkel; auf Fotos wirkt dunkel meist ruhiger

Logo und Hintergrund lassen sich hochladen oder als URL hinterlegen; beim
Löschen einer Display-Seite verschwinden die hochgeladenen Dateien mit.

Nebenbei aufgeräumt: das Bildfeld (Vorschau + Upload + URL + Entfernen)
lag als Funktion in admin/settings.php und wird jetzt von beiden Seiten
genutzt – Ui::imageField() für die Darstellung, Upload::resolveField()
für die Auswertung.

Sicherheit: die Akzentfarbe landet in einem style-Attribut, deshalb wird
sie sowohl beim Speichern als auch beim Ausgeben auf eine echte Hex-Farbe
geprüft; ein Test hält das fest.

Migration 0006, database.sql nachgezogen, 4 neue Tests (42 gesamt),
Screenshots ergänzt, Version 2.8.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:58:54 +00:00
943e427150 Merge pull request 'Display-Seiten: Gäste holen sich den Zugang selbst' (#7) from feature/kiosk into main
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
Release-Paket / ZIP bauen und veröffentlichen (push) Waiting to run
2026-09-23 16:52:17 +00:00
ee3b85add4 Version 2.7.0
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:51:58 +00:00
61810eb050 Display-Seiten: Gäste holen sich den Zugang selbst
Für Empfang, Lobby oder Tagungsraum lässt sich je Site eine öffentliche
Seite anlegen (kiosk.php), die auf einem Bildschirm oder Tablet läuft:
ein großer Knopf, ein Klick, ein Zugangscode mit QR-Code. Nach einer
einstellbaren Anzeigedauer springt der Bildschirm zurück, damit der
nächste Gast nicht den Code seines Vorgängers sieht.

Verwaltung unter Administration → Display-Seiten:
- Site und optionales Voucher-Profil (bestimmt Laufzeit, Geräte, QoS)
- eigene Überschrift und Text für den Bildschirm
- Codes pro Tag, Wartezeit zwischen zwei Codes, Anzeigedauer
- geheimer Link zum Kopieren, als QR-Code anzeigbar und jederzeit
  erneuerbar (der alte Link gilt dann sofort nicht mehr)

Absicherung: der Link ist der Zugang, deshalb Tageslimit und Wartezeit
je Display, CSRF-Token am Formular, `noindex` im Kopf und ein Eintrag im
Audit-Log für jeden ausgegebenen Code. Webhooks werden für Kiosk-Codes
bewusst nicht ausgelöst – ein Empfangsdisplay würde den Kanal fluten.

Technik:
- neue Tabelle `kiosks`, `vouchers.kiosk_id` hält die Herkunft fest
  (Migration 0005, database.sql nachgezogen)
- includes/Kiosk.php kapselt Token, Limits und Profil-Auflösung
- includes/VoucherService.php bündelt die Voucher-Erstellung, die vorher
  in index.php lag und für den Kiosk ein zweites Mal nötig gewesen wäre
- Startbildschirm zeigt zusätzlich einen QR auf sich selbst, damit Gäste
  die Seite am eigenen Handy öffnen können

Tests: 9 neue Fälle für Token-Prüfung, Wartezeit, Tageslimit und
Profil-Auflösung (38 Tests gesamt), PHPStan deckt Kiosk.php mit ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:51:50 +00:00
5d72febadc Merge pull request 'Einrichtung für einen Forgejo-Actions-Runner' (#6) from ci/runner-setup into main
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
Release-Paket / ZIP bauen und veröffentlichen (push) Waiting to run
2026-09-23 15:06:36 +00:00
83f4223d89 Einrichtung für einen Forgejo-Actions-Runner beilegen
Some checks are pending
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
tools/runner/ enthält eine Compose-Datei, eine Beispiel-Konfiguration und
eine Schritt-für-Schritt-Anleitung, um den Runner auf dem Server zu
registrieren. Der Runner läuft in einem eigenen Verzeichnis und fasst
/opt/forgejo nicht an.

Abgestimmt auf die vorhandenen Workflows und den Server:
- Label-Zuordnung ubuntu-latest -> node:20-bookworm, damit runs-on in
  ci.yml und release.yml greift
- capacity 1 und Speicherlimit, weil der Server nur 4 GB hat
- Hinweis darauf, was das Reichen des Docker-Sockets bedeutet, und die
  Host-Modus-Alternative samt ihrer Grenzen (setup-php braucht Container)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:06:19 +00:00
ad197ecf90 Merge pull request 'Release-Workflow: ZIP bei jedem Merge nach main' (#5) from ci/release-zip into main
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
Release-Paket / ZIP bauen und veröffentlichen (push) Waiting to run
2026-09-23 14:50:36 +00:00
8facc71455 Release-Workflow: ZIP bei jedem Merge nach main
Some checks are pending
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
.github/workflows/release.yml baut bei jedem Push auf main (also auch
nach jedem gemergten Pull Request) ein installierbares Paket und hängt es
an das rollende Vorab-Release "latest-main". Der Download-Link bleibt
damit stabil und zeigt immer auf den aktuellen Stand. Ein Tag v* erzeugt
mit derselben Mechanik ein reguläres Release.

Details:
- das ZIP entsteht per `git archive`, die Auswahl steuert .gitattributes
  (export-ignore) – docs/, tests/, tools/ und CI bleiben draußen, das
  Paket ist rund 1,4 MB groß
- eine Prüfschritt kontrolliert, dass Kerndateien wirklich enthalten sind,
  dazu gibt es eine .sha256-Datei
- zusätzlich als Build-Artefakt abgelegt (optional, bricht nicht ab, wenn
  der Artefakt-Speicher fehlt)
- Release-API wird über den automatisch bereitgestellten Token
  angesprochen, FORGEJO_TOKEN dient als Ausweichweg; ohne Token wird der
  Schritt übersprungen statt fehlzuschlagen

Neu ist die Datei VERSION als einzige Quelle der Versionsnummer: sie
benennt das Paket und erscheint über Ui::version() unten in der
Admin-Seitenleiste ("v2.6.0 · Entwickelt von Loheide.eu").

Geprüft: Paket lokal gebaut, entpackt und die Anwendung daraus gestartet –
alle Seiten antworten mit 200, keine fehlenden Dateien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:50:10 +00:00
b7f13d8fac Merge pull request 'README auf Forgejo umstellen' (#4) from docs/forgejo into main
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
2026-09-23 13:58:31 +00:00
5f7c503dff README: Projekt liegt auf Forgejo, nicht mehr auf GitHub
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
- CI-Badge von github.com entfernt (zeigte ins Leere); stattdessen ein
  statisches Test-Badge und ein Link zum Repository
- Clone-Befehl in der Installation auf git.loheide.cloud umgestellt
- neuer Abschnitt "Repository & Mitwirken" mit HTTPS-/SSH-Adressen,
  Hinweis auf Issues und Pull Requests sowie der tea-CLI
- vermerkt, dass .github/workflows von Forgejo Actions mitgelesen wird und
  docker-publish.yml (ghcr.io) noch aus der GitHub-Zeit stammt
- klargestellt, dass der Auto-Updater unabhängig davon über
  update.loheide.eu läuft
- Entwicklungs-Abschnitt nennt jetzt auch die CI-Prüfungen der Sprachdateien
- composer.json: homepage, authors und support-Links ergänzt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:58:16 +00:00
068f6c08f9 Merge pull request 'Entwicklerhinweis "Entwickelt von Loheide.eu"' (#3) from feature/credit into main
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
2026-09-23 11:22:15 +00:00
7fa423a74c Entwicklerhinweis "Entwickelt von Loheide.eu" ergänzen
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
Zentral in Ui::credit() gepflegt und dezent im Seitenfuß eingebunden:

- Admin-Bereich: unter dem Benutzerbereich in der Sidebar
- öffentliche Voucher-Seite: unter der Karte
- Anmeldung, Installer und Updater: am Ende der jeweiligen Karte
- Wartungsseite: direkt im Markup, da sie bewusst eigenständig bleibt
- README: im Kopf und in der Fußzeile

Der Hinweis ist übersetzt ("Entwickelt von" / "Developed by") und stört
das Branding der Betreiber nicht – die konfigurierbare Fußzeile der
Login-Seite bleibt unverändert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:21:53 +00:00
7dc60c5bb0 Merge pull request 'Self-Hosting, Branding, i18n, Barrierefreiheit und Werkzeuge' (#2) from feature/self-hosted-assets-branding into main
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
2026-09-23 06:54:39 +00:00
54 changed files with 2234 additions and 108 deletions

15
.gitattributes vendored Normal file
View file

@ -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

152
.github/workflows/release.yml vendored Normal file
View file

@ -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}"

160
Readme.md
View file

@ -4,12 +4,15 @@
**Webbasiertes WLAN-Voucher-Management für UniFi OS** mit Multi-Site-Support, Benutzerverwaltung, Microsoft-365-Login und integriertem Auto-Updater. **Webbasiertes WLAN-Voucher-Management für UniFi OS** mit Multi-Site-Support, Benutzerverwaltung, Microsoft-365-Login und integriertem Auto-Updater.
Entwickelt von **[Loheide.eu](https://loheide.eu)**
![PHP](https://img.shields.io/badge/PHP-7.4%2B-777BB4?logo=php&logoColor=white) ![PHP](https://img.shields.io/badge/PHP-7.4%2B-777BB4?logo=php&logoColor=white)
![MySQL](https://img.shields.io/badge/MySQL-5.7%2B%20%2F%20MariaDB-4479A1?logo=mysql&logoColor=white) ![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) ![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) ![License](https://img.shields.io/badge/Lizenz-MIT-green)
![Version](https://img.shields.io/badge/Version-2.6.0-blueviolet) ![Version](https://img.shields.io/badge/Version-2.8.0-blueviolet)
![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg) ![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)
</div> </div>
@ -25,6 +28,8 @@
## ✨ Features ## ✨ Features
- 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand - 🎟️ **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 - 📦 **Bulk-Erstellung** bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout
- 🧩 **Voucher-Profile/Templates** vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl - 🧩 **Voucher-Profile/Templates** vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl
- 🏢 **Multi-Site-Support** beliebig viele UniFi-Standorte zentral verwalten - 🏢 **Multi-Site-Support** beliebig viele UniFi-Standorte zentral verwalten
@ -80,6 +85,18 @@
<img src="docs/screenshots/settings-login.png" alt="Einstellungen der Login-Seite" width="48%"> <img src="docs/screenshots/settings-login.png" alt="Einstellungen der Login-Seite" width="48%">
</div> </div>
### Display-Seite für Gäste
<div align="center">
<img src="docs/screenshots/kiosk-display.png" alt="Display-Seite im Ruhezustand" width="48%">
<img src="docs/screenshots/kiosk-branded.png" alt="Display-Seite mit eigenem Bild und Farben" width="48%">
</div>
<div align="center">
<img src="docs/screenshots/kiosk-code.png" alt="Ausgegebener Zugangscode auf dem Display" width="48%">
<img src="docs/screenshots/kiosks-form.png" alt="Display-Seite einrichten" width="48%">
</div>
<div align="center"> <div align="center">
<img src="docs/screenshots/voucher-result.png" alt="Voucher-Ergebnis mit QR-Code" width="48%"> <img src="docs/screenshots/voucher-result.png" alt="Voucher-Ergebnis mit QR-Code" width="48%">
<img src="docs/screenshots/bulk-vouchers.png" alt="Bulk-Voucher-Erstellung" width="48%"> <img src="docs/screenshots/bulk-vouchers.png" alt="Bulk-Voucher-Erstellung" width="48%">
@ -140,8 +157,8 @@
## 🚀 Installation ## 🚀 Installation
```bash ```bash
git clone https://github.com/friloo/unifi-voucher-tool.git git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git
cd unifi-voucher-tool cd Unifi-Voucher-Tool
``` ```
1. Dateien auf den Webserver hochladen 1. Dateien auf den Webserver hochladen
@ -211,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*
<div align="center">
<img src="docs/screenshots/kiosks-admin.png" alt="Verwaltung der Display-Seiten" width="80%">
</div>
| Einstellung | Wirkung |
|---|---|
| Site | für welchen Standort die Codes erzeugt werden |
| Voucher-Profil | Laufzeit, Geräteanzahl und Bandbreite der Codes (leer = Standardwerte) |
| Überschrift / Text | was auf dem Bildschirm steht |
| Codes pro Tag | Obergrenze je Kalendertag (0 = unbegrenzt) |
| Wartezeit | Abstand zwischen zwei Codes an diesem Display |
| Anzeigedauer | danach springt der Bildschirm automatisch zurück |
Jede Seite hat einen **eigenen, geheimen Link** (`kiosk.php?k=…`). Er lässt sich
kopieren, als QR-Code anzeigen (praktisch, um ihn am Tablet zu öffnen) und
jederzeit erneuern der alte Link ist dann sofort ungültig. Den Link nicht
öffentlich verbreiten: wer ihn hat, kann im Rahmen der Limits Codes ziehen.
Auf dem Startbildschirm steht zusätzlich ein QR-Code, der auf dieselbe Seite
zeigt. Gäste können sie damit **am eigenen Handy** öffnen praktisch bei
Bildschirmen ohne Touch.
### 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 | 090 % 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 ## ⚙️ Konfiguration
### `config.php` ### `config.php`
@ -512,10 +587,78 @@ Tests und statische Analyse:
```bash ```bash
composer install composer install
vendor/bin/phpunit vendor/bin/phpunit # 29 Tests (Crypto, TOTP, API-Keys, Upload, Ui)
vendor/bin/phpstan analyse 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:
**<https://git.loheide.cloud/friloo/Unifi-Voucher-Tool>**
```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`**:
**<https://git.loheide.cloud/friloo/Unifi-Voucher-Tool/releases>**
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 ## 🗺️ Roadmap
- [x] Voucher-Templates (vordefinierte Laufzeiten) - [x] Voucher-Templates (vordefinierte Laufzeiten)
@ -534,11 +677,14 @@ vendor/bin/phpstan analyse
- [x] Branding über die Oberfläche (Farben, Logo, Login-Seite) - [x] Branding über die Oberfläche (Farben, Logo, Login-Seite)
- [x] Assets lokal ausliefern (keine Drittanbieter-CDNs) - [x] Assets lokal ausliefern (keine Drittanbieter-CDNs)
- [x] Vollständige englische Übersetzung des Admin-Bereichs - [x] Vollständige englische Übersetzung des Admin-Bereichs
- [x] Display-Seiten: Selbstbedienung für Gäste am Bildschirm
--- ---
<div align="center"> <div align="center">
**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)**
</div> </div>

1
VERSION Normal file
View file

@ -0,0 +1 @@
2.8.0

View file

@ -50,7 +50,8 @@ $actionLabels = [];
foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created', foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created',
'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted', 'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted',
'settings_saved', 'password_reset', 'template_created', 'template_updated', '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); $actionLabels[$action] = __('audit_action_' . $action);
} }
?> ?>

500
admin/kiosks.php Normal file
View file

@ -0,0 +1,500 @@
<?php
/**
* Verwaltung der öffentlichen Display-Seiten ("Kiosk").
*
* Jeder Kiosk gehört zu einer Site, hat einen geheimen Link und gibt über
* kiosk.php Zugangscodes aus ohne Anmeldung, aber mit Tageslimit und
* Wartezeit zwischen zwei Codes.
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Ui.php';
require_once __DIR__ . '/../includes/Kiosk.php';
require_once __DIR__ . '/../includes/Upload.php';
$auth = new Auth();
$auth->requireAdmin();
I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$error = '';
$success = '';
/** Formularwerte einsammeln für Anlegen und Bearbeiten identisch. */
function kioskInput(): array
{
return [
'site_id' => (int)($_POST['site_id'] ?? 0),
'template_id' => (int)($_POST['template_id'] ?? 0) ?: null,
'name' => trim((string)($_POST['name'] ?? '')),
'headline' => trim((string)($_POST['headline'] ?? '')),
'subline' => trim((string)($_POST['subline'] ?? '')),
'daily_limit' => max(0, (int)($_POST['daily_limit'] ?? Kiosk::DEFAULT_DAILY_LIMIT)),
'cooldown_seconds' => max(0, min(3600, (int)($_POST['cooldown_seconds'] ?? Kiosk::DEFAULT_COOLDOWN))),
'display_seconds' => max(10, min(600, (int)($_POST['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS))),
'is_active' => isset($_POST['is_active']) ? 1 : 0,
'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 = '';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('kiosks_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js', '../') ?>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header">
<div>
<h1 class="page-title"><?= __('kiosks_title') ?></h1>
<p class="page-subtitle"><?= __('kiosks_subtitle') ?></p>
</div>
<button onclick="openAddModal()" class="btn btn-primary">
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('kiosks_add') ?>
</button>
</div>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
<?php if (empty($sites)): ?>
<div class="empty-card">
<div class="empty-icon"><i class="fas fa-location-dot" aria-hidden="true"></i></div>
<p><?= __('kiosks_no_sites') ?></p>
<a href="sites.php" class="btn btn-primary" style="margin-top:16px;">
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('sites_add') ?>
</a>
</div>
<?php elseif (empty($kiosks)): ?>
<div class="empty-card">
<div class="empty-icon"><i class="fas fa-display" aria-hidden="true"></i></div>
<p><?= __('kiosks_empty') ?></p>
<button onclick="openAddModal()" class="btn btn-primary" style="margin-top:16px;">
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('kiosks_add') ?>
</button>
</div>
<?php else: ?>
<div class="sites-grid">
<?php foreach ($kiosks as $k): ?>
<?php $url = Kiosk::publicUrl($k['token']); ?>
<div class="site-card">
<div class="site-card-header">
<div>
<div class="site-name"><?= htmlspecialchars($k['name']) ?></div>
<div class="site-id-label"><?= htmlspecialchars($k['site_name']) ?></div>
</div>
<span class="badge <?= $k['is_active'] ? 'badge-success' : 'badge-neutral' ?>">
<?= $k['is_active'] ? __('status_active') : __('status_inactive') ?>
</span>
</div>
<div class="site-info">
<div class="site-info-item">
<i class="fas fa-layer-group" aria-hidden="true"></i>
<?= $k['template_name'] ? htmlspecialchars($k['template_name']) : __('kiosks_no_template') ?>
</div>
<div class="site-info-item">
<i class="fas fa-gauge-high" aria-hidden="true"></i>
<?= (int)$k['today_vouchers'] ?><?= (int)$k['daily_limit'] > 0 ? ' / ' . (int)$k['daily_limit'] : '' ?>
<?= __('kiosks_today') ?>
</div>
<div class="site-info-item">
<i class="fas fa-ticket" aria-hidden="true"></i>
<?= (int)$k['total_vouchers'] ?> <?= __('kiosks_total') ?>
</div>
</div>
<label class="muted" style="display:block;margin-bottom:6px;"><?= __('kiosks_link') ?></label>
<div class="kiosk-link-row">
<input type="text" class="input" readonly value="<?= htmlspecialchars($url) ?>"
id="link-<?= (int)$k['id'] ?>" onclick="this.select()">
<button class="btn btn-secondary" type="button"
onclick="copyToClipboard('<?= htmlspecialchars($url, ENT_QUOTES) ?>', '<?= __('js_copied') ?>')"
title="<?= __('js_copy') ?>" aria-label="<?= __('js_copy') ?>">
<i class="fas fa-copy" aria-hidden="true"></i>
</button>
</div>
<div class="site-actions">
<a class="btn btn-secondary btn-sm" href="<?= htmlspecialchars($url) ?>" target="_blank" rel="noopener">
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i> <?= __('kiosks_open') ?>
</a>
<button class="btn btn-secondary btn-sm" type="button"
onclick="showQr('<?= htmlspecialchars($url, ENT_QUOTES) ?>', '<?= htmlspecialchars($k['name'], ENT_QUOTES) ?>')">
<i class="fas fa-qrcode" aria-hidden="true"></i> <?= __('kiosks_qr') ?>
</button>
<button class="btn btn-secondary btn-sm" type="button"
onclick='openEditModal(<?= json_encode([
"id" => (int)$k["id"], "site_id" => (int)$k["site_id"],
"template_id" => (int)$k["template_id"], "name" => $k["name"],
"headline" => $k["headline"], "subline" => $k["subline"],
"logo_url" => $k["logo_url"], "background_url" => $k["background_url"],
"bg_overlay" => (int)$k["bg_overlay"], "accent_color" => $k["accent_color"],
"card_style" => $k["card_style"],
"daily_limit" => (int)$k["daily_limit"],
"cooldown_seconds" => (int)$k["cooldown_seconds"],
"display_seconds" => (int)$k["display_seconds"],
"is_active" => (int)$k["is_active"],
], JSON_HEX_APOS | JSON_HEX_QUOT) ?>)'>
<i class="fas fa-edit" aria-hidden="true"></i> <?= __('btn_edit') ?>
</button>
<form method="post" style="display:inline;"
onsubmit="return confirm('<?= __('kiosks_renew_confirm') ?>');">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="kiosk_id" value="<?= (int)$k['id'] ?>">
<button class="btn btn-secondary btn-sm" type="submit" name="renew_token">
<i class="fas fa-rotate" aria-hidden="true"></i> <?= __('kiosks_renew') ?>
</button>
</form>
<a class="btn btn-danger-soft btn-sm"
href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"
onclick="return confirm('<?= __('kiosks_delete_confirm') ?>');"
title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>">
<i class="fas fa-trash" aria-hidden="true"></i>
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Anlegen / Bearbeiten -->
<div class="modal" id="kioskModal">
<div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="kioskModalTitle">
<div class="modal-header">
<h2 class="modal-title" id="kioskModalTitle"><?= __('kiosks_add') ?></h2>
<button class="modal-close" type="button" onclick="closeModal()" aria-label="<?= __('btn_cancel') ?>">&times;</button>
</div>
<form method="post" enctype="multipart/form-data">
<div class="modal-body">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="kiosk_id" id="kiosk_id" value="">
<div class="form-group">
<label for="name"><?= __('kiosks_name') ?></label>
<input type="text" id="name" name="name" required placeholder="<?= __('kiosks_name_placeholder') ?>">
<div class="help-text"><?= __('kiosks_name_hint') ?></div>
</div>
<div class="form-grid">
<div class="form-group">
<label for="site_id"><?= __('label_site') ?></label>
<select id="site_id" name="site_id" required>
<?php foreach ($sites as $s): ?>
<option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="template_id"><?= __('kiosks_template') ?></label>
<select id="template_id" name="template_id">
<option value="0"><?= __('kiosks_no_template') ?></option>
<?php foreach ($templates as $t): ?>
<option value="<?= (int)$t['id'] ?>">
<?= htmlspecialchars($t['name']) ?> <?= (int)$t['max_uses'] ?> <?= __('label_devices') ?>,
<?= (int)$t['expire_minutes'] ?> <?= __('minutes_short') ?>
</option>
<?php endforeach; ?>
</select>
<div class="help-text"><?= __('kiosks_template_hint') ?></div>
</div>
</div>
<hr class="section-divider">
<div class="form-group">
<label for="headline"><?= __('kiosks_headline') ?></label>
<input type="text" id="headline" name="headline" placeholder="<?= htmlspecialchars(__('kiosk_default_headline')) ?>">
</div>
<div class="form-group">
<label for="subline"><?= __('kiosks_subline') ?></label>
<textarea id="subline" name="subline" rows="2" placeholder="<?= htmlspecialchars(__('kiosk_default_subline')) ?>"></textarea>
</div>
<hr class="section-divider">
<h3 style="font-size:14px;margin-bottom:14px;"><?= __('kiosks_appearance') ?></h3>
<?= Ui::imageField('logo_url', __('kiosks_logo'), '', __('kiosks_logo_hint')) ?>
<?= Ui::imageField('background_url', __('kiosks_background'), '', __('kiosks_background_hint')) ?>
<div class="form-grid">
<div class="form-group">
<label for="bg_overlay"><?= __('kiosks_overlay') ?></label>
<input type="number" id="bg_overlay" name="bg_overlay" min="0" max="90" value="45">
<div class="help-text"><?= __('kiosks_overlay_hint') ?></div>
</div>
<div class="form-group">
<label for="accent_color"><?= __('kiosks_accent') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="accent_color" value="<?= Ui::DEFAULT_ACCENT ?>">
<input type="text" id="accent_color" name="accent_color" placeholder="<?= __('kiosks_accent_default') ?>">
</div>
<div class="help-text"><?= __('kiosks_accent_hint') ?></div>
</div>
<div class="form-group">
<label for="card_style"><?= __('kiosks_card_style') ?></label>
<select id="card_style" name="card_style">
<option value="light"><?= __('kiosks_card_light') ?></option>
<option value="dark"><?= __('kiosks_card_dark') ?></option>
</select>
<div class="help-text"><?= __('kiosks_card_hint') ?></div>
</div>
</div>
<hr class="section-divider">
<div class="form-grid">
<div class="form-group">
<label for="daily_limit"><?= __('kiosks_daily_limit') ?></label>
<input type="number" id="daily_limit" name="daily_limit" min="0" value="<?= Kiosk::DEFAULT_DAILY_LIMIT ?>">
<div class="help-text"><?= __('kiosks_daily_limit_hint') ?></div>
</div>
<div class="form-group">
<label for="cooldown_seconds"><?= __('kiosks_cooldown') ?></label>
<input type="number" id="cooldown_seconds" name="cooldown_seconds" min="0" max="3600" value="<?= Kiosk::DEFAULT_COOLDOWN ?>">
<div class="help-text"><?= __('kiosks_cooldown_hint') ?></div>
</div>
<div class="form-group">
<label for="display_seconds"><?= __('kiosks_display') ?></label>
<input type="number" id="display_seconds" name="display_seconds" min="10" max="600" value="<?= Kiosk::DEFAULT_DISPLAY_SECONDS ?>">
<div class="help-text"><?= __('kiosks_display_hint') ?></div>
</div>
</div>
<div class="checkbox-group" id="activeRow" style="display:none;">
<input type="checkbox" id="is_active" name="is_active" checked>
<label for="is_active"><?= __('kiosks_active') ?></label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal()"><?= __('btn_cancel') ?></button>
<button type="submit" name="add_kiosk" id="submitAdd" class="btn btn-primary">
<i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?>
</button>
<button type="submit" name="edit_kiosk" id="submitEdit" class="btn btn-primary" style="display:none;">
<i class="fas fa-save" aria-hidden="true"></i> <?= __('btn_save') ?>
</button>
</div>
</form>
</div>
</div>
<!-- QR-Code des Links -->
<div class="modal" id="qrModal">
<div class="modal-content" style="max-width:420px;" role="dialog" aria-modal="true">
<div class="modal-header">
<h2 class="modal-title" id="qrTitle"><?= __('kiosks_qr') ?></h2>
<button class="modal-close" type="button" onclick="closeQr()" aria-label="<?= __('btn_cancel') ?>">&times;</button>
</div>
<div class="modal-body" style="text-align:center;">
<div id="qrTarget" style="display:inline-block;padding:14px;background:#fff;border-radius:12px;line-height:0;"></div>
<p class="help-text" style="margin-top:14px;"><?= __('kiosks_qr_hint') ?></p>
</div>
</div>
</div>
</main>
<div id="toast-container" role="status" aria-live="polite"></div>
<script src="../assets/global.js"></script>
<script>
function openAddModal() {
document.getElementById('kioskModalTitle').textContent = <?= json_encode(__('kiosks_add')) ?>;
document.querySelector('#kioskModal form').reset();
document.getElementById('kiosk_id').value = '';
setImageField('logo_url', '');
setImageField('background_url', '');
document.getElementById('submitAdd').style.display = '';
document.getElementById('submitEdit').style.display = 'none';
document.getElementById('activeRow').style.display = 'none';
document.getElementById('kioskModal').classList.add('active');
}
function openEditModal(data) {
document.getElementById('kioskModalTitle').textContent = <?= json_encode(__('kiosks_edit')) ?>;
document.getElementById('kiosk_id').value = data.id;
document.getElementById('name').value = data.name || '';
document.getElementById('site_id').value = data.site_id;
document.getElementById('template_id').value = data.template_id || 0;
document.getElementById('headline').value = data.headline || '';
document.getElementById('subline').value = data.subline || '';
document.getElementById('bg_overlay').value = data.bg_overlay;
document.getElementById('accent_color').value = data.accent_color || '';
document.getElementById('card_style').value = data.card_style || 'light';
setImageField('logo_url', data.logo_url || '');
setImageField('background_url', data.background_url || '');
document.getElementById('daily_limit').value = data.daily_limit;
document.getElementById('cooldown_seconds').value = data.cooldown_seconds;
document.getElementById('display_seconds').value = data.display_seconds;
document.getElementById('is_active').checked = data.is_active === 1;
document.getElementById('submitAdd').style.display = 'none';
document.getElementById('submitEdit').style.display = '';
document.getElementById('activeRow').style.display = '';
document.getElementById('kioskModal').classList.add('active');
}
function closeModal() { document.getElementById('kioskModal').classList.remove('active'); }
/**
* Bildfeld im Modal auf den Wert des Kiosks setzen: Vorschau, URL-Feld und
* der Entfernen-Schalter hängen am selben Namen.
*/
function setImageField(name, value) {
const wrapper = document.querySelector('[name="' + name + '"]').closest('.form-group');
const text = wrapper.querySelector('input[type="text"]');
const preview = wrapper.querySelector('.image-preview');
const remove = wrapper.querySelector('input[type="checkbox"]');
if (text) text.value = value;
if (remove) remove.checked = false;
if (preview) {
const src = value && !/^https?:|^\//.test(value) ? '../' + value : value;
preview.innerHTML = value
? '<img src="' + src + '" alt="">'
: '<i class="fas fa-image" aria-hidden="true"></i>';
}
}
function showQr(url, name) {
var target = document.getElementById('qrTarget');
target.innerHTML = '';
document.getElementById('qrTitle').textContent = name;
new QRCode(target, { text: url, width: 260, height: 260, colorDark: '#101625', colorLight: '#ffffff' });
document.getElementById('qrModal').classList.add('active');
}
function closeQr() { document.getElementById('qrModal').classList.remove('active'); }
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeModal(); closeQr(); }
});
document.querySelectorAll('.modal').forEach(function (m) {
m.addEventListener('click', function (e) { if (e.target === m) m.classList.remove('active'); });
});
</script>
</body>
</html>

View file

@ -42,33 +42,6 @@ if (isset($_POST['ajax_smtp_test'])) {
$error = ''; $error = '';
$success = ''; $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 // Einstellungen speichern
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
@ -80,8 +53,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if ($formType === 'general') { if ($formType === 'general') {
$settings['app_title'] = trim($_POST['app_title'] ?? ''); $settings['app_title'] = trim($_POST['app_title'] ?? '');
$settings['logo_url'] = resolveImageField('logo_url', $db, 'image'); $settings['logo_url'] = Upload::resolveField('logo_url', (string)$db->getSetting('logo_url', ''), 'image');
$settings['favicon_url'] = resolveImageField('favicon_url', $db, 'favicon'); $settings['favicon_url'] = Upload::resolveField('favicon_url', (string)$db->getSetting('favicon_url', ''), 'favicon');
$settings['instruction_header'] = trim($_POST['instruction_header'] ?? ''); $settings['instruction_header'] = trim($_POST['instruction_header'] ?? '');
$settings['instruction_text'] = $_POST['instruction_text'] ?? ''; $settings['instruction_text'] = $_POST['instruction_text'] ?? '';
$settings['public_access'] = isset($_POST['public_access']) ? '1' : '0'; $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') { if ($formType === 'login') {
$settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0'; $settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0';
$settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? ''); $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_title'] = trim($_POST['login_claim_title'] ?? '');
$settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? ''); $settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? '');
$settings['login_features'] = trim($_POST['login_features'] ?? ''); $settings['login_features'] = trim($_POST['login_features'] ?? '');
$settings['login_footer'] = trim($_POST['login_footer'] ?? ''); $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_from'] = trim($_POST['login_bg_from'] ?? '');
$settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? ''); $settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? '');
$overlay = (int)($_POST['login_bg_overlay'] ?? 40); $overlay = (int)($_POST['login_bg_overlay'] ?? 40);
@ -263,41 +236,6 @@ $cs = [
'last_cron_sync' => $db->getSetting('last_cron_sync', ''), '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, '../');
?>
<div class="form-group">
<label><?= htmlspecialchars($label) ?></label>
<div class="image-field">
<div class="image-preview">
<?php if ($preview !== ''): ?>
<img src="<?= htmlspecialchars($preview) ?>" alt="">
<?php else: ?>
<i class="fas fa-image" aria-hidden="true"></i>
<?php endif; ?>
</div>
<div class="image-field-controls">
<input type="file" name="<?= $name ?>_file" accept="<?= htmlspecialchars($accept) ?>">
<input type="text" name="<?= $name ?>" value="<?= htmlspecialchars($value) ?>"
placeholder="<?= htmlspecialchars(__('settings_image_url_placeholder')) ?>">
<?php if ($value !== ''): ?>
<label class="chk"><input type="checkbox" name="<?= $name ?>_remove" value="1"> <?= __('settings_image_remove') ?></label>
<?php endif; ?>
</div>
</div>
<?php if ($hint !== ''): ?><div class="help-text"><?= htmlspecialchars($hint) ?></div><?php endif; ?>
</div>
<?php
}
// Aktiver Tab per ?tab=… (Deep-Link, funktioniert auch ohne JavaScript)
$activeTab = preg_replace('/[^a-z_]/', '', (string)($_GET['tab'] ?? 'general')) ?: 'general';
$currentPage = 'settings'; $currentPage = 'settings';
$adminBase = ''; $adminBase = '';
?> ?>
@ -350,8 +288,8 @@ $adminBase = '';
<input type="hidden" name="form_type" value="general"> <input type="hidden" name="form_type" value="general">
<div class="form-group"><label><?= __('settings_app_title') ?></label><input type="text" name="app_title" value="<?= htmlspecialchars($cs['app_title']) ?>" required></div> <div class="form-group"><label><?= __('settings_app_title') ?></label><input type="text" name="app_title" value="<?= htmlspecialchars($cs['app_title']) ?>" required></div>
<div class="form-grid"> <div class="form-grid">
<?php imageField('logo_url', __('settings_logo_url'), $cs['logo_url'], __('settings_upload_hint')); ?> <?= Ui::imageField('logo_url', __('settings_logo_url'), $cs['logo_url'], __('settings_upload_hint')) ?>
<?php imageField('favicon_url', __('settings_favicon_url'), $cs['favicon_url'], __('settings_favicon_hint'), 'image/x-icon,image/png,image/svg+xml'); ?> <?= Ui::imageField('favicon_url', __('settings_favicon_url'), $cs['favicon_url'], __('settings_favicon_hint'), 'image/x-icon,image/png,image/svg+xml') ?>
</div> </div>
<hr class="section-divider"> <hr class="section-divider">
<div class="form-group"><label><?= __('settings_instr_header') ?></label><input type="text" name="instruction_header" value="<?= htmlspecialchars($cs['instruction_header']) ?>"></div> <div class="form-group"><label><?= __('settings_instr_header') ?></label><input type="text" name="instruction_header" value="<?= htmlspecialchars($cs['instruction_header']) ?>"></div>
@ -485,7 +423,7 @@ $adminBase = '';
<input type="text" name="login_brand_name" value="<?= htmlspecialchars($cs['login_brand_name']) ?>" placeholder="<?= htmlspecialchars($cs['app_title']) ?>"> <input type="text" name="login_brand_name" value="<?= htmlspecialchars($cs['login_brand_name']) ?>" placeholder="<?= htmlspecialchars($cs['app_title']) ?>">
<div class="help-text"><?= __('settings_login_brand_hint') ?></div> <div class="help-text"><?= __('settings_login_brand_hint') ?></div>
</div> </div>
<?php imageField('login_logo_url', __('settings_login_logo'), $cs['login_logo_url'], __('settings_login_logo_hint')); ?> <?= Ui::imageField('login_logo_url', __('settings_login_logo'), $cs['login_logo_url'], __('settings_login_logo_hint')) ?>
</div> </div>
<hr class="section-divider"> <hr class="section-divider">
@ -510,7 +448,7 @@ $adminBase = '';
<hr class="section-divider"> <hr class="section-divider">
<?php imageField('login_bg_image', __('settings_login_bg_image'), $cs['login_bg_image'], __('settings_login_bg_image_hint')); ?> <?= Ui::imageField('login_bg_image', __('settings_login_bg_image'), $cs['login_bg_image'], __('settings_login_bg_image_hint')) ?>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label><?= __('settings_login_bg_from') ?></label> <label><?= __('settings_login_bg_from') ?></label>

View file

@ -1535,6 +1535,20 @@ input.search-bar, .site-selector .search-bar { min-width: 240px; width: auto; fl
.print-qr { display: flex; justify-content: center; line-height: 0; } .print-qr { display: flex; justify-content: center; line-height: 0; }
.print-qr img, .print-qr canvas { display: block; } .print-qr img, .print-qr canvas { display: block; }
/* Entwicklerhinweis im Seitenfuß */
.app-credit {
margin: 0;
font-size: 11.5px;
color: var(--text-muted);
}
.app-credit a { color: var(--text-secondary); text-decoration: none; }
.app-credit a:hover { color: var(--accent); text-decoration: underline; }
.sidebar-foot .app-credit { margin-top: 10px; padding: 0 4px; text-align: center; }
.app-footer { max-width: 560px; margin: 20px auto 0; text-align: center; }
.login-container .app-credit { margin-top: 26px; text-align: center; }
.focus-card .app-credit { margin-top: 22px; text-align: center; }
.wrap .app-credit { margin-top: 18px; text-align: center; }
/* Sprungmarke zum Inhalt (nur bei Tastaturfokus sichtbar) */ /* Sprungmarke zum Inhalt (nur bei Tastaturfokus sichtbar) */
.skip-link { .skip-link {
position: absolute; position: absolute;
@ -1596,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 13. RESPONSIVE
========================================================================= */ ========================================================================= */

View file

@ -2,6 +2,17 @@
"name": "friloo/unifi-voucher-tool", "name": "friloo/unifi-voucher-tool",
"description": "Webbasiertes WLAN-Voucher-Management für UniFi OS", "description": "Webbasiertes WLAN-Voucher-Management für UniFi OS",
"license": "MIT", "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": { "require": {
"php": ">=7.4" "php": ">=7.4"
}, },

View file

@ -66,6 +66,34 @@ CREATE TABLE IF NOT EXISTS `voucher_templates` (
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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` ( CREATE TABLE IF NOT EXISTS `api_keys` (
`id` INT PRIMARY KEY AUTO_INCREMENT, `id` INT PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL, `name` VARCHAR(255) NOT NULL,
@ -93,6 +121,7 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
`id` INT PRIMARY KEY AUTO_INCREMENT, `id` INT PRIMARY KEY AUTO_INCREMENT,
`site_id` INT NOT NULL, `site_id` INT NOT NULL,
`user_id` INT, `user_id` INT,
`kiosk_id` INT NULL,
`voucher_code` VARCHAR(50) NOT NULL, `voucher_code` VARCHAR(50) NOT NULL,
`voucher_name` VARCHAR(255) NOT NULL, `voucher_name` VARCHAR(255) NOT NULL,
`max_uses` INT NOT NULL, `max_uses` INT NOT NULL,
@ -109,7 +138,8 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
INDEX `idx_site` (`site_id`), INDEX `idx_site` (`site_id`),
INDEX `idx_created` (`created_at`), INDEX `idx_created` (`created_at`),
INDEX `idx_unifi_id` (`unifi_voucher_id`), 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `sessions` ( CREATE TABLE IF NOT EXISTS `sessions` (

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

After

Width:  |  Height:  |  Size: 396 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

After

Width:  |  Height:  |  Size: 389 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 319 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 319 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

After

Width:  |  Height:  |  Size: 472 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Before After
Before After

169
includes/Kiosk.php Normal file
View file

@ -0,0 +1,169 @@
<?php
/**
* Öffentliche Display-Seiten ("Kiosk").
*
* Ein Kiosk gehört zu genau einer Site und ist über einen geheimen Link
* erreichbar. Gäste holen sich darüber mit einem Klick einen Zugangscode
* ohne Anmeldung, aber begrenzt durch Tageslimit und Wartezeit.
*/
class Kiosk
{
/** Vorgaben für neue Kiosk-Seiten. */
public const DEFAULT_DAILY_LIMIT = 100;
public const DEFAULT_COOLDOWN = 20; // Sekunden zwischen zwei Codes
public const DEFAULT_DISPLAY_SECONDS = 90; // Anzeigedauer des Codes
/** Unrat-freier Zufallstoken für den öffentlichen Link. */
public static function newToken(): string
{
return bin2hex(random_bytes(16));
}
/** Token aus einer Anfrage säubern (Länge und Zeichen fest vorgegeben). */
public static function sanitizeToken(?string $token): string
{
$token = strtolower(trim((string)$token));
return preg_match('/^[0-9a-f]{32}$/', $token) ? $token : '';
}
/**
* Kiosk samt Site und Profil laden. Liefert null, wenn der Token nicht
* passt, der Kiosk deaktiviert ist oder die Site nicht mehr aktiv ist.
*/
public static function findByToken($db, string $token): ?array
{
$token = self::sanitizeToken($token);
if ($token === '') {
return null;
}
$row = $db->fetchOne(
"SELECT k.*, s.name AS site_name, s.is_active AS site_active,
t.name AS template_name, t.max_uses AS tpl_max_uses, t.expire_minutes AS tpl_expire_minutes,
t.qos_rate_max_down, t.qos_rate_max_up, t.qos_usage_quota
FROM kiosks k
INNER JOIN sites s ON s.id = k.site_id
LEFT JOIN voucher_templates t ON t.id = k.template_id
WHERE k.token = ? AND k.is_active = 1",
[$token]
);
if (!$row || (int)$row['site_active'] !== 1) {
return null;
}
return $row;
}
/** Wie viele Codes hat dieser Kiosk heute schon ausgegeben? */
public static function usedToday($db, int $kioskId): int
{
$row = $db->fetchOne(
"SELECT COUNT(*) AS c FROM vouchers WHERE kiosk_id = ? AND DATE(created_at) = CURDATE()",
[$kioskId]
);
return (int)($row['c'] ?? 0);
}
/**
* Darf gerade ein Code geholt werden?
*
* @return array{allowed:bool,reason:string,wait:int}
* reason: '' | 'cooldown' | 'daily_limit'
*/
public static function checkLimits($db, array $kiosk): array
{
$cooldown = max(0, (int)$kiosk['cooldown_seconds']);
if ($cooldown > 0 && !empty($kiosk['last_used_at'])) {
$elapsed = time() - strtotime((string)$kiosk['last_used_at']);
if ($elapsed >= 0 && $elapsed < $cooldown) {
return ['allowed' => false, 'reason' => 'cooldown', 'wait' => $cooldown - $elapsed];
}
}
$limit = max(0, (int)$kiosk['daily_limit']);
if ($limit > 0 && self::usedToday($db, (int)$kiosk['id']) >= $limit) {
return ['allowed' => false, 'reason' => 'daily_limit', 'wait' => 0];
}
return ['allowed' => true, 'reason' => '', 'wait' => 0];
}
/** Nach erfolgreicher Ausgabe den Zeitstempel fortschreiben. */
public static function markUsed($db, int $kioskId): void
{
$db->execute("UPDATE kiosks SET last_used_at = NOW() WHERE id = ?", [$kioskId]);
}
/**
* Voucher-Eckdaten eines Kiosks: entweder aus dem verknüpften Profil
* oder aus den globalen Standardwerten.
*/
public static function voucherSettings($db, array $kiosk): array
{
$maxUses = (int)($kiosk['tpl_max_uses'] ?? 0);
$expire = (int)($kiosk['tpl_expire_minutes'] ?? 0);
if ($maxUses < 1) {
$maxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
}
if ($expire < 1) {
$expire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
}
return [
'max_uses' => $maxUses,
'expire_minutes' => $expire,
'qos' => [
'down' => max(0, (int)($kiosk['qos_rate_max_down'] ?? 0)),
'up' => max(0, (int)($kiosk['qos_rate_max_up'] ?? 0)),
'quota_mb' => max(0, (int)($kiosk['qos_usage_quota'] ?? 0)),
],
];
}
/**
* 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;
}
}

View file

@ -116,6 +116,82 @@ class Ui
. '</style>'; . '</style>';
} }
/**
* 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 !== ''
? '<img src="' . $esc($preview) . '" alt="">'
: '<i class="fas fa-image" aria-hidden="true"></i>';
$remove = $value !== ''
? '<label class="chk"><input type="checkbox" name="' . $esc($name) . '_remove" value="1"> '
. $esc($t('settings_image_remove', 'Bild entfernen')) . '</label>'
: '';
return '<div class="form-group">'
. '<label>' . $esc($label) . '</label>'
. '<div class="image-field">'
. '<div class="image-preview">' . $thumb . '</div>'
. '<div class="image-field-controls">'
. '<input type="file" name="' . $esc($name) . '_file" accept="' . $esc($accept) . '">'
. '<input type="text" name="' . $esc($name) . '" value="' . $esc($value) . '" '
. 'placeholder="' . $esc($t('settings_image_url_placeholder', 'https://… oder Datei hochladen')) . '">'
. $remove
. '</div></div>'
. ($hint !== '' ? '<div class="help-text">' . $esc($hint) . '</div>' : '')
. '</div>';
}
/**
* 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';
/**
* Dezenter Hinweis auf den Entwickler, wie er im Seitenfuß erscheint.
*/
public static function credit(bool $withVersion = false): string
{
$label = function_exists('__') ? __('credit_by') : 'Entwickelt von';
$prefix = '';
if ($withVersion && self::version() !== '') {
$prefix = 'v' . htmlspecialchars(self::version()) . ' · ';
}
return '<p class="app-credit">' . $prefix . htmlspecialchars($label) . ' '
. '<a href="' . self::CREDIT_URL . '" target="_blank" rel="noopener">'
. self::CREDIT_NAME . '</a></p>';
}
/** /**
* Standard-Druckvorlage (wird nur verwendet, solange keine eigene * Standard-Druckvorlage (wird nur verwendet, solange keine eigene
* Vorlage gespeichert ist). {QR_CODE} fuellt der Browser. * Vorlage gespeichert ist). {QR_CODE} fuellt der Browser.

View file

@ -120,6 +120,39 @@ class Upload
return 'uploads/' . $name; 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 <name>, <name>_file, <name>_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, * Entfernt aktive Inhalte aus SVG-Dateien (Skripte, Event-Handler,
* externe Verweise). Lieber eine Grafik verlieren als eine XSS-Luecke. * externe Verweise). Lieber eine Grafik verlieren als eine XSS-Luecke.

View file

@ -0,0 +1,63 @@
<?php
/**
* Erstellt Voucher im UniFi-Controller und schreibt sie in die Datenbank.
*
* Bis hierher lag diese Logik doppelt in index.php und der REST-API; mit der
* Kiosk-Seite waere sie ein drittes Mal noetig gewesen.
*/
require_once __DIR__ . '/UniFiController.php';
require_once __DIR__ . '/Crypto.php';
class VoucherService
{
/**
* @param array $site Zeile aus `sites`
* @param array $qos ['down' => kbit, 'up' => kbit, 'quota_mb' => MB]
* @param int|null $userId angemeldeter Benutzer, sonst null
* @param int|null $kioskId Herkunft, falls ueber eine Display-Seite geholt
*
* @return array{code:string,site_name:string,max_uses:int,expire_min:int,expiry_date:string,expiry_time:string}
* @throws Exception wenn der Controller keinen gueltigen Voucher liefert
*/
public static function create(
$db,
array $site,
string $voucherName,
int $maxUses,
int $expireMinutes,
?int $userId = null,
array $qos = [],
?int $kioskId = null
): array {
$fullName = date('Y-m-d') . '_' . $voucherName;
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
throw new Exception(function_exists('__') ? __('error_voucher_invalid') : 'Ungueltige Antwort des Controllers');
}
$db->execute(
"INSERT INTO vouchers (site_id, user_id, kiosk_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[$site['id'], $userId, $kioskId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
);
$expiryTs = time() + ($expireMinutes * 60);
return [
'code' => $voucher['formatted_code'],
'site_name' => $site['name'],
'max_uses' => $maxUses,
'expire_min' => $expireMinutes,
'expiry_date' => date('d.m.Y', $expiryTs),
'expiry_time' => date('H:i', $expiryTs),
];
}
}

View file

@ -23,6 +23,7 @@ $navGroups = [
['vouchers', 'vouchers.php', 'fa-ticket', 'nav_vouchers'], ['vouchers', 'vouchers.php', 'fa-ticket', 'nav_vouchers'],
['templates', 'templates.php', 'fa-layer-group', 'nav_templates'], ['templates', 'templates.php', 'fa-layer-group', 'nav_templates'],
['import', 'import.php', 'fa-file-arrow-up', 'nav_import'], ['import', 'import.php', 'fa-file-arrow-up', 'nav_import'],
['kiosks', 'kiosks.php', 'fa-display', 'nav_kiosks'],
['sites', 'sites.php', 'fa-location-dot', 'nav_sites'], ['sites', 'sites.php', 'fa-location-dot', 'nav_sites'],
['users', 'users.php', 'fa-users', 'nav_users'], ['users', 'users.php', 'fa-users', 'nav_users'],
], ],
@ -76,8 +77,8 @@ foreach ($navGroups as $items) {
<?php endforeach; ?> <?php endforeach; ?>
</nav> </nav>
<?php if ($currentUser): ?>
<div class="sidebar-foot"> <div class="sidebar-foot">
<?php if ($currentUser): ?>
<div class="user-menu" style="width:100%;justify-content:flex-start;"> <div class="user-menu" style="width:100%;justify-content:flex-start;">
<div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div> <div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div>
<div style="min-width:0;flex:1;"> <div style="min-width:0;flex:1;">
@ -88,8 +89,9 @@ foreach ($navGroups as $items) {
<i class="fas fa-arrow-right-from-bracket" aria-hidden="true"></i> <i class="fas fa-arrow-right-from-bracket" aria-hidden="true"></i>
</a> </a>
</div> </div>
</div>
<?php endif; ?> <?php endif; ?>
<?= Ui::credit(true) ?>
</div>
</aside> </aside>
<header class="topbar"> <header class="topbar">

View file

@ -20,6 +20,7 @@ require_once __DIR__ . '/includes/Notifier.php';
require_once __DIR__ . '/includes/Captcha.php'; require_once __DIR__ . '/includes/Captcha.php';
require_once __DIR__ . '/includes/Sms.php'; require_once __DIR__ . '/includes/Sms.php';
require_once __DIR__ . '/includes/Ui.php'; require_once __DIR__ . '/includes/Ui.php';
require_once __DIR__ . '/includes/VoucherService.php';
require_once __DIR__ . '/includes/I18n.php'; require_once __DIR__ . '/includes/I18n.php';
$auth = new Auth(); $auth = new Auth();
@ -115,34 +116,9 @@ if ($auth->isLoggedIn()) {
$autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0; $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 = []) { function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos = []) {
$datum = date('Y-m-d'); return VoucherService::create($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos);
$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),
];
} }
// Single voucher // Single voucher
@ -635,6 +611,8 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<?php endif; ?> <?php endif; ?>
</main> </main>
<footer class="app-footer no-print"><?= Ui::credit() ?></footer>
<div id="toast-container" role="status" aria-live="polite"></div> <div id="toast-container" role="status" aria-live="polite"></div>
<script src="assets/global.js"></script> <script src="assets/global.js"></script>
<script> <script>

View file

@ -375,6 +375,7 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
<button type="submit" class="btn btn-primary btn-lg btn-block">Installation abschließen</button> <button type="submit" class="btn btn-primary btn-lg btn-block">Installation abschließen</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?= Ui::credit() ?>
</div> </div>
</body> </body>
</html> </html>

245
kiosk.php Normal file
View file

@ -0,0 +1,245 @@
<?php
/**
* Öffentliche Display-Seite ("Kiosk").
*
* Aufruf: kiosk.php?k=<token>
*
* Gedacht für ein Tablet oder einen Bildschirm im Empfangsbereich: ein großer
* Knopf, ein Klick, ein Zugangscode. Gäste ohne Zugriff auf den Bildschirm
* können denselben Link über den QR-Code am Handy öffnen.
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/I18n.php';
require_once __DIR__ . '/includes/Ui.php';
require_once __DIR__ . '/includes/Kiosk.php';
require_once __DIR__ . '/includes/VoucherService.php';
I18n::init();
try {
$db = Database::getInstance();
$auth = new Auth();
} catch (Exception $e) {
http_response_code(500);
die('Datenbankfehler');
}
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$token = Kiosk::sanitizeToken($_GET['k'] ?? '');
$kiosk = $token !== '' ? Kiosk::findByToken($db, $token) : null;
if (!$kiosk) {
http_response_code(404);
$notFound = true;
} else {
$notFound = false;
}
$voucher = null; // erzeugter Code
$error = '';
$waitSecs = 0;
if (!$notFound && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
$limits = Kiosk::checkLimits($db, $kiosk);
if (!$limits['allowed']) {
$waitSecs = (int)$limits['wait'];
$error = $limits['reason'] === 'cooldown'
? str_replace('{seconds}', (string)$waitSecs, __('kiosk_error_cooldown'))
: __('kiosk_error_limit');
} else {
try {
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [(int)$kiosk['site_id']]);
if (!$site) {
throw new Exception(__('error_site_not_found'));
}
$settings = Kiosk::voucherSettings($db, $kiosk);
$voucher = VoucherService::create(
$db,
$site,
$kiosk['name'],
$settings['max_uses'],
$settings['expire_minutes'],
null,
$settings['qos'],
(int)$kiosk['id']
);
Kiosk::markUsed($db, (int)$kiosk['id']);
$auth->writeAuditLog(null, 'voucher_kiosk', 'kiosk', (int)$kiosk['id'],
$kiosk['name'] . ' · ' . $voucher['code']);
} catch (Exception $e) {
error_log('Kiosk-Fehler: ' . $e->getMessage());
$error = __('kiosk_error_generic');
}
}
}
}
$headline = trim((string)($kiosk['headline'] ?? '')) ?: __('kiosk_default_headline');
$subline = trim((string)($kiosk['subline'] ?? '')) ?: __('kiosk_default_subline');
$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);';
}
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title><?= htmlspecialchars($appTitle) ?></title>
<?= Ui::head($db) ?>
<?php if (!$notFound): ?>
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js') ?>
<?php endif; ?>
</head>
<body class="<?= $bodyClass ?>"<?= $bodyStyle !== '' ? ' style="' . $bodyStyle . '"' : '' ?>>
<?php if ($notFound): ?>
<main class="kiosk-stage">
<div class="kiosk-card">
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-link-slash" aria-hidden="true"></i></div>
<h1><?= __('kiosk_unknown') ?></h1>
<p><?= __('kiosk_unknown_hint') ?></p>
</div>
</div>
</main>
<?php elseif ($voucher): ?>
<main class="kiosk-stage">
<div class="kiosk-card kiosk-result">
<?php if ($logoUrl): ?>
<img class="kiosk-logo kiosk-logo-sm" src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="<?= htmlspecialchars($appTitle) ?>">
<?php endif; ?>
<p class="kiosk-eyebrow"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('kiosk_ready') ?></p>
<div class="kiosk-code" id="voucherCode"><?= htmlspecialchars($voucher['code']) ?></div>
<div class="kiosk-meta">
<span><i class="fas fa-location-dot" aria-hidden="true"></i> <?= htmlspecialchars($voucher['site_name']) ?></span>
<span><i class="fas fa-clock" aria-hidden="true"></i> <?= (int)$voucher['expire_min'] ?> <?= __('minutes_short') ?></span>
<span><i class="fas fa-mobile-screen" aria-hidden="true"></i> <?= (int)$voucher['max_uses'] ?> <?= __('label_devices') ?></span>
</div>
<div class="kiosk-qr">
<div id="qrcode"></div>
<p class="kiosk-qr-label"><?= __('kiosk_scan_code') ?></p>
</div>
<p class="kiosk-countdown">
<?= str_replace('{seconds}', '<span id="countdown">' . $display . '</span>', __('kiosk_reset_in')) ?>
</p>
<a class="btn btn-secondary btn-lg" href="?k=<?= htmlspecialchars($kiosk['token']) ?>"><?= __('kiosk_done') ?></a>
</div>
</main>
<?php else: ?>
<main class="kiosk-stage">
<div class="kiosk-card">
<?php if ($logoUrl): ?>
<img class="kiosk-logo" src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="<?= htmlspecialchars($appTitle) ?>">
<?php else: ?>
<span class="brand-mark kiosk-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
<?php endif; ?>
<h1 class="kiosk-headline"><?= htmlspecialchars($headline) ?></h1>
<p class="kiosk-subline"><?= htmlspecialchars($subline) ?></p>
<?php if ($error): ?>
<div class="alert alert-error kiosk-alert"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="post" id="kioskForm">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
<button type="submit" class="btn btn-primary kiosk-button" id="kioskButton"
<?= $waitSecs > 0 ? 'disabled' : '' ?>>
<i class="fas fa-wifi" aria-hidden="true"></i>
<span><?= __('kiosk_button') ?></span>
</button>
</form>
<div class="kiosk-phone">
<div id="selfQr" class="kiosk-phone-qr"></div>
<p><?= __('kiosk_phone_hint') ?></p>
</div>
</div>
</main>
<?php endif; ?>
<footer class="kiosk-footer"><?= Ui::credit() ?></footer>
<?php if (!$notFound): ?>
<script>
(function () {
<?php if ($voucher): ?>
// Code als QR lokal erzeugt, ohne externen Dienst.
new QRCode(document.getElementById('qrcode'), {
text: <?= json_encode(str_replace('-', '', $voucher['code'])) ?>,
width: 240, height: 240,
colorDark: '#101625', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
// Nach der Anzeigedauer zurück zum Startbildschirm, damit der nächste
// Gast nicht den Code seines Vorgängers sieht.
var left = <?= $display ?>;
var out = document.getElementById('countdown');
setInterval(function () {
left -= 1;
if (out) out.textContent = left > 0 ? left : 0;
if (left <= 0) location.href = '?k=<?= htmlspecialchars($kiosk['token'], ENT_QUOTES) ?>';
}, 1000);
<?php else: ?>
// QR auf diese Seite selbst: Gäste öffnen sie am eigenen Handy.
new QRCode(document.getElementById('selfQr'), {
text: <?= json_encode($selfUrl) ?>,
width: 150, height: 150,
colorDark: '#101625', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
// Doppelklicks auf dem Touch-Display verhindern.
var form = document.getElementById('kioskForm');
var button = document.getElementById('kioskButton');
if (form && button) {
form.addEventListener('submit', function () {
button.disabled = true;
button.querySelector('span').textContent = <?= json_encode(__('kiosk_working')) ?>;
});
}
<?php if ($waitSecs > 0): ?>
// Nach der Wartezeit wieder freigeben.
setTimeout(function () { location.href = '?k=<?= htmlspecialchars($kiosk['token'], ENT_QUOTES) ?>'; }, <?= $waitSecs * 1000 ?>);
<?php endif; ?>
<?php endif; ?>
})();
</script>
<?php endif; ?>
</body>
</html>

View file

@ -469,6 +469,74 @@ return [
'settings_qr_hint' => '{QR_CODE} fügt einen QR-Code mit dem Voucher-Code ein.', 'settings_qr_hint' => '{QR_CODE} fügt einen QR-Code mit dem Voucher-Code ein.',
'print_valid_until' => 'Gültig bis', 'print_valid_until' => 'Gültig bis',
'print_devices' => 'Geräte', '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' => '090 %. 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_general' => 'Allgemein',
'settings_tab_defaults' => 'Voucher-Standards', 'settings_tab_defaults' => 'Voucher-Standards',
'settings_tab_cron' => 'Cron-Sync', 'settings_tab_cron' => 'Cron-Sync',

View file

@ -469,6 +469,74 @@ return [
'settings_qr_hint' => '{QR_CODE} inserts a QR code containing the voucher code.', 'settings_qr_hint' => '{QR_CODE} inserts a QR code containing the voucher code.',
'print_valid_until' => 'Valid until', 'print_valid_until' => 'Valid until',
'print_devices' => 'devices', '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' => '090%. 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_general' => 'General',
'settings_tab_defaults' => 'Voucher Defaults', 'settings_tab_defaults' => 'Voucher Defaults',
'settings_tab_cron' => 'Cron Sync', 'settings_tab_cron' => 'Cron Sync',

View file

@ -288,6 +288,7 @@ try {
<?php if ($publicAccess): ?> <?php if ($publicAccess): ?>
<div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('login_back') ?></a></div> <div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= __('login_back') ?></a></div>
<?php endif; ?> <?php endif; ?>
<?= Ui::credit() ?>
</div> </div>
</section> </section>

View file

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

205
tests/KioskTest.php Normal file
View file

@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../includes/Kiosk.php';
/**
* Datenbank-Attrappe: liefert feste Zählwerte und Einstellungen.
*/
class FakeKioskDb
{
public int $usedToday = 0;
/** @var array<string, string> */
private array $settings;
/** @param array<string, string> $settings */
public function __construct(array $settings = [])
{
$this->settings = $settings;
}
public function fetchOne($sql, $params = [])
{
return ['c' => $this->usedToday];
}
public function getSetting($key, $default = null)
{
return $this->settings[$key] ?? $default;
}
}
/**
* Die Grenzen der Kiosk-Seite sind das, was sie vor Missbrauch schützt
* der Link ist öffentlich, also muss diese Logik stimmen.
*/
class KioskTest extends TestCase
{
/** @param array<string, mixed> $overrides */
private function kiosk(array $overrides = []): array
{
return array_merge([
'id' => 1,
'daily_limit' => 100,
'cooldown_seconds' => 20,
'last_used_at' => null,
], $overrides);
}
public function testTokenHasFixedShape(): void
{
$token = \Kiosk::newToken();
$this->assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $token);
$this->assertNotSame($token, \Kiosk::newToken(), 'Tokens dürfen sich nicht wiederholen');
}
public function testSanitizeTokenRejectsAnythingElse(): void
{
$valid = \Kiosk::newToken();
$this->assertSame($valid, \Kiosk::sanitizeToken($valid));
$this->assertSame($valid, \Kiosk::sanitizeToken(strtoupper($valid)));
$this->assertSame('', \Kiosk::sanitizeToken('kurz'));
$this->assertSame('', \Kiosk::sanitizeToken("' OR 1=1 --"));
$this->assertSame('', \Kiosk::sanitizeToken(null));
$this->assertSame('', \Kiosk::sanitizeToken($valid . 'ff'));
}
public function testCooldownBlocksSecondCode(): void
{
$db = new FakeKioskDb();
$kiosk = $this->kiosk(['last_used_at' => date('Y-m-d H:i:s', time() - 5)]);
$result = \Kiosk::checkLimits($db, $kiosk);
$this->assertFalse($result['allowed']);
$this->assertSame('cooldown', $result['reason']);
$this->assertGreaterThan(0, $result['wait']);
$this->assertLessThanOrEqual(20, $result['wait']);
}
public function testCooldownExpires(): void
{
$db = new FakeKioskDb();
$kiosk = $this->kiosk(['last_used_at' => date('Y-m-d H:i:s', time() - 60)]);
$this->assertTrue(\Kiosk::checkLimits($db, $kiosk)['allowed']);
}
public function testDailyLimitBlocks(): void
{
$db = new FakeKioskDb();
$db->usedToday = 100;
$result = \Kiosk::checkLimits($db, $this->kiosk());
$this->assertFalse($result['allowed']);
$this->assertSame('daily_limit', $result['reason']);
}
public function testZeroMeansUnlimited(): void
{
$db = new FakeKioskDb();
$db->usedToday = 5000;
$kiosk = $this->kiosk(['daily_limit' => 0, 'cooldown_seconds' => 0]);
$this->assertTrue(\Kiosk::checkLimits($db, $kiosk)['allowed']);
}
public function testVoucherSettingsPreferTemplate(): void
{
$db = new FakeKioskDb(['default_max_uses' => '1', 'default_expire_minutes' => '480']);
$kiosk = $this->kiosk([
'tpl_max_uses' => 5,
'tpl_expire_minutes' => 240,
'qos_rate_max_down' => 20000,
'qos_rate_max_up' => 5000,
'qos_usage_quota' => 1024,
]);
$settings = \Kiosk::voucherSettings($db, $kiosk);
$this->assertSame(5, $settings['max_uses']);
$this->assertSame(240, $settings['expire_minutes']);
$this->assertSame(20000, $settings['qos']['down']);
$this->assertSame(1024, $settings['qos']['quota_mb']);
}
public function testVoucherSettingsFallBackToDefaults(): void
{
$db = new FakeKioskDb(['default_max_uses' => '3', 'default_expire_minutes' => '120']);
$settings = \Kiosk::voucherSettings($db, $this->kiosk());
$this->assertSame(3, $settings['max_uses']);
$this->assertSame(120, $settings['expire_minutes']);
$this->assertSame(0, $settings['qos']['down']);
}
public function 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/')
);
}
}

View file

@ -38,5 +38,11 @@ php -S 127.0.0.1:8123 -t /tmp/uvt-demo &
CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py 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 Die Skripte sind Hilfsmittel für die Entwicklung im Betrieb werden sie nicht
benötigt und sind per `.htaccess` nicht über HTTP erreichbar. benötigt und sind per `.htaccess` nicht über HTTP erreichbar.

View file

@ -89,6 +89,21 @@ document.addEventListener('DOMContentLoaded', function () {
patch(os.path.join(target, 'admin', 'api_keys.php'), '$keys = $db->fetchAll(', patch(os.path.join(target, 'admin', 'api_keys.php'), '$keys = $db->fetchAll(',
"if (($_GET['demo'] ?? '') === 'new') { $newKey = 'uvt_3f9a2c7d41e8b60592af18cc4d7e0b3a95f2617c'; }\n$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'),
'<div class="modal" id="kioskModal">',
'<div class="modal<?= ($_GET[\'demo\'] ?? \'\') === \'form\' ? \' active\' : \'\' ?>" id="kioskModal">')
# Theme per Query-Parameter erzwingen (fuer Dark-Mode-Screenshots) # Theme per Query-Parameter erzwingen (fuer Dark-Mode-Screenshots)
ui = os.path.join(target, 'includes', 'Ui.php') ui = os.path.join(target, 'includes', 'Ui.php')
patch(ui, 'var s=localStorage.getItem("theme");', patch(ui, 'var s=localStorage.getItem("theme");',

View file

@ -58,6 +58,26 @@ class Database {
public function fetchAll($sql, $params = []) { public function fetchAll($sql, $params = []) {
$s = preg_replace('/\s+/', ' ', strtolower($sql)); $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 sites')) return [['count'=>4]];
if (str_contains($s, 'count(*) as count from users')) return [['count'=>12]]; 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]]; if (str_contains($s, 'count(*) as count from vouchers where date(created_at)=curdate()')) return [['count'=>18]];

98
tools/runner/README.md Normal file
View file

@ -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 "<REGISTRIERUNGS-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
```

View file

@ -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: []

View file

@ -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"

View file

@ -35,6 +35,11 @@ SHOTS = [
('two-factor.png', 'admin/security.php', 1200, 900), ('two-factor.png', 'admin/security.php', 1200, 900),
('updater.png', 'admin/update.php', 1200, 780), ('updater.png', 'admin/update.php', 1200, 780),
('updater-available.png', 'admin/update.php?demo=available', 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), ('mobile-vouchers.png', 'admin/users.php', 430, 860),
('maintenance.png', 'updater/templates/maintenance.html', 1200, 700), ('maintenance.png', 'updater/templates/maintenance.html', 1200, 700),
] ]

View file

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

View file

@ -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`;

View file

@ -90,6 +90,7 @@
<p>Es wird gerade ein Update eingespielt. Die Seite ist in wenigen Augenblicken wieder erreichbar.</p> <p>Es wird gerade ein Update eingespielt. Die Seite ist in wenigen Augenblicken wieder erreichbar.</p>
<div class="bar"></div> <div class="bar"></div>
<p class="hint">Diese Seite aktualisiert sich automatisch.</p> <p class="hint">Diese Seite aktualisiert sich automatisch.</p>
<p class="hint" style="margin-top:10px;">Entwickelt von <a href="https://loheide.eu" style="color:inherit;">Loheide.eu</a></p>
</div> </div>
</body> </body>
</html> </html>

View file

@ -116,6 +116,7 @@ $channels = \Updater\UpdateManager::CHANNELS;
<div class="alert alert-ok" id="migAlert"></div> <div class="alert alert-ok" id="migAlert"></div>
</div> </div>
</div> </div>
<?= Ui::credit() ?>
</div> </div>
<script> <script>