diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..7e20944
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,15 @@
+# Zeilenenden nicht anfassen – einige Dateien liegen bewusst mit CRLF vor.
+* -text
+
+# Dateien, die nicht ins Release-ZIP gehoeren.
+# `git archive` (siehe .github/workflows/release.yml) wertet export-ignore aus.
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/.dockerignore export-ignore
+/.github export-ignore
+/docs export-ignore
+/tests export-ignore
+/tools export-ignore
+/phpunit.xml.dist export-ignore
+/phpstan.neon export-ignore
+/composer.lock export-ignore
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5aaaad9..e88d61a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,9 +29,40 @@ jobs:
php -l "$f"
done
- - name: Validate JSON language/migration assets
+ - name: Validate language files
run: |
- php -r 'foreach (glob("lang/*.php") as $f) { $a = require $f; if (!is_array($a)) { fwrite(STDERR, "Bad lang file: $f\n"); exit(1);} } echo "lang OK\n";'
+ php -r '
+ $de = require "lang/de.php"; $en = require "lang/en.php";
+ if (!is_array($de) || !is_array($en)) { fwrite(STDERR, "Bad lang file\n"); exit(1); }
+ $missingEn = array_diff(array_keys($de), array_keys($en));
+ $missingDe = array_diff(array_keys($en), array_keys($de));
+ if ($missingEn || $missingDe) {
+ fwrite(STDERR, "Fehlend in en: " . implode(", ", $missingEn) . "\n");
+ fwrite(STDERR, "Fehlend in de: " . implode(", ", $missingDe) . "\n");
+ exit(1);
+ }
+ echo "lang OK (" . count($de) . " Schluessel)\n";'
+
+ - name: Check that every used translation key exists
+ run: |
+ php -r '
+ $de = require "lang/de.php";
+ $missing = [];
+ $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(".", FilesystemIterator::SKIP_DOTS));
+ foreach ($it as $file) {
+ $path = $file->getPathname();
+ if (substr($path, -4) !== ".php") continue;
+ if (strpos($path, "/vendor/") !== false || strpos($path, "/tools/") !== false) continue;
+ preg_match_all("/__\(\s*\x27([a-z0-9_]+)\x27/", file_get_contents($path), $m);
+ foreach ($m[1] as $key) {
+ if (!isset($de[$key]) && substr($key, -1) !== "_") { $missing[$key] = $path; }
+ }
+ }
+ if ($missing) {
+ foreach ($missing as $key => $path) { fwrite(STDERR, "Unbekannter Schluessel $key in $path\n"); }
+ exit(1);
+ }
+ echo "Alle verwendeten Schluessel vorhanden\n";'
test:
name: Unit Tests & Static Analysis
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..3fc3001
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,152 @@
+name: Release-Paket
+
+# Bei jedem Merge nach main entsteht ein installierbares ZIP und landet als
+# Vorab-Release "latest-main" im Repository. Wird ein Tag v* gepusht, wird
+# daraus ein regulaeres Release mit derselben Mechanik.
+on:
+ push:
+ branches: [ main ]
+ tags: [ 'v*' ]
+ workflow_dispatch:
+
+jobs:
+ package:
+ name: ZIP bauen und veröffentlichen
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Version und Dateinamen bestimmen
+ id: meta
+ run: |
+ set -eu
+ VERSION="$(tr -d ' \r\n' < VERSION)"
+ SHORT_SHA="$(git rev-parse --short HEAD)"
+ BUILD_DATE="$(date -u +%Y-%m-%d)"
+
+ if [ "${GITHUB_REF_TYPE:-branch}" = "tag" ]; then
+ TAG="${GITHUB_REF_NAME}"
+ NAME="unifi-voucher-tool-${TAG}"
+ TITLE="Version ${TAG}"
+ PRERELEASE="false"
+ else
+ TAG="latest-main"
+ NAME="unifi-voucher-tool-${VERSION}+${BUILD_DATE}.${SHORT_SHA}"
+ TITLE="Aktueller Stand von main – ${VERSION} (${SHORT_SHA})"
+ PRERELEASE="true"
+ fi
+
+ {
+ echo "version=${VERSION}"
+ echo "short_sha=${SHORT_SHA}"
+ echo "build_date=${BUILD_DATE}"
+ echo "tag=${TAG}"
+ echo "name=${NAME}"
+ echo "title=${TITLE}"
+ echo "prerelease=${PRERELEASE}"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: ZIP erzeugen
+ run: |
+ set -eu
+ mkdir -p dist
+ # git archive wertet die export-ignore-Regeln aus .gitattributes aus,
+ # docs/, tests/, tools/ und CI-Dateien bleiben also draußen.
+ git archive --format=zip -9 \
+ --prefix="unifi-voucher-tool/" \
+ -o "dist/${{ steps.meta.outputs.name }}.zip" HEAD
+ cd dist
+ sha256sum "${{ steps.meta.outputs.name }}.zip" > "${{ steps.meta.outputs.name }}.zip.sha256"
+ ls -lh
+
+ - name: Inhalt kurz prüfen
+ run: |
+ set -eu
+ # Ein paar Dateien muessen enthalten sein, sonst ist das Paket kaputt.
+ for required in \
+ unifi-voucher-tool/index.php \
+ unifi-voucher-tool/install.php \
+ unifi-voucher-tool/database.sql \
+ unifi-voucher-tool/assets/global.css \
+ unifi-voucher-tool/assets/vendor/inter/inter.css \
+ unifi-voucher-tool/includes/Ui.php
+ do
+ if ! unzip -l "dist/${{ steps.meta.outputs.name }}.zip" | grep -q "$required"; then
+ echo "Fehlt im Paket: $required" >&2
+ exit 1
+ fi
+ done
+ echo "Paket vollständig."
+
+ - name: Als Build-Artefakt sichern
+ uses: actions/upload-artifact@v3
+ continue-on-error: true # Artefakt-Speicher ist optional
+ with:
+ name: ${{ steps.meta.outputs.name }}
+ path: dist/*
+ retention-days: 30
+
+ - name: Release anlegen bzw. auffrischen
+ env:
+ # Forgejo stellt den Token automatisch bereit; FORGEJO_TOKEN
+ # (persönlicher Token) dient als Ausweichweg.
+ TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.FORGEJO_TOKEN }}
+ API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
+ TAG: ${{ steps.meta.outputs.tag }}
+ NAME: ${{ steps.meta.outputs.name }}
+ TITLE: ${{ steps.meta.outputs.title }}
+ PRERELEASE: ${{ steps.meta.outputs.prerelease }}
+ VERSION: ${{ steps.meta.outputs.version }}
+ SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
+ BUILD_DATE: ${{ steps.meta.outputs.build_date }}
+ run: |
+ set -eu
+ if [ -z "${TOKEN:-}" ]; then
+ echo "Kein Token vorhanden – Release wird übersprungen." >&2
+ exit 0
+ fi
+ AUTH="Authorization: token ${TOKEN}"
+
+ # Rollendes Vorab-Release durch ein frisches ersetzen, damit der
+ # Download-Link stabil bleibt und auf den aktuellen Stand zeigt.
+ if [ "$TAG" = "latest-main" ]; then
+ OLD_ID="$(curl -sf -H "$AUTH" "${API}/releases/tags/${TAG}" \
+ | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)"
+ if [ -n "${OLD_ID:-}" ]; then
+ curl -sf -X DELETE -H "$AUTH" "${API}/releases/${OLD_ID}" || true
+ curl -sf -X DELETE -H "$AUTH" "${API}/tags/${TAG}" || true
+ fi
+ fi
+
+ # Release-Text bewusst ohne Anfuehrungszeichen und Backslashes,
+ # damit er ohne jq direkt in den JSON-Body passt (\n bleibt literal).
+ BODY="Automatisch gebaut aus Commit ${SHORT_SHA}.\n\n"
+ BODY="${BODY}| | |\n|---|---|\n"
+ BODY="${BODY}| Version | ${VERSION} |\n"
+ BODY="${BODY}| Commit | ${SHORT_SHA} |\n"
+ BODY="${BODY}| Gebaut am | ${BUILD_DATE} |\n\n"
+ BODY="${BODY}**Neuinstallation:** ZIP entpacken, Dateien auf den Webserver legen, install.php aufrufen.\n\n"
+ BODY="${BODY}**Update einer bestehenden Installation:** config.php, uploads/ und updater/storage/ nicht ueberschreiben "
+ BODY="${BODY}- oder gleich den eingebauten Updater unter Administration, System-Update verwenden.\n\n"
+ BODY="${BODY}Pruefsumme: siehe beigelegte .sha256-Datei."
+
+ RELEASE_ID="$(curl -sf -X POST -H "$AUTH" -H 'Content-Type: application/json' \
+ -d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"${TITLE}\",\"body\":\"${BODY}\",\"draft\":false,\"prerelease\":${PRERELEASE}}" \
+ "${API}/releases" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)"
+
+ if [ -z "${RELEASE_ID:-}" ]; then
+ echo "Release konnte nicht angelegt werden." >&2
+ exit 1
+ fi
+
+ for file in "dist/${NAME}.zip" "dist/${NAME}.zip.sha256"; do
+ curl -sf -X POST -H "$AUTH" \
+ -F "attachment=@${file}" \
+ "${API}/releases/${RELEASE_ID}/assets?name=$(basename "$file")" > /dev/null
+ echo "Angehängt: $(basename "$file")"
+ done
+
+ echo "Release ${TITLE} steht bereit: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
diff --git a/.htaccess b/.htaccess
new file mode 100644
index 0000000..2654739
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1,26 @@
+# ---------------------------------------------------------------------------
+# Sicherheits-Header und Zugriffsschutz (Apache)
+# Nginx-Entsprechung siehe Readme.md, Abschnitt "Sicherheit".
+# ---------------------------------------------------------------------------
+
+
+ Header always set X-Content-Type-Options "nosniff"
+ Header always set X-Frame-Options "SAMEORIGIN"
+ Header always set Referrer-Policy "strict-origin-when-cross-origin"
+ Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
+
+ # Alle Frontend-Assets liegen lokal; externe Quellen nur fuer hCaptcha,
+ # falls es in den Einstellungen aktiviert wurde.
+ Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'unsafe-inline' https://*.hcaptcha.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.hcaptcha.com; frame-src https://*.hcaptcha.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'"
+
+
+# Kein Verzeichnislisting
+Options -Indexes
+
+# Dateien, die nie direkt ausgeliefert werden sollen
+
+ Require all denied
+
+
+# Interne Ordner schuetzen sich ueber eigene .htaccess-Dateien
+# (funktioniert auch bei Installation in einem Unterverzeichnis).
diff --git a/Dockerfile b/Dockerfile
index 3d0f28c..9696aa2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,11 +16,14 @@ RUN { \
echo 'post_max_size=8M'; \
} > /usr/local/etc/php/conf.d/zz-voucher.ini
+# .htaccess auswerten (Sicherheits-Header, Schutz des uploads-Ordners)
+RUN sed -ri 's!!\n\tAllowOverride All!g' /etc/apache2/apache2.conf
+
WORKDIR /var/www/html
COPY . /var/www/html
-# Laufzeit-Verzeichnis des Updaters beschreibbar machen
-RUN mkdir -p /var/www/html/updater/storage \
+# Laufzeit-Verzeichnisse beschreibbar machen
+RUN mkdir -p /var/www/html/updater/storage /var/www/html/uploads \
&& chown -R www-data:www-data /var/www/html
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
diff --git a/Readme.md b/Readme.md
index 7019def..46a8a84 100644
--- a/Readme.md
+++ b/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.
+Entwickelt von **[Loheide.eu](https://loheide.eu)**
+




-
-
+
+
+[](https://git.loheide.cloud/friloo/Unifi-Voucher-Tool)
@@ -25,6 +28,8 @@
## ✨ Features
- 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand
+- 🖥️ **Display-Seiten (Kiosk)** – öffentliche Seite je Site, an der Gäste sich mit einem Klick selbst einen Zugang holen
+- 🎨 **Jede Display-Seite eigenständig gestaltbar** – Logo, Hintergrundbild, Akzentfarbe, helle oder dunkle Karte
- 📦 **Bulk-Erstellung** – bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout
- 🧩 **Voucher-Profile/Templates** – vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl
- 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten
@@ -45,6 +50,13 @@
- 💾 **Config-Backup & -Restore** (JSON Export/Import)
- 🐳 **Docker** – Dockerfile + docker-compose (MariaDB)
- 🌍 **Öffentlicher Modus** – optional ohne Login nutzbar (mit CSRF-Schutz & Throttle)
+- 🎨 **Einheitliches Design-System** – ein Stylesheet für Frontend, Login und Backend (Tokens, Komponenten, Light/Dark)
+- 🏷️ **Login-Seite individualisierbar** – Firmenname, Logo, Texte, Hintergrundbild bzw. Farbverlauf
+- 🖌️ **Eigene Markenfarben** – Akzentfarbe, Verlauf und Eckenradius wirken auf die gesamte Oberfläche
+- ⬆️ **Bild-Upload** für Logo, Favicon und Login-Hintergrund (kein externes Hosting nötig)
+- 🔒 **Keine externen CDNs** – Schrift, Icons, Diagramme und Editor werden lokal ausgeliefert (DSGVO, Offline-Netze)
+- ♿ **Barrierearm** – Kontraste nach WCAG AA, Sprungmarke, aria-Beschriftungen, `prefers-reduced-motion`
+- 📱 **Mobil nutzbar** – Tabellen werden auf schmalen Geräten zu Karten
- 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert
- 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`)
- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay
@@ -59,31 +71,52 @@
## 📸 Screenshots
+> Alle Screenshots stammen aus der Oberfläche in Version 2.5.0 (neues Design-System).
+
### Anmeldung & Voucher-Erstellung
-### Bulk-Erstellung & Dark Mode
-
-### Administration & Updater
+### Display-Seite für Gäste
+
+
+

+

+
+
+
+

+

+
+
+
+

+

+
+
+### Administration
+
+
+

+
### REST-API, 2FA & Integrationen
@@ -97,6 +130,17 @@
+### Updater & Wartungsmodus
+
+
+

+

+
+
+
+

+
+
---
## 📋 Anforderungen
@@ -113,8 +157,8 @@
## 🚀 Installation
```bash
-git clone https://github.com/friloo/unifi-voucher-tool.git
-cd unifi-voucher-tool
+git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git
+cd Unifi-Voucher-Tool
```
1. Dateien auf den Webserver hochladen
@@ -184,6 +228,64 @@ Während eines Updates wird die Anwendung kurz in den **Wartungsmodus** versetzt
---
+## 🖥️ Display-Seiten für Gäste
+
+Für Empfang, Lobby oder Tagungsraum lässt sich je Site eine **öffentliche Seite**
+anlegen, die auf einem Bildschirm oder Tablet läuft. Gäste tippen auf einen
+Knopf und bekommen sofort einen eigenen Zugangscode – ohne Anmeldung, ohne
+Personal am Tresen.
+
+**Anlegen:** Administration → **Display-Seiten** → *Display-Seite anlegen*
+
+
+

+
+
+| Einstellung | Wirkung |
+|---|---|
+| Site | für welchen Standort die Codes erzeugt werden |
+| Voucher-Profil | Laufzeit, Geräteanzahl und Bandbreite der Codes (leer = Standardwerte) |
+| Überschrift / Text | was auf dem Bildschirm steht |
+| Codes pro Tag | Obergrenze je Kalendertag (0 = unbegrenzt) |
+| Wartezeit | Abstand zwischen zwei Codes an diesem Display |
+| Anzeigedauer | danach springt der Bildschirm automatisch zurück |
+
+Jede Seite hat einen **eigenen, geheimen Link** (`kiosk.php?k=…`). Er lässt sich
+kopieren, als QR-Code anzeigen (praktisch, um ihn am Tablet zu öffnen) und
+jederzeit erneuern – der alte Link ist dann sofort ungültig. Den Link nicht
+öffentlich verbreiten: wer ihn hat, kann im Rahmen der Limits Codes ziehen.
+
+Auf dem Startbildschirm steht zusätzlich ein QR-Code, der auf dieselbe Seite
+zeigt. Gäste können sie damit **am eigenen Handy** öffnen – praktisch bei
+Bildschirmen ohne Touch.
+
+### Jede Seite eigenständig gestalten
+
+Jede Display-Seite bringt ihr eigenes Erscheinungsbild mit – das Hotel am
+Empfang sieht anders aus als der Tagungsraum nebenan:
+
+| Einstellung | Wirkung |
+|---|---|
+| Logo | eigenes Logo auf der Karte (leer = Logo aus den Einstellungen) |
+| Hintergrundbild | formatfüllend hinter der Karte, z. B. ein Foto des Hauses |
+| Abdunklung | 0–90 % dunkle Ebene über dem Bild, damit die Karte lesbar bleibt |
+| Akzentfarbe | färbt den Knopf dieser Seite (leer = Farbe aus dem Design-Tab) |
+| Karte | hell oder dunkel – auf Fotos wirkt die dunkle Karte meist ruhiger |
+
+Logo und Hintergrund lassen sich direkt hochladen (PNG, JPG, WEBP, GIF, SVG bis
+3 MB) oder als URL hinterlegen; beim Löschen einer Display-Seite verschwinden
+die hochgeladenen Dateien mit.
+
+Die ausgegebenen Codes erscheinen normal in *Live Vouchers*, im *Reporting* und
+im *Audit-Log* (Aktion „Voucher am Display geholt"), sodass jederzeit
+nachvollziehbar bleibt, woher ein Zugang stammt.
+
+> Display-Seiten funktionieren unabhängig vom globalen öffentlichen Modus – der
+> geheime Link ist der Zugang. Webhook-Benachrichtigungen werden für diese Codes
+> bewusst **nicht** ausgelöst, sonst wäre der Slack-Kanal voll.
+
+---
+
## ⚙️ Konfiguration
### `config.php`
@@ -220,6 +322,80 @@ Den Token finden Sie unter **Administration → Einstellungen → Cron**.
---
+## 🎨 Design-System
+
+Frontend, Login-Seiten, Installer, Updater und der gesamte Admin-Bereich nutzen ein
+gemeinsames Stylesheet: **`assets/global.css`**.
+
+- **Design-Tokens** (`:root` bzw. `[data-theme="dark"]`) für Flächen, Text, Linien,
+ Markenfarbe, Statusfarben, Radien, Schatten und Layout-Maße
+- **Komponenten** darauf aufgebaut: Buttons, Formularfelder, Cards, Tabellen, Badges,
+ Alerts, Tabs, Pagination, Modals, Toasts, Statistik-Kacheln, Sidebar/Topbar
+- **Dark Mode** ausschließlich über Tokens – keine `!important`-Overrides mehr
+- **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback
+
+### Markenfarben ohne Code
+
+Unter **Administration → Einstellungen → Design** lassen sich Akzentfarbe
+(hell und dunkel), Markenverlauf und Eckenradius setzen. Abgeleitete Töne –
+Hover, weiche Flächen, Rahmen, Fokusring – berechnet das System per `color-mix`
+aus der Grundfarbe; eine Farbe genügt also. Eine Live-Vorschau zeigt Button,
+Badge, Chip und Logo-Kachel sofort im neuen Ton.
+
+Die Werte landen als schlanker `:root`-Override im Seitenkopf und gelten überall,
+auch auf Login-Seite, Installer und Updater. Wer lieber in CSS arbeitet, kann
+dieselben Variablen weiterhin in `assets/global.css` überschreiben:
+
+```css
+:root {
+ --accent: #0f766e; /* Primärfarbe (Buttons, aktive Navigation) */
+ --brand-gradient: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%);
+ --r-lg: 14px; /* Eckenradius für Cards */
+}
+```
+
+### Bilder hochladen
+
+Logo, Favicon, Login-Logo und Login-Hintergrund lassen sich direkt hochladen –
+alternativ bleibt das URL-Feld bestehen. Die Dateien landen unter `uploads/`
+(Docker: eigenes Volume, siehe unten). Erlaubt sind PNG, JPG, WEBP, GIF und SVG
+bis 3 MB; SVGs werden vor dem Speichern von Skripten und externen Verweisen
+befreit, und im Upload-Ordner sperrt eine `.htaccess` die PHP-Ausführung.
+
+### Assets ohne Drittanbieter
+
+Schrift (Inter), Icons (Font Awesome), Diagramme (Chart.js), QR-Codes und der
+WYSIWYG-Editor (TinyMCE) liegen unter `assets/vendor/` und kommen vom eigenen
+Server. Das hält Besucher-IPs bei Ihnen – und die Oberfläche funktioniert auch
+dort, wo das Netz keinen Weg nach außen hat. Details und Aktualisierungs-Hinweise:
+[`assets/vendor/README.md`](assets/vendor/README.md).
+
+Alle Asset-URLs tragen einen Versionsstempel (`?v=…`), damit Browser nach einem
+Update nicht die alten Dateien aus dem Cache verwenden.
+
+### Login-Seite individualisieren
+
+Unter **Administration → Einstellungen → Login-Seite** lässt sich die Anmeldeseite
+ohne Code-Änderung an das eigene Haus anpassen. Leere Felder verwenden jeweils den
+Standardwert – eine frische Installation sieht also unverändert aus.
+
+| Einstellung | Wirkung |
+|---|---|
+| Linke Bildspalte anzeigen | Split-Screen an/aus. Aus = zentrierte Anmeldekarte |
+| Firmenname | Name neben dem Logo bzw. in der Fußzeile (leer = Anwendungstitel) |
+| Logo (URL) | Eigenes Logo in der Bildspalte (leer = allgemeines Logo) |
+| Überschrift / Beschreibungstext | Claim in der Bildspalte |
+| Stichpunkte | Liste mit Haken – ein Stichpunkt pro Zeile, leer = keine Liste |
+| Fußzeile | z. B. `© 2026 Muster GmbH · Datenschutz · Impressum` |
+| Hintergrundbild (URL) | Formatfüllendes Bild der Bildspalte |
+| Verlauf Start-/Endfarbe | Farbverlauf, wenn kein Bild gesetzt ist |
+| Abdunklung (%) | Dunkle Ebene über dem Bild, damit der Text lesbar bleibt |
+
+Über **„Vorschau öffnen"** lässt sich die Login-Seite als angemeldeter Administrator
+ansehen (`login.php?preview=1`), ohne sich abzumelden.
+
+---
+
## 🛡️ Sicherheit
Das Tool ist auf einen sicheren Standardbetrieb ausgelegt:
@@ -235,13 +411,30 @@ Das Tool ist auf einen sicheren Standardbetrieb ausgelegt:
| **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout |
| **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) |
-Empfohlene zusätzliche Härtung am Server:
+Mitgeliefert wird eine `.htaccess` im Projektstamm mit Sicherheits-Headern
+(`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
+`Permissions-Policy` und einer Content-Security-Policy). Da alle Assets lokal
+liegen, erlaubt die CSP nur noch die eigene Herkunft – externe Verbindungen
+bleiben lediglich für hCaptcha offen, falls es aktiviert wird. Ordner wie
+`includes/`, `tools/`, `tests/` und `uploads/` schützen sich über eigene
+`.htaccess`-Dateien.
-```apache
-# .htaccess – sensible Dateien sperren (wird vom Installer erzeugt)
-
- Require all denied
-
+> **Apache:** `AllowOverride All` muss für das Verzeichnis gesetzt sein, sonst
+> werden die `.htaccess`-Dateien ignoriert. Das mitgelieferte Docker-Image
+> erledigt das bereits.
+
+Für **Nginx** entspricht das:
+
+```nginx
+add_header X-Content-Type-Options "nosniff" always;
+add_header X-Frame-Options "SAMEORIGIN" always;
+add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'" always;
+
+location ~ ^/(includes|tools|tests)/ { deny all; }
+location ~ ^/updater/(storage|migrations)/ { deny all; }
+location ~ ^/(config\.php|database\.sql)$ { deny all; }
+location ^~ /uploads/ { location ~ \.php$ { deny all; } }
```
```sql
@@ -364,6 +557,108 @@ Das Schema wird beim ersten Start automatisch in MariaDB geladen; danach den
Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV
setzen (`DB_*`, `APP_KEY`).
+Hochgeladene Logos und Hintergründe liegen im Volume `uploads` und überstehen
+damit ein Image-Update. Bei eigener Apache-/Nginx-Installation muss `uploads/`
+für den Webserver beschreibbar sein:
+
+```bash
+chown -R www-data:www-data uploads updater/storage
+```
+
+---
+
+## 🧪 Entwicklung
+
+Für Screenshots und einen schnellen Durchlauf aller Seiten gibt es eine
+Demo-Instanz **ohne Datenbank** – `Database` und `Auth` werden durch Stubs mit
+festen Beispieldaten ersetzt:
+
+```bash
+python3 tools/demo/build.py /tmp/uvt-demo
+php -S 127.0.0.1:8123 -t /tmp/uvt-demo &
+
+# Bilder in docs/screenshots neu erzeugen (benötigt headless Chromium)
+CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py
+```
+
+Details und die verfügbaren Demo-Zustände: [`tools/README.md`](tools/README.md).
+
+Tests und statische Analyse:
+
+```bash
+composer install
+vendor/bin/phpunit # 29 Tests (Crypto, TOTP, API-Keys, Upload, Ui)
+vendor/bin/phpstan analyse # Level 5
+```
+
+Die Versionsnummer steht in der Datei **`VERSION`** im Projektstamm. Sie wird
+im Admin-Bereich unten in der Seitenleiste angezeigt und benennt das
+Release-Paket – für eine neue Version also dort (und im Badge oben) anheben.
+
+Die Pipeline (`.github/workflows/ci.yml`) führt zusätzlich einen
+Syntax-Check über alle PHP-Dateien aus und prüft, ob `lang/de.php` und
+`lang/en.php` dieselben Schlüssel enthalten und jeder im Code verwendete
+Schlüssel existiert. Dieselben Schritte lassen sich lokal ausführen.
+
+---
+
+## 📦 Repository & Mitwirken
+
+Der Quellcode liegt auf der eigenen Forgejo-Instanz – **nicht** auf GitHub:
+
+****
+
+```bash
+# HTTPS
+git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git
+
+# SSH (Port 2222)
+git clone ssh://git@git.loheide.cloud:2222/friloo/Unifi-Voucher-Tool.git
+```
+
+### Fertige Pakete
+
+Jeder Merge nach `main` erzeugt automatisch ein installierbares ZIP
+(`.github/workflows/release.yml`) und hängt es an das rollende Vorab-Release
+**`latest-main`**:
+
+****
+
+Das Paket enthält nur die Laufzeit-Dateien – `docs/`, `tests/`, `tools/` und die
+CI-Konfiguration bleiben draußen (rund 1,4 MB). Wird ein Tag `v*` gepusht,
+entsteht daraus ein reguläres Release mit derselben Mechanik.
+
+> Beim **Update einer bestehenden Installation** `config.php`, `uploads/` und
+> `updater/storage/` nicht überschreiben – oder gleich den eingebauten Updater
+> verwenden, der genau diese Pfade schützt.
+
+Voraussetzung ist ein registrierter **Forgejo-Actions-Runner**; ohne Runner
+bleiben die Workflows in der Warteschlange stehen. Compose-Datei und Anleitung
+dafür liegen in [`tools/runner/`](tools/runner/README.md).
+
+### Mitwirken
+
+Fehlerberichte und Änderungsvorschläge laufen über die **Issues** und **Pull
+Requests** dort. Für die Kommandozeile eignet sich [`tea`](https://gitea.com/gitea/tea),
+die Gitea-/Forgejo-CLI:
+
+```bash
+tea pr create # Pull Request öffnen
+tea issues ls # offene Tickets ansehen
+```
+
+> Die Workflows unter `.github/workflows/` werden von Forgejo Actions
+> mitgelesen; das Badge oben ist bewusst statisch, solange kein Runner
+> registriert ist. `docker-publish.yml` veröffentlicht nach `ghcr.io` und
+> stammt noch aus der GitHub-Zeit – für den Forgejo-Betrieb entweder auf die
+> eigene Registry umstellen oder entfernen.
+
+Der **Auto-Updater** ist davon unabhängig: er zieht seine Pakete über
+`update.loheide.eu` (Channels `stable` und `development`) und nicht direkt aus
+dem Git-Hoster.
+
+---
+
## 🗺️ Roadmap
- [x] Voucher-Templates (vordefinierte Laufzeiten)
@@ -378,11 +673,18 @@ setzen (`DB_*`, `APP_KEY`).
- [x] Docker-Container
- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan)
+- [x] Gemeinsames Design-System für Frontend, Login und Backend
+- [x] Branding über die Oberfläche (Farben, Logo, Login-Seite)
+- [x] Assets lokal ausliefern (keine Drittanbieter-CDNs)
+- [x] Vollständige englische Übersetzung des Admin-Bereichs
+- [x] Display-Seiten: Selbstbedienung für Gäste am Bildschirm
---
-**Version 2.4.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
+**Version 2.8.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
+
+Entwickelt von **[Loheide.eu](https://loheide.eu)**
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..834f262
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+2.8.0
diff --git a/admin/api_keys.php b/admin/api_keys.php
index a2bbbfb..ccc0c24 100644
--- a/admin/api_keys.php
+++ b/admin/api_keys.php
@@ -37,7 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) {
);
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt");
$newKey = $k['plain'];
- $success = 'API-Schlüssel erstellt. Bitte JETZT kopieren – er wird nur einmal angezeigt!';
+ $success = __('api_created_once');
}
}
}
@@ -46,14 +46,14 @@ if (isset($_GET['toggle']) && isset($_GET['token']) && $auth->validateCsrfToken(
$row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]);
if ($row) {
$db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]);
- $success = 'Status aktualisiert.';
+ $success = __('api_status_updated');
}
}
if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) {
$db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]);
$auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht');
- $success = 'API-Schlüssel gelöscht.';
+ $success = __('api_deleted');
}
$keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC");
@@ -66,94 +66,80 @@ $adminBase = '';
-API-Schlüssel – = htmlspecialchars($appTitle) ?>
+= __('api_title') ?> – = htmlspecialchars($appTitle) ?>
-
-
-
-🔑 API-Schlüssel
+
= htmlspecialchars($error) ?>
= htmlspecialchars($success) ?>
-
Neuer Schlüssel
-
Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.
+
= __('api_new_key') ?>
+
= __('api_new_key_hint') ?>
= htmlspecialchars($newKey) ?>
-
Neuen API-Schlüssel erstellen
+
= __('api_create_title') ?>
-
Vorhandene Schlüssel
+
= __('api_existing') ?>
-
Noch keine API-Schlüssel angelegt.
+
= __('api_none') ?>
-
- | Name | Präfix | Scope | Limit | Status | Zuletzt genutzt | Erstellt von | |
+
+
+ | = __('label_name') ?> | = __('api_col_prefix') ?> | = __('api_col_scope') ?> | = __('api_col_limit') ?> | = __('label_status') ?> | = __('api_col_last_used') ?> | = __('api_col_created_by') ?> | |
- | = htmlspecialchars($k['name']) ?> |
- uvt_= htmlspecialchars($k['key_prefix']) ?>… |
- = ($k['scope'] ?? 'write') === 'read' ? 'nur Lesen' : 'Lesen+Erstellen' ?> |
- = (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?> |
- = $k['is_active'] ? 'aktiv' : 'gesperrt' ?> |
- = $k['last_used_at'] ? htmlspecialchars($k['last_used_at']) : '–' ?> |
- = htmlspecialchars($k['creator'] ?? '–') ?> |
+ = htmlspecialchars($k['name']) ?> |
+ uvt_= htmlspecialchars($k['key_prefix']) ?>… |
+ = ($k['scope'] ?? 'write') === 'read' ? __('api_scope_read_short') : __('api_scope_write_short') ?> |
+ = (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?> |
+ = $k['is_active'] ? __('api_state_active') : __('api_state_blocked') ?> |
+ = $k['last_used_at'] ? date('d.m.Y H:i', strtotime($k['last_used_at'])) : '–' ?> |
+ = htmlspecialchars($k['creator'] ?? '–') ?> |
- = $k['is_active'] ? 'Sperren' : 'Aktivieren' ?>
- Löschen
+ = $k['is_active'] ? __('api_action_block') : __('api_action_unblock') ?>
+ = __('btn_delete') ?>
|
+
-
Verwendung
-
Authentifizierung per Header Authorization: Bearer <key> oder X-API-Key: <key>.
+
= __('api_usage') ?>
+
= __('api_usage_hint') ?> Authorization: Bearer <key> oder X-API-Key: <key>.
# Voucher erstellen
curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
-H "Authorization: Bearer uvt_…" \
@@ -162,10 +148,10 @@ curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
# Sites auflisten
curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"
-
OpenAPI-Spezifikation (Import in Postman/Swagger): /api/openapi.php
+
= __('api_openapi') ?> /api/openapi.php
-
+