Compare commits
14 commits
feature/se
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a09e35097 | |||
| 4159b92268 | |||
| 943e427150 | |||
| ee3b85add4 | |||
| 61810eb050 | |||
| 5d72febadc | |||
| 83f4223d89 | |||
| ad197ecf90 | |||
| 8facc71455 | |||
| b7f13d8fac | |||
| 5f7c503dff | |||
| 068f6c08f9 | |||
| 7fa423a74c | |||
| 7dc60c5bb0 |
15
.gitattributes
vendored
Normal 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
|
|
@ -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
|
|
@ -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)**
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
[](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 | 0–90 % dunkle Ebene über dem Bild, damit die Karte lesbar bleibt |
|
||||||
|
| Akzentfarbe | färbt den Knopf dieser Seite (leer = Farbe aus dem Design-Tab) |
|
||||||
|
| Karte | hell oder dunkel – auf Fotos wirkt die dunkle Karte meist ruhiger |
|
||||||
|
|
||||||
|
Logo und Hintergrund lassen sich direkt hochladen (PNG, JPG, WEBP, GIF, SVG bis
|
||||||
|
3 MB) oder als URL hinterlegen; beim Löschen einer Display-Seite verschwinden
|
||||||
|
die hochgeladenen Dateien mit.
|
||||||
|
|
||||||
|
Die ausgegebenen Codes erscheinen normal in *Live Vouchers*, im *Reporting* und
|
||||||
|
im *Audit-Log* (Aktion „Voucher am Display geholt"), sodass jederzeit
|
||||||
|
nachvollziehbar bleibt, woher ein Zugang stammt.
|
||||||
|
|
||||||
|
> Display-Seiten funktionieren unabhängig vom globalen öffentlichen Modus – der
|
||||||
|
> geheime Link ist der Zugang. Webhook-Benachrichtigungen werden für diese Codes
|
||||||
|
> bewusst **nicht** ausgelöst, sonst wäre der Slack-Kanal voll.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ⚙️ Konfiguration
|
## ⚙️ 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
|
|
@ -0,0 +1 @@
|
||||||
|
2.8.0
|
||||||
|
|
@ -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
|
|
@ -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') ?>">×</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') ?>">×</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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
32
database.sql
|
|
@ -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` (
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 386 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 379 KiB After Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 319 KiB |
BIN
docs/screenshots/kiosk-branded.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
docs/screenshots/kiosk-code.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
docs/screenshots/kiosk-display.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
docs/screenshots/kiosks-admin.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
docs/screenshots/kiosks-form.png
Normal file
|
After Width: | Height: | Size: 446 KiB |
|
Before Width: | Height: | Size: 465 KiB After Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 1 MiB After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 412 KiB After Width: | Height: | Size: 421 KiB |
169
includes/Kiosk.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
63
includes/VoucherService.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?= Ui::credit(true) ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|
|
||||||
32
index.php
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
68
lang/de.php
|
|
@ -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' => '0–90 %. Höhere Werte machen die Karte auf hellen Bildern lesbarer.',
|
||||||
|
'kiosks_accent' => 'Akzentfarbe',
|
||||||
|
'kiosks_accent_default' => 'Standardfarbe verwenden',
|
||||||
|
'kiosks_accent_hint' => 'Färbt den Knopf auf dieser Seite. Leer = Farbe aus dem Design-Tab.',
|
||||||
|
'kiosks_card_style' => 'Karte',
|
||||||
|
'kiosks_card_light' => 'Hell',
|
||||||
|
'kiosks_card_dark' => 'Dunkel',
|
||||||
|
'kiosks_card_hint' => 'Auf Fotos wirkt die dunkle Karte meist ruhiger.',
|
||||||
'settings_tab_general' => 'Allgemein',
|
'settings_tab_general' => 'Allgemein',
|
||||||
'settings_tab_defaults' => 'Voucher-Standards',
|
'settings_tab_defaults' => 'Voucher-Standards',
|
||||||
'settings_tab_cron' => 'Cron-Sync',
|
'settings_tab_cron' => 'Cron-Sync',
|
||||||
|
|
|
||||||
68
lang/en.php
|
|
@ -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' => '0–90%. Higher values keep the card readable on bright images.',
|
||||||
|
'kiosks_accent' => 'Accent colour',
|
||||||
|
'kiosks_accent_default' => 'Use default colour',
|
||||||
|
'kiosks_accent_hint' => 'Colours the button on this page. Empty = colour from the Design tab.',
|
||||||
|
'kiosks_card_style' => 'Card',
|
||||||
|
'kiosks_card_light' => 'Light',
|
||||||
|
'kiosks_card_dark' => 'Dark',
|
||||||
|
'kiosks_card_hint' => 'On photos the dark card usually looks calmer.',
|
||||||
'settings_tab_general' => 'General',
|
'settings_tab_general' => 'General',
|
||||||
'settings_tab_defaults' => 'Voucher Defaults',
|
'settings_tab_defaults' => 'Voucher Defaults',
|
||||||
'settings_tab_cron' => 'Cron Sync',
|
'settings_tab_cron' => 'Cron Sync',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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");',
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
```
|
||||||
27
tools/runner/config.example.yml
Normal 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: []
|
||||||
30
tools/runner/docker-compose.yml
Normal 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"
|
||||||
|
|
@ -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),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
29
updater/migrations/0005_kiosk.sql
Normal 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`);
|
||||||
8
updater/migrations/0006_kiosk_branding.sql
Normal 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`;
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||