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/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 4f2709a..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Lint - -on: - push: - pull_request: - -jobs: - php-lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - - name: PHP Syntax-Check (alle Dateien) - run: | - set -e - fail=0 - while IFS= read -r f; do - php -l "$f" > /dev/null || fail=1 - done < <(git ls-files '*.php') - exit $fail - - name: Sprachdateien-Paritaet (de/en) - run: | - php -r ' - $de = require "lang/de.php"; $en = require "lang/en.php"; - $missing = array_merge(array_diff(array_keys($de), array_keys($en)), array_diff(array_keys($en), array_keys($de))); - if ($missing) { fwrite(STDERR, "Fehlende Keys: " . implode(", ", $missing) . "\n"); exit(1); } - echo "OK: " . count($de) . " Keys synchron\n"; - ' 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)** + ![PHP](https://img.shields.io/badge/PHP-7.4%2B-777BB4?logo=php&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-5.7%2B%20%2F%20MariaDB-4479A1?logo=mysql&logoColor=white) ![UniFi OS](https://img.shields.io/badge/UniFi%20OS-7.0%2B-0559C9?logo=ubiquiti&logoColor=white) ![License](https://img.shields.io/badge/Lizenz-MIT-green) -![Version](https://img.shields.io/badge/Version-2.4.0-blueviolet) -![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg) +![Version](https://img.shields.io/badge/Version-2.8.0-blueviolet) +![Tests](https://img.shields.io/badge/Tests-PHPUnit%20%2B%20PHPStan-brightgreen) +[![Repository](https://img.shields.io/badge/Code-git.loheide.cloud-4b3ec4)](https://git.loheide.cloud/friloo/Unifi-Voucher-Tool) @@ -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
- Login mit Microsoft 365 - Voucher erstellen - Voucher-Ergebnis + Anmeldung + Anmeldung mit eigenem Branding
-### Bulk-Erstellung & Dark Mode -
- Bulk-Voucher-Erstellung - Dashboard im Dark Mode + Voucher erstellen + Einstellungen der Login-Seite
-### Administration & Updater +### Display-Seite für Gäste + +
+ Display-Seite im Ruhezustand + Display-Seite mit eigenem Bild und Farben +
+ +
+ Ausgegebener Zugangscode auf dem Display + Display-Seite einrichten +
+ +
+ Voucher-Ergebnis mit QR-Code + Bulk-Voucher-Erstellung +
+ +### Administration
Dashboard - Auto-Updater + Dashboard im Dark Mode
- Updater – Ausgangszustand - Wartungsmodus + Live-Voucher-Verwaltung + Einstellungen +
+ +
+ Markenfarben einstellen + Ansicht auf dem Smartphone
### REST-API, 2FA & Integrationen @@ -97,6 +130,17 @@ Zwei-Faktor-Authentifizierung +### Updater & Wartungsmodus + +
+ Updater – Ausgangszustand + Auto-Updater mit verfügbarem Update +
+ +
+ 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* + +
+ Verwaltung der Display-Seiten +
+ +| Einstellung | Wirkung | +|---|---| +| Site | für welchen Standort die Codes erzeugt werden | +| Voucher-Profil | Laufzeit, Geräteanzahl und Bandbreite der Codes (leer = Standardwerte) | +| Überschrift / Text | was auf dem Bildschirm steht | +| Codes pro Tag | Obergrenze je Kalendertag (0 = unbegrenzt) | +| Wartezeit | Abstand zwischen zwei Codes an diesem Display | +| Anzeigedauer | danach springt der Bildschirm automatisch zurück | + +Jede Seite hat einen **eigenen, geheimen Link** (`kiosk.php?k=…`). Er lässt sich +kopieren, als QR-Code anzeigen (praktisch, um ihn am Tablet zu öffnen) und +jederzeit erneuern – der alte Link ist dann sofort ungültig. Den Link nicht +öffentlich verbreiten: wer ihn hat, kann im Rahmen der Limits Codes ziehen. + +Auf dem Startbildschirm steht zusätzlich ein QR-Code, der auf dieselbe Seite +zeigt. Gäste können sie damit **am eigenen Handy** öffnen – praktisch bei +Bildschirmen ohne Touch. + +### Jede Seite eigenständig gestalten + +Jede Display-Seite bringt ihr eigenes Erscheinungsbild mit – das Hotel am +Empfang sieht anders aus als der Tagungsraum nebenan: + +| Einstellung | Wirkung | +|---|---| +| Logo | eigenes Logo auf der Karte (leer = Logo aus den Einstellungen) | +| Hintergrundbild | formatfüllend hinter der Karte, z. B. ein Foto des Hauses | +| Abdunklung | 0–90 % dunkle Ebene über dem Bild, damit die Karte lesbar bleibt | +| Akzentfarbe | färbt den Knopf dieser Seite (leer = Farbe aus dem Design-Tab) | +| Karte | hell oder dunkel – auf Fotos wirkt die dunkle Karte meist ruhiger | + +Logo und Hintergrund lassen sich direkt hochladen (PNG, JPG, WEBP, GIF, SVG bis +3 MB) oder als URL hinterlegen; beim Löschen einer Display-Seite verschwinden +die hochgeladenen Dateien mit. + +Die ausgegebenen Codes erscheinen normal in *Live Vouchers*, im *Reporting* und +im *Audit-Log* (Aktion „Voucher am Display geholt"), sodass jederzeit +nachvollziehbar bleibt, woher ein Zugang stammt. + +> Display-Seiten funktionieren unabhängig vom globalen öffentlichen Modus – der +> geheime Link ist der Zugang. Webhook-Benachrichtigungen werden für diese Codes +> bewusst **nicht** ausgelöst, sonst wäre der Slack-Kanal voll. + +--- + ## ⚙️ Konfiguration ### `config.php` @@ -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

+
-

Neuer Schlüssel

-

Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.

+

+

-

Neuen API-Schlüssel erstellen

+

- - + +
- +
- - + +
- +
-

Vorhandene Schlüssel

+

-

Noch keine API-Schlüssel angelegt.

+

- - +
+
NamePräfixScopeLimitStatusZuletzt genutztErstellt von
+ - - - - - - - + + + + + + +
uvt_uvt_ - - Löschen + +
+
-

Verwendung

-

Authentifizierung per Header Authorization: Bearer <key> oder X-API-Key: <key>.

+

+

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.php

- + diff --git a/admin/audit_log.php b/admin/audit_log.php index 1cfb77e..8edff87 100644 --- a/admin/audit_log.php +++ b/admin/audit_log.php @@ -45,24 +45,15 @@ $users = $db->fetchAll("SELECT id, name FROM users WHERE is_active = 1 ORDER BY $currentPage = 'audit_log'; $adminBase = ''; -// WICHTIG: Die Keys muessen den tatsaechlich via writeAuditLog() geschriebenen -// Action-Namen entsprechen (user_create, site_edit, ...), sonst erscheinen -// die Eintraege als rohe Keys. -$actionLabels = [ - 'voucher_create' => '🎫 Voucher erstellt', - 'voucher_bulk' => '🎫 Bulk Voucher', - 'user_login' => '🔐 Login', - 'user_create' => '👤 Benutzer erstellt', - 'user_edit' => '👤 Benutzer geändert', - 'user_delete' => '👤 Benutzer gelöscht', - 'site_create' => '🌐 Site hinzugefügt', - 'site_edit' => '🌐 Site geändert', - 'site_delete' => '🌐 Site gelöscht', - 'password_reset' => '🔑 Passwort-Reset', - 'update_installed' => '🔄 Update installiert', - 'update_failed' => '🔄 Update fehlgeschlagen', - 'migrations_run' => '🗄️ Migrationen ausgeführt', -]; +// Aktionsnamen uebersetzt anzeigen; unbekannte Aktionen bleiben technisch. +$actionLabels = []; +foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created', + 'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted', + 'settings_saved', 'password_reset', 'template_created', 'template_updated', + 'template_deleted', 'voucher_kiosk', 'kiosk_created', 'kiosk_updated', + 'kiosk_deleted'] as $action) { + $actionLabels[$action] = __('audit_action_' . $action); +} ?> @@ -71,46 +62,17 @@ $actionLabels = [ <?= __('audit_title') ?> - <?= htmlspecialchars($appTitle) ?> - -
-
+
@@ -127,7 +89,7 @@ $actionLabels = [
- - Zurücksetzen + + Zurücksetzen
@@ -144,14 +106,15 @@ $actionLabels = [
- + Einträge
-

+

- +
+
@@ -165,54 +128,55 @@ $actionLabels = [ - - - - - - +
+
+ +
- System/Anonym +
+ : - +
+
1): ?> @@ -221,7 +185,7 @@ $actionLabels = [
- + diff --git a/admin/backup.php b/admin/backup.php index 0faf942..bc4bfa6 100644 --- a/admin/backup.php +++ b/admin/backup.php @@ -43,12 +43,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { $error = __('error_csrf'); } elseif (empty($_FILES['backup']['tmp_name'])) { - $error = 'Bitte eine Backup-Datei auswählen.'; + $error = __('backup_choose_file'); } else { $raw = file_get_contents($_FILES['backup']['tmp_name']); $data = json_decode($raw, true); if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') { - $error = 'Ungültige oder fremde Backup-Datei.'; + $error = __('backup_invalid_file'); } else { $importSites = isset($_POST['import_sites']); $importTemplates = isset($_POST['import_templates']); @@ -95,7 +95,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) { } } $auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert'); - $success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile."; + $success = str_replace(['{settings}', '{sites}', '{templates}'], + [(string)$counts['settings'], (string)$counts['sites'], (string)$counts['templates']], + __('backup_imported')); } catch (Exception $e) { $error = 'Import-Fehler: ' . $e->getMessage(); } @@ -112,48 +114,38 @@ $adminBase = ''; -Backup & Restore – <?= htmlspecialchars($appTitle) ?> +<?= __('backup_title') ?> – <?= htmlspecialchars($appTitle) ?> - - - -

💾 Backup & Restore

+
-

Export

-

Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem APP_KEY dieser Installation verschlüsselt – ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.

- Konfiguration exportieren +

+

APP_KEY

+
-

Import / Restore

-

Vorhandene Sites werden anhand von Name + Site-ID aktualisiert, neue hinzugefügt. Profile werden nur angelegt, wenn der Name noch nicht existiert. Der Cron-Token wird nie überschrieben.

+

+


- - - - + + + +
- + diff --git a/admin/import.php b/admin/import.php index dfb9725..d32b357 100644 --- a/admin/import.php +++ b/admin/import.php @@ -74,7 +74,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['do_import'])) { Notifier::voucherCreated($created, $site['name'], $_SESSION['user_name'] ?? null); $auth->writeAuditLog($_SESSION['user_id'], 'voucher_import', 'site', $siteId, "$created Voucher importiert"); } - $success = "$created Voucher erstellt."; + $success = str_replace('{count}', (string)$created, __('import_created')); } catch (Exception $e) { $error = $e->getMessage(); } @@ -93,48 +93,38 @@ $adminBase = ''; CSV-Import – <?= htmlspecialchars($appTitle) ?> - - - -

📥 Voucher-Import (CSV)

+
-

Mehrere Voucher erstellen

-

Eine Zeile pro Voucher: Name,MaxGeräte,Minuten – MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:
+

+

Name,MaxGeräte,Minuten
Gast Müller,1,480 · Konferenzraum A,5,240 · Tagespass

- + - + - + - +
-

Ergebnis

- +

+
NameCode / FehlerStatus
@@ -142,7 +132,7 @@ code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; borde - + diff --git a/admin/index.php b/admin/index.php index 2613073..4f3ee40 100644 --- a/admin/index.php +++ b/admin/index.php @@ -6,6 +6,7 @@ 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/Ui.php'; require_once __DIR__ . '/../includes/UniFiController.php'; require_once __DIR__ . '/../includes/I18n.php'; @@ -26,12 +27,9 @@ if (isset($_GET['ajax_stats'])) { $syncErrors = []; if ($syncFirst) { - // Mehrere Sites werden sequentiell synchronisiert (je bis zu ~15s - // bei Timeout) – PHP-Default von 30s reicht dann nicht. - @set_time_limit(30 + count($sites) * 20); foreach ($sites as $site) { try { - $ctrl = new UniFiController($site['unifi_controller_url'], $site['unifi_username'], Crypto::decrypt($site['unifi_password']), $site['site_id'], $site['ssl_verify'] ?? 0); + $ctrl = new UniFiController($site['unifi_controller_url'], $site['unifi_username'], Crypto::decrypt($site['unifi_password']), $site['site_id']); $ctrl->syncVouchersToDatabase($db, $site['id']); } catch (Exception $e) { $syncErrors[$site['id']] = $e->getMessage(); @@ -93,78 +91,8 @@ $currentPage = 'dashboard'; <?= __('dashboard_title') ?> – <?= htmlspecialchars($appTitle) ?> - + - - - -
- - -
- @@ -186,43 +114,43 @@ $currentPage = 'dashboard';
-
+
-
+
-
🟢
-
+
+
-
🟡
-
+
+
-
🔴
-
+
+
-
📊
-
+
+
@@ -230,16 +158,16 @@ $currentPage = 'dashboard';
-

🔴

+

-
+
0,'valid'=>0,'used'=>0,'expired'=>0]; ?>
-
+
@@ -254,10 +182,10 @@ $currentPage = 'dashboard';
-
- +
+

- +
@@ -265,18 +193,18 @@ $currentPage = 'dashboard';
-

📊

+

-
-
-

🏆

-
+
+
+

+
-

+

    @@ -296,21 +224,22 @@ $currentPage = 'dashboard';
-
-

📋

Live
+
+
-

+

-
+
+
- - - - + + + @@ -318,17 +247,26 @@ $currentPage = 'dashboard';
+
+
- + -
+
diff --git a/admin/integrations.php b/admin/integrations.php index 6f6d1bd..cfc7c83 100644 --- a/admin/integrations.php +++ b/admin/integrations.php @@ -49,13 +49,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) { $db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0))); $db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30))); $auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert'); - $success = 'Einstellungen gespeichert.'; + $success = __('settings_saved'); } } if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) { Notifier::send('✅ Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']); - $success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).'; + $success = __('int_webhook_test_sent'); } $enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1'; @@ -92,24 +92,14 @@ $adminBase = ''; -Integration & Wartung – <?= htmlspecialchars($appTitle) ?> +<?= __('int_title') ?> – <?= htmlspecialchars($appTitle) ?> - - - -

🔧 Integration & Wartung

+
@@ -118,64 +108,64 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
-

Sicherheitsrichtlinie

-

Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.

- - +

+

+ + - - + + - - + +
-
+
-

Reverse-Proxy

-

IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus X-Forwarded-For für Rate-Limit & Audit verwendet.

+

+

X-Forwarded-For

-

Webhook-Benachrichtigungen

-

Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.

- - +

+

+ +
-

SMS-Versand (Twilio)

-

Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.

- +

+

+
-
-
+
+
-

Single Sign-On (OpenID Connect)

-

Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI:

- +

+

+
-
+
-
+
@@ -184,21 +174,21 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
-

Datenhaltung & Cleanup (DSGVO)

-

Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per cron_cleanup.php (täglich empfohlen). -
Letzter Lauf: +

+

cron_cleanup.php +

-
-
-
+
+
+
- + - + diff --git a/admin/kiosks.php b/admin/kiosks.php new file mode 100644 index 0000000..50019af --- /dev/null +++ b/admin/kiosks.php @@ -0,0 +1,500 @@ +requireAdmin(); +I18n::init(); + +$db = Database::getInstance(); +$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); + +$error = ''; +$success = ''; + +/** Formularwerte einsammeln – für Anlegen und Bearbeiten identisch. */ +function kioskInput(): array +{ + return [ + 'site_id' => (int)($_POST['site_id'] ?? 0), + 'template_id' => (int)($_POST['template_id'] ?? 0) ?: null, + 'name' => trim((string)($_POST['name'] ?? '')), + 'headline' => trim((string)($_POST['headline'] ?? '')), + 'subline' => trim((string)($_POST['subline'] ?? '')), + 'daily_limit' => max(0, (int)($_POST['daily_limit'] ?? Kiosk::DEFAULT_DAILY_LIMIT)), + 'cooldown_seconds' => max(0, min(3600, (int)($_POST['cooldown_seconds'] ?? Kiosk::DEFAULT_COOLDOWN))), + 'display_seconds' => max(10, min(600, (int)($_POST['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS))), + 'is_active' => isset($_POST['is_active']) ? 1 : 0, + 'bg_overlay' => max(0, min(90, (int)($_POST['bg_overlay'] ?? 45))), + 'accent_color' => self_accent($_POST['accent_color'] ?? ''), + 'card_style' => ($_POST['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light', + ]; +} + +/** Nur echte Hex-Farben durchlassen – der Wert landet in einem style-Attribut. */ +function self_accent($value): ?string +{ + $value = strtolower(trim((string)$value)); + + return preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : null; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_kiosk'])) { + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $error = __('error_csrf'); + } else { + try { + $in = kioskInput(); + if ($in['name'] === '') throw new Exception(__('error_name_req')); + if ($in['site_id'] <= 0) throw new Exception(__('error_site_req')); + + $logo = Upload::resolveField('logo_url', '', 'image'); + $bg = Upload::resolveField('background_url', '', 'image'); + + $db->execute( + "INSERT INTO kiosks (site_id, template_id, name, token, headline, subline, + logo_url, background_url, bg_overlay, accent_color, card_style, + daily_limit, cooldown_seconds, display_seconds, is_active, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)", + [$in['site_id'], $in['template_id'], $in['name'], Kiosk::newToken(), + $in['headline'], $in['subline'], $logo, $bg, $in['bg_overlay'], + $in['accent_color'], $in['card_style'], $in['daily_limit'], + $in['cooldown_seconds'], $in['display_seconds'], $_SESSION['user_id']] + ); + $auth->writeAuditLog($_SESSION['user_id'], 'kiosk_created', 'kiosk', null, $in['name']); + $success = __('kiosks_added'); + } catch (Exception $e) { + $error = $e->getMessage(); + } + } +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_kiosk'])) { + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $error = __('error_csrf'); + } else { + try { + $id = (int)($_POST['kiosk_id'] ?? 0); + $in = kioskInput(); + if ($in['name'] === '') throw new Exception(__('error_name_req')); + if ($in['site_id'] <= 0) throw new Exception(__('error_site_req')); + + $current = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [$id]) ?: []; + $logo = Upload::resolveField('logo_url', (string)($current['logo_url'] ?? ''), 'image'); + $bg = Upload::resolveField('background_url', (string)($current['background_url'] ?? ''), 'image'); + + $db->execute( + "UPDATE kiosks SET site_id=?, template_id=?, name=?, headline=?, subline=?, + logo_url=?, background_url=?, bg_overlay=?, accent_color=?, card_style=?, + daily_limit=?, cooldown_seconds=?, display_seconds=?, is_active=? + WHERE id=?", + [$in['site_id'], $in['template_id'], $in['name'], $in['headline'], $in['subline'], + $logo, $bg, $in['bg_overlay'], $in['accent_color'], $in['card_style'], + $in['daily_limit'], $in['cooldown_seconds'], $in['display_seconds'], $in['is_active'], $id] + ); + $auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, $in['name']); + $success = __('kiosks_updated'); + } catch (Exception $e) { + $error = $e->getMessage(); + } + } +} + +// Neuen Link erzeugen – der alte gilt damit sofort nicht mehr. +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['renew_token'])) { + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $error = __('error_csrf'); + } else { + $id = (int)($_POST['kiosk_id'] ?? 0); + $db->execute("UPDATE kiosks SET token = ? WHERE id = ?", [Kiosk::newToken(), $id]); + $auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, 'Link erneuert'); + $success = __('kiosks_token_renewed'); + } +} + +if (isset($_GET['delete'], $_GET['token'])) { + if ($auth->validateCsrfToken($_GET['token'])) { + $old = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [(int)$_GET['delete']]); + if ($old) { + Upload::delete((string)($old['logo_url'] ?? '')); + Upload::delete((string)($old['background_url'] ?? '')); + } + $db->execute("DELETE FROM kiosks WHERE id = ?", [(int)$_GET['delete']]); + $auth->writeAuditLog($_SESSION['user_id'], 'kiosk_deleted', 'kiosk', (int)$_GET['delete'], ''); + $success = __('kiosks_deleted'); + } else { + $error = __('error_csrf'); + } +} + +$sites = $db->fetchAll("SELECT id, name FROM sites WHERE is_active = 1 ORDER BY name"); +$templates = $db->fetchAll("SELECT id, name, max_uses, expire_minutes FROM voucher_templates WHERE is_active = 1 ORDER BY name"); +$kiosks = $db->fetchAll( + "SELECT k.*, s.name AS site_name, t.name AS template_name, + (SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id) AS total_vouchers, + (SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id AND DATE(v.created_at) = CURDATE()) AS today_vouchers + FROM kiosks k + INNER JOIN sites s ON s.id = k.site_id + LEFT JOIN voucher_templates t ON t.id = k.template_id + ORDER BY k.is_active DESC, k.name" +); + +$csrf = $auth->getCsrfToken(); +$currentPage = 'kiosks'; +$adminBase = ''; +?> + + + + + +<?= __('kiosks_title') ?> – <?= htmlspecialchars($appTitle) ?> + + + + + +
+
+ + +
+
+

+ + + +
+ +
+
+

+ +
+ +
+ + +
+
+
+
+
+
+ + + +
+ +
+
+ + +
+
+ + 0 ? ' / ' . (int)$k['daily_limit'] : '' ?> + +
+
+ + +
+
+ + + + +
+ + + + + +
+ + + +
+ + + +
+
+ +
+ + + + + + + + + + +
+ + + + diff --git a/admin/reports.php b/admin/reports.php index e43b056..7b2aebf 100644 --- a/admin/reports.php +++ b/admin/reports.php @@ -6,6 +6,7 @@ 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/Ui.php'; require_once __DIR__ . '/../includes/I18n.php'; $auth = new Auth(); @@ -95,56 +96,46 @@ $adminBase = ''; -Reporting – <?= htmlspecialchars($appTitle) ?> - +<?= __('rep_title') ?> – <?= htmlspecialchars($appTitle) ?> + - - - -

📊 Reporting

+
- +
- ⬇️ CSV (täglich) - ⬇️ CSV (pro Site) - ⬇️ CSV (pro Nutzer) - + + + +
-
Vouchers gesamt
-
Gültig
-
Verwendet
-
In Tagen erstellt
+
+
+
+
-

Erstellte Voucher ( Tage)

+

-

Pro Site

- +

+
SiteGesamtGültigVerwendetAbgelaufen
@@ -152,22 +143,32 @@ select.input { padding:9px; border:2px solid var(--border-color); border-radius:
-

Top-Nutzer

-
+

+
BenutzerVoucher erstellt
- +
Keine Daten
- + diff --git a/admin/security.php b/admin/security.php index b1934d0..3de454c 100644 --- a/admin/security.php +++ b/admin/security.php @@ -6,6 +6,10 @@ 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'; + +I18n::init(); $auth = new Auth(); $auth->requireLogin(); @@ -24,20 +28,20 @@ $setupRequired = isset($_GET['setup_required']); // 2FA aktivieren (Code bestaetigen) if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $error = 'Ungültiges Sicherheits-Token'; + $error = __('sec_token_invalid'); } else { $secret = $_SESSION['totp_setup_secret'] ?? ''; $code = trim($_POST['code'] ?? ''); if ($secret === '') { - $error = 'Setup abgelaufen, bitte erneut starten.'; + $error = __('sec_setup_expired'); } elseif (!Totp::verify($secret, $code)) { - $error = 'Code ungültig. Bitte erneut versuchen.'; + $error = __('sec_code_invalid'); } else { $backupCodes = $auth->enableTotp($user['id'], $secret); unset($_SESSION['totp_setup_secret']); $totpEnabled = true; $user = $auth->getCurrentUser(); - $success = 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!'; + $success = __('sec_enabled'); } } } @@ -45,32 +49,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) { // Überall abmelden (andere Sessions beenden) if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout_others'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $error = 'Ungültiges Sicherheits-Token'; + $error = __('sec_token_invalid'); } else { $auth->logoutOtherSessions(); - $success = 'Alle anderen Sitzungen wurden beendet.'; + $success = __('sec_sessions_closed'); } } // Recovery-Codes neu erzeugen if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $error = 'Ungültiges Sicherheits-Token'; + $error = __('sec_token_invalid'); } elseif (!empty($user['totp_enabled'])) { $backupCodes = $auth->regenerateBackupCodes($user['id']); $user = $auth->getCurrentUser(); - $success = 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.'; + $success = __('sec_codes_new'); } } // 2FA deaktivieren if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $error = 'Ungültiges Sicherheits-Token'; + $error = __('sec_token_invalid'); } else { $auth->disableTotp($user['id']); $totpEnabled = false; - $success = 'Zwei-Faktor-Authentifizierung wurde deaktiviert.'; + $success = __('sec_disabled'); } } @@ -87,54 +91,36 @@ $dbSessions = $db->getSetting('session_driver', 'php') === 'db'; $activeSessions = $dbSessions ? $auth->activeSessionCount() : 0; ?> - + -Zwei-Faktor-Authentifizierung – <?= htmlspecialchars($appTitle) ?> +<?= __('sec_title') ?> – <?= htmlspecialchars($appTitle) ?> - + - + - -
-

🔐 Zwei-Faktor-Authentifizierung

-

Konto:

+ +
+
+ +
+

+

+
+
-
Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.
+
- 🔑 Recovery-Codes -

Bewahren Sie diese sicher auf. Jeder Code funktioniert einmal, falls Sie keinen Zugriff auf Ihre App haben.

+ +

@@ -142,53 +128,54 @@ input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-ra -
● Nicht verfügbar
-

Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.

+
+

-
● Aktiv
-

Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.
- Verbleibende Recovery-Codes: backupCodesRemaining($user) ?>

+
+


+ backupCodesRemaining($user) ?>

- +
-
+ - +
-
● Inaktiv
+
    -
  1. Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator …)
  2. -
  3. QR-Code scannen oder Secret manuell eingeben
  4. -
  5. Den angezeigten 6-stelligen Code unten eingeben
  6. +
  7. +
  8. +
- - - + + +
-
-

Aktive Sitzungen:

-
+
+

+ - +
- ← Zurück +
diff --git a/admin/settings.php b/admin/settings.php index 5f37b34..d5763aa 100644 --- a/admin/settings.php +++ b/admin/settings.php @@ -8,7 +8,8 @@ require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Mailer.php'; require_once __DIR__ . '/../includes/I18n.php'; -require_once __DIR__ . '/../includes/Helpers.php'; +require_once __DIR__ . '/../includes/Ui.php'; +require_once __DIR__ . '/../includes/Upload.php'; $auth = new Auth(); $auth->requireAdmin(); @@ -52,13 +53,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if ($formType === 'general') { $settings['app_title'] = trim($_POST['app_title'] ?? ''); - $settings['logo_url'] = trim($_POST['logo_url'] ?? ''); - $settings['favicon_url'] = trim($_POST['favicon_url'] ?? ''); + $settings['logo_url'] = Upload::resolveField('logo_url', (string)$db->getSetting('logo_url', ''), 'image'); + $settings['favicon_url'] = Upload::resolveField('favicon_url', (string)$db->getSetting('favicon_url', ''), 'favicon'); $settings['instruction_header'] = trim($_POST['instruction_header'] ?? ''); $settings['instruction_text'] = $_POST['instruction_text'] ?? ''; $settings['public_access'] = isset($_POST['public_access']) ? '1' : '0'; } + if ($formType === 'branding') { + foreach (['brand_accent', 'brand_accent_dark', 'brand_gradient_from', 'brand_gradient_to'] as $key) { + $value = strtolower(trim($_POST[$key] ?? '')); + $settings[$key] = preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : ''; + } + $radius = (int)($_POST['brand_radius'] ?? Ui::DEFAULT_RADIUS); + $settings['brand_radius'] = (string)max(0, min(28, $radius)); + } + + if ($formType === 'login') { + $settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0'; + $settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? ''); + $settings['login_logo_url'] = Upload::resolveField('login_logo_url', (string)$db->getSetting('login_logo_url', ''), 'image'); + $settings['login_claim_title'] = trim($_POST['login_claim_title'] ?? ''); + $settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? ''); + $settings['login_features'] = trim($_POST['login_features'] ?? ''); + $settings['login_footer'] = trim($_POST['login_footer'] ?? ''); + $settings['login_bg_image'] = Upload::resolveField('login_bg_image', (string)$db->getSetting('login_bg_image', ''), 'image'); + $settings['login_bg_from'] = trim($_POST['login_bg_from'] ?? ''); + $settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? ''); + $overlay = (int)($_POST['login_bg_overlay'] ?? 40); + $settings['login_bg_overlay'] = (string)max(0, min(90, $overlay)); + } + if ($formType === 'defaults') { $expMin = (int)($_POST['default_expire_minutes'] ?? 480); $defDev = (int)($_POST['default_max_uses'] ?? 1); @@ -73,11 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if ($formType === 'm365') { $settings['m365_client_id'] = trim($_POST['m365_client_id'] ?? ''); - // Secret nur aktualisieren, wenn eines eingegeben wurde – es wird - // (wie das SMTP-Passwort) nicht mehr ins Formular zurueckgegeben. - if (!empty($_POST['m365_client_secret'])) { - $settings['m365_client_secret'] = trim($_POST['m365_client_secret']); - } + $settings['m365_client_secret'] = trim($_POST['m365_client_secret'] ?? ''); $settings['m365_tenant_id'] = trim($_POST['m365_tenant_id'] ?? ''); } @@ -90,7 +111,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { $settings['smtp_password'] = trim($_POST['smtp_password']); } $settings['smtp_encryption'] = trim($_POST['smtp_encryption'] ?? 'tls'); - $settings['smtp_verify_ssl'] = isset($_POST['smtp_verify_ssl']) ? '1' : '0'; $settings['smtp_from_email'] = trim($_POST['smtp_from_email'] ?? ''); $settings['smtp_from_name'] = trim($_POST['smtp_from_name'] ?? ''); } @@ -104,7 +124,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { } if ($formType === 'system') { - $settings['tinymce_api_key'] = trim($_POST['tinymce_api_key'] ?? ''); $settings['print_template'] = $_POST['print_template'] ?? ''; } @@ -112,15 +131,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { $db->setSetting($key, $value); } - // PRG + Tab-Anker: F5 speichert nicht erneut, und der Nutzer landet - // wieder auf dem Tab, in dem er gespeichert hat. - $tabAnchors = [ - 'general' => 'general', 'defaults' => 'defaults', 'm365' => 'm365', - 'smtp' => 'smtp', 'templates' => 'templates_email', 'system' => 'system', - ]; - flashSet(__('settings_saved')); - header('Location: settings.php#' . ($tabAnchors[$formType] ?? 'general')); - exit; + $success = __('settings_saved'); + } catch (RuntimeException $e) { + $error = $e->getMessage(); } catch (Exception $e) { $error = 'Fehler: ' . $e->getMessage(); } @@ -133,9 +146,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['generate_cron_token'] $error = __('error_csrf'); } else { $db->setSetting('cron_token', bin2hex(random_bytes(32))); - flashSet(__('cron_token_generated')); - header('Location: settings.php#cron'); - exit; + $success = 'Neuer Cron-Token wurde generiert!'; } } if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token'])) { @@ -143,9 +154,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token'])) $error = __('error_csrf'); } else { $db->setSetting('cron_token', ''); - flashSet(__('cron_token_deleted')); - header('Location: settings.php#cron'); - exit; + $success = 'Cron-Token wurde gelöscht!'; } } @@ -157,29 +166,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['change_password'])) { try { $user = $auth->getCurrentUser(); if (!password_verify($_POST['current_password'], $user['password_hash'])) { - throw new Exception(__('error_pw_current')); + throw new Exception('Aktuelles Passwort ist falsch'); } if (strlen($_POST['new_password']) < 8) { throw new Exception(__('settings_pw_minlength')); } if ($_POST['new_password'] !== $_POST['confirm_password']) { - throw new Exception(__('error_pw_mismatch')); + throw new Exception('Passwörter stimmen nicht überein'); } $db->query("UPDATE users SET password_hash = ? WHERE id = ?", [password_hash($_POST['new_password'], PASSWORD_DEFAULT), $user['id']]); - flashSet(__('settings_pw_changed')); - header('Location: settings.php#password'); - exit; + $success = __('settings_pw_changed'); } catch (Exception $e) { $error = $e->getMessage(); } } } -if (empty($success) && empty($error) && ($flash = flashGet())) { - $success = $flash['message']; -} - $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST']; $scriptPath = dirname($_SERVER['SCRIPT_NAME'], 2); @@ -205,16 +208,30 @@ $cs = [ 'smtp_username' => $db->getSetting('smtp_username', ''), 'smtp_password' => $db->getSetting('smtp_password', ''), 'smtp_encryption' => $db->getSetting('smtp_encryption', 'tls'), - 'smtp_verify_ssl' => $db->getSetting('smtp_verify_ssl', '0'), 'smtp_from_email' => $db->getSetting('smtp_from_email', ''), 'smtp_from_name' => $db->getSetting('smtp_from_name', ''), 'system_url' => $db->getSetting('system_url', $autoDetectedUrl), 'email_voucher_subject' => $db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'), - 'email_voucher_body' => $db->getSetting('email_voucher_body', "Hallo,\n\nIhr Code: {VOUCHER_CODE}\n\nGültigkeit: 8h\nGeräte: {MAX_USES}\nSite: {SITE_NAME}"), + 'email_voucher_body' => $db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\nMaximale Geräte: {MAX_USES}
\nStandort: {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}"), 'email_user_notification_subject' => $db->getSetting('email_user_notification_subject', '{APP_TITLE} - Berechtigungen geändert'), 'email_user_notification_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"), - 'tinymce_api_key' => $db->getSetting('tinymce_api_key', ''), - 'print_template' => $db->getSetting('print_template', '

{APP_TITLE}

WLAN Code

{VOUCHER_CODE}

Gültig bis: {EXPIRY_DATE} {EXPIRY_TIME}

Site: {SITE_NAME}

Geräte: {MAX_USES}


{INSTRUCTIONS}
'), + 'print_template' => $db->getSetting('print_template', Ui::defaultPrintTemplate()), + 'brand_accent' => $db->getSetting('brand_accent', '') ?: Ui::DEFAULT_ACCENT, + 'brand_accent_dark' => $db->getSetting('brand_accent_dark', '') ?: Ui::DEFAULT_ACCENT_DARK, + 'brand_gradient_from' => $db->getSetting('brand_gradient_from', '') ?: Ui::DEFAULT_GRADIENT_FROM, + 'brand_gradient_to' => $db->getSetting('brand_gradient_to', '') ?: Ui::DEFAULT_GRADIENT_TO, + 'brand_radius' => $db->getSetting('brand_radius', (string)Ui::DEFAULT_RADIUS), + 'login_panel_enabled' => $db->getSetting('login_panel_enabled', '1'), + 'login_brand_name' => $db->getSetting('login_brand_name', ''), + 'login_logo_url' => $db->getSetting('login_logo_url', ''), + 'login_claim_title' => $db->getSetting('login_claim_title', ''), + 'login_claim_text' => $db->getSetting('login_claim_text', ''), + 'login_features' => $db->getSetting('login_features', ''), + 'login_footer' => $db->getSetting('login_footer', ''), + 'login_bg_image' => $db->getSetting('login_bg_image', ''), + 'login_bg_from' => $db->getSetting('login_bg_from', '#3b2f8f'), + 'login_bg_to' => $db->getSetting('login_bg_to', '#6d5ce7'), + 'login_bg_overlay' => $db->getSetting('login_bg_overlay', '40'), 'cron_token' => $db->getSetting('cron_token', ''), 'last_cron_sync' => $db->getSetting('last_cron_sync', ''), ]; @@ -229,98 +246,63 @@ $adminBase = ''; <?= __('settings_title') ?> - <?= htmlspecialchars($appTitle) ?> - - - - - + + + - - - -
+
-
+
- - - - - - - - + + + + + + + + + +
-
-

-
+
+

+
-
-
+ +

>
- +
-
-

-

Diese Werte werden als Vorgabe im Voucher-Formular verwendet.

+
+

+

@@ -342,37 +324,184 @@ $adminBase = '';
-

Gültigkeits-Referenz

+

Gültigkeits-Referenz

60 Min = 1 Stunde  |  480 Min = 8 Stunden  |  1440 Min = 1 Tag  |  10080 Min = 1 Woche  |  43200 Min = 30 Tage

- + + +
+ + +
+

+

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

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

+

+
+ + + +
+ > + +
+ +
+
+ + +
+
+ +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+
+ +
+ + + + +
-
-

+
+

-

Was macht der Cron-Job?

+

Der Cron-Job synchronisiert automatisch alle Voucher von Ihren UniFi Controllern in die lokale Datenbank.


-

Kein Token konfiguriert.

+

Kein Token konfiguriert.

- +
- -
-
+ +
+
Cron-URL
- +
@@ -393,7 +522,7 @@ $adminBase = '';
- +
@@ -402,24 +531,24 @@ $adminBase = '';
-

Microsoft 365

+

Microsoft 365

-

Azure AD App

+

Azure AD App

Redirect URI: /m365_callback.php

-
+
- +
-
-

SMTP

+
+

SMTP

@@ -429,63 +558,62 @@ $adminBase = '';
-
>
-
+
-
+
- +

SMTP testen

- +
-
-

E-Mail Templates

+
+

E-Mail Templates

Auto:

-

Voucher E-Mail

-

Platzhalter:

{VOUCHER_CODE}{SITE_NAME}{MAX_USES}{APP_TITLE}{INSTRUCTIONS}
+

+

{VOUCHER_CARD}{VOUCHER_CODE}{SITE_NAME}{MAX_USES}{APP_TITLE}{INSTRUCTIONS}


-

Benutzer-Benachrichtigung

-

Platzhalter:

{USER_NAME}{CHANGES}{APP_TITLE}{SYSTEM_URL}
+

+

{USER_NAME}{CHANGES}{APP_TITLE}{SYSTEM_URL}
- +
-
-

System & Erweitert

+
+

System & Erweitert

-

TinyMCE API Key

-

Kostenlosen API Key: tiny.cloud/signup

+

WYSIWYG-Editor

+

assets/vendor/

-
Für WYSIWYG-Editor in Anleitungen
+

Druck-Template

-

Platzhalter:

{VOUCHER_CODE}{EXPIRY_DATE}{EXPIRY_TIME}{SITE_NAME}{MAX_USES}{APP_TITLE}{INSTRUCTIONS}
-
- +

{QR_CODE}{VOUCHER_CODE}{EXPIRY_DATE}{EXPIRY_TIME}{SITE_NAME}{MAX_USES}{APP_TITLE}{INSTRUCTIONS}

+
+

System-Information

@@ -497,19 +625,19 @@ $adminBase = '';
-
-

+
+

- +
-
+ diff --git a/admin/sites.php b/admin/sites.php index becda5e..23b0e14 100644 --- a/admin/sites.php +++ b/admin/sites.php @@ -8,7 +8,6 @@ require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/UniFiController.php'; require_once __DIR__ . '/../includes/I18n.php'; -require_once __DIR__ . '/../includes/Helpers.php'; $auth = new Auth(); $auth->requireAdmin(); @@ -19,32 +18,6 @@ I18n::init(); $error = ''; $success = ''; -// AJAX: Verbindungstest mit gespeicherten Zugangsdaten (Health-Check pro Site) -if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['ajax_test_site'])) { - header('Content-Type: application/json'); - if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - echo json_encode(['success' => false, 'message' => __('error_csrf')]); - exit; - } - $site = $db->fetchOne("SELECT * FROM sites WHERE id=?", [(int)$_POST['ajax_test_site']]); - if (!$site) { - echo json_encode(['success' => false, 'message' => __('error_site_not_found')]); - exit; - } - $test = UniFiController::testConnection( - $site['unifi_controller_url'], - $site['unifi_username'], - Crypto::decrypt($site['unifi_password']), - $site['site_id'], - $site['ssl_verify'] ?? 0 - ); - echo json_encode([ - 'success' => $test === true, - 'message' => $test === true ? __('site_test_ok') : __('site_test_fail') . ': ' . $test, - ]); - exit; -} - // Edit site if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) { if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { @@ -58,27 +31,18 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['edit_site'])) { $username = trim($_POST['username']); $password = $_POST['password']; $publicAccess = isset($_POST['public_access']) ? 1 : 0; - $sslVerify = isset($_POST['ssl_verify']) ? 1 : 0; if (empty($name)||empty($siteIdStr)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all')); if (!empty($password)) { - $test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr,$sslVerify); - if ($test !== true) throw new Exception(__('site_test_fail').': '.$test); - $db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,unifi_password=?,public_access=?,ssl_verify=? WHERE id=?", - [$name,$siteIdStr,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$sslVerify,$siteId]); + $test = UniFiController::testConnection($controllerUrl,$username,$password,$siteIdStr); + if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$test); + $db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,unifi_password=?,public_access=? WHERE id=?", + [$name,$siteIdStr,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$siteId]); } else { - // Auch ohne Passwortaenderung testen (mit gespeichertem Passwort) – - // sonst fallen Tippfehler in URL/Username erst beim naechsten Voucher auf. - $stored = $db->fetchOne("SELECT unifi_password FROM sites WHERE id=?", [$siteId]); - if (!$stored) throw new Exception(__('error_site_not_found')); - $test = UniFiController::testConnection($controllerUrl,$username,Crypto::decrypt($stored['unifi_password']),$siteIdStr,$sslVerify); - if ($test !== true) throw new Exception(__('site_test_fail').': '.$test); - $db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=?,ssl_verify=? WHERE id=?", - [$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$sslVerify,$siteId]); + $db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=? WHERE id=?", + [$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$siteId]); } $auth->writeAuditLog($_SESSION['user_id'],'site_edit','site',$siteId,"Site {$name} aktualisiert"); - flashSet(__('sites_updated')); - header('Location: sites.php'); - exit; + $success = __('sites_updated'); } catch (Exception $e) { $error = $e->getMessage(); } } } @@ -95,49 +59,38 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['add_site'])) { $username = trim($_POST['username']); $password = $_POST['password']; $publicAccess = isset($_POST['public_access']) ? 1 : 0; - $sslVerify = isset($_POST['ssl_verify']) ? 1 : 0; if (empty($name)||empty($siteId)||empty($controllerUrl)||empty($username)) throw new Exception(__('error_fill_all')); - $test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId,$sslVerify); - if ($test !== true) throw new Exception(__('site_test_fail').': '.$test); - $newId = $db->execute("INSERT INTO sites (name,site_id,unifi_controller_url,unifi_username,unifi_password,public_access,ssl_verify) VALUES (?,?,?,?,?,?,?)", - [$name,$siteId,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess,$sslVerify]); + $test = UniFiController::testConnection($controllerUrl,$username,$password,$siteId); + if ($test !== true) throw new Exception('Verbindung fehlgeschlagen: '.$test); + $newId = $db->execute("INSERT INTO sites (name,site_id,unifi_controller_url,unifi_username,unifi_password,public_access) VALUES (?,?,?,?,?,?)", + [$name,$siteId,$controllerUrl,$username,Crypto::encrypt($password),$publicAccess]); $auth->writeAuditLog($_SESSION['user_id'],'site_create','site',$newId,"Site {$name} erstellt"); - flashSet(__('sites_added')); - header('Location: sites.php'); - exit; + $success = __('sites_added'); } catch (Exception $e) { $error = $e->getMessage(); } } } -// Delete site (POST + PRG) -if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['delete_site'])) { - if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $delId = (int)$_POST['delete_site']; +// Delete site +if (isset($_GET['delete']) && isset($_GET['token'])) { + if ($auth->validateCsrfToken($_GET['token'])) { + $delId = (int)$_GET['delete']; $db->query("DELETE FROM sites WHERE id=?", [$delId]); $auth->writeAuditLog($_SESSION['user_id'],'site_delete','site',$delId,'Site gelöscht'); - flashSet(__('sites_deleted')); - header('Location: sites.php'); - exit; + $success = __('sites_deleted'); } else { $error = __('error_csrf'); } } -// Toggle site (POST + PRG) -if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_site'])) { - if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_POST['toggle_site']]); +// Toggle site +if (isset($_GET['toggle']) && isset($_GET['token'])) { + if ($auth->validateCsrfToken($_GET['token'])) { + $site = $db->fetchOne("SELECT is_active FROM sites WHERE id=?", [(int)$_GET['toggle']]); if ($site) { - $db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_POST['toggle_site']]); - flashSet(__('sites_status_updated')); - header('Location: sites.php'); - exit; + $db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_GET['toggle']]); + $success = 'Site-Status aktualisiert!'; } } else { $error = __('error_csrf'); } } -if (empty($success) && empty($error) && ($flash = flashGet())) { - $success = $flash['message']; -} - $sites = $db->fetchAll("SELECT * FROM sites ORDER BY name"); $currentPage = 'sites'; ?> @@ -148,61 +101,24 @@ $currentPage = 'sites'; <?= __('sites_title') ?> – <?= htmlspecialchars($appTitle) ?> - -
+
-
+
- +

@@ -216,62 +132,51 @@ $currentPage = 'sites';
- + - + - +
- +
- +
- +
- -
- - - -
- -
- - - -
+ + + + + + +
-
+
- +
@@ -367,14 +267,9 @@ $currentPage = 'sites'; -
- - -
-
@@ -383,13 +278,13 @@ $currentPage = 'sites'; -
+
diff --git a/admin/templates.php b/admin/templates.php index 7fae89d..292675d 100644 --- a/admin/templates.php +++ b/admin/templates.php @@ -7,7 +7,6 @@ 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/Helpers.php'; $auth = new Auth(); $auth->requireAdmin(); @@ -42,9 +41,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_template'])) { "INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']] ); - flashSet(__('templates_added')); - header('Location: templates.php'); - exit; + $success = __('templates_added'); } catch (Exception $e) { $error = $e->getMessage(); } @@ -74,31 +71,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) { "UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, qos_rate_max_down=?, qos_rate_max_up=?, qos_usage_quota=?, is_active=? WHERE id=?", [$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id] ); - flashSet(__('templates_updated')); - header('Location: templates.php'); - exit; + $success = __('templates_updated'); } catch (Exception $e) { $error = $e->getMessage(); } } } -// Profil löschen (POST + PRG) -if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_template'])) { - if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { - $db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_POST['delete_template']]); - flashSet(__('templates_deleted')); - header('Location: templates.php'); - exit; +// Profil löschen +if (isset($_GET['delete']) && isset($_GET['token'])) { + if ($auth->validateCsrfToken($_GET['token'])) { + $db->execute("DELETE FROM voucher_templates WHERE id = ?", [(int)$_GET['delete']]); + $success = __('templates_deleted'); } else { $error = __('error_csrf'); } } -if (empty($success) && empty($error) && ($flash = flashGet())) { - $success = $flash['message']; -} - $templates = $db->fetchAll("SELECT t.*, u.name as creator FROM voucher_templates t LEFT JOIN users u ON t.created_by = u.id ORDER BY t.is_active DESC, t.name"); $currentPage = 'templates'; @@ -111,56 +100,22 @@ $adminBase = ''; <?= __('templates_title') ?> - <?= htmlspecialchars($appTitle) ?> - - -
+
-
+
@@ -170,14 +125,15 @@ $adminBase = '';
- +

- +
- +
+
@@ -191,11 +147,11 @@ $adminBase = ''; - - + - - - + -
- + + + = 1440 && $m % 1440 === 0) { @@ -206,44 +162,39 @@ $adminBase = ''; $durLabel = $m . ' Min.'; } ?> - + + + -
- - - -
+ class="btn btn-secondary btn-small"> +
+
- + -
+
- + - -
+ +
- +

@@ -149,11 +110,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- + - +
diff --git a/includes/.htaccess b/includes/.htaccess new file mode 100644 index 0000000..2ac81c4 --- /dev/null +++ b/includes/.htaccess @@ -0,0 +1,2 @@ +# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert. +Require all denied diff --git a/includes/Auth.php b/includes/Auth.php index c8705c6..4caab35 100644 --- a/includes/Auth.php +++ b/includes/Auth.php @@ -5,9 +5,7 @@ require_once __DIR__ . '/Crypto.php'; class Auth { private $db; - /** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */ - private $sessionUser = false; - + public function __construct() { try { $this->db = Database::getInstance(); @@ -20,9 +18,6 @@ class Auth { ini_set('session.cookie_httponly', 1); ini_set('session.use_strict_mode', 1); ini_set('session.cookie_samesite', 'Lax'); - if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') { - ini_set('session.cookie_secure', 1); - } // Opt-in: Sessions in der DB ablegen (für "überall abmelden" / Skalierung) try { @@ -248,8 +243,6 @@ class Auth { private function recordLoginAttempt($ip, $email) { try { - // Alte Eintraege aufraeumen, damit die Tabelle nicht unbegrenzt waechst - $this->db->query("DELETE FROM login_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 1 DAY)"); $this->db->query( "INSERT INTO login_attempts (ip_address, email) VALUES (?, ?)", [$ip, $email] @@ -316,12 +309,6 @@ class Auth { // Session setzen private function setUserSession($user) { - // Session-ID nach erfolgreichem Login rotieren (verhindert Session-Fixation) - if (session_status() === PHP_SESSION_ACTIVE) { - session_regenerate_id(true); - } - - $this->sessionUser = false; // User-Cache invalidieren $_SESSION['user_id'] = $user['id']; $_SESSION['user_email'] = $user['email']; $_SESSION['user_name'] = $user['name']; @@ -344,7 +331,6 @@ class Auth { // Ausloggen public function logout() { - $this->sessionUser = null; $_SESSION = []; if (isset($_COOKIE[session_name()])) { @@ -354,25 +340,6 @@ class Auth { session_destroy(); } - /** - * Laedt den Session-User einmal pro Request aus der DB. Dadurch wirken - * Rechteaenderungen (Admin entzogen, Konto deaktiviert/geloescht) sofort - * und nicht erst nach Ablauf der Session. - */ - private function loadSessionUser() { - if ($this->sessionUser === false) { - $this->sessionUser = null; - if (isset($_SESSION['user_id'])) { - $user = $this->db->fetchOne( - "SELECT * FROM users WHERE id = ? AND is_active = 1", - [$_SESSION['user_id']] - ); - $this->sessionUser = $user ?: null; - } - } - return $this->sessionUser; - } - // Prüfen ob eingeloggt public function isLoggedIn() { if (!isset($_SESSION['user_id']) || !isset($_SESSION['login_time'])) { @@ -387,32 +354,24 @@ class Auth { return false; } - // Deaktivierte/geloeschte Konten sofort aussperren - if ($this->loadSessionUser() === null) { - $this->logout(); - return false; - } - return true; } - - // Prüfen ob Admin (live aus der DB, nicht aus dem Session-Cache) + + // Prüfen ob Admin public function isAdmin() { - if (!$this->isLoggedIn()) { - return false; - } - $user = $this->loadSessionUser(); - $isAdmin = $user !== null && (bool)$user['is_admin']; - $_SESSION['is_admin'] = $isAdmin; - return $isAdmin; + return $this->isLoggedIn() && isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true; } - + // Aktuellen Benutzer abrufen public function getCurrentUser() { if (!$this->isLoggedIn()) { return null; } - return $this->loadSessionUser(); + + return $this->db->fetchOne( + "SELECT * FROM users WHERE id = ?", + [$_SESSION['user_id']] + ); } // Prüfen ob Benutzer Zugriff auf Site hat diff --git a/includes/Crypto.php b/includes/Crypto.php index 61e74ff..ae42b4b 100644 --- a/includes/Crypto.php +++ b/includes/Crypto.php @@ -114,9 +114,4 @@ class Crypto { public static function isEncrypted($value) { return is_string($value) && strpos($value, self::PREFIX) === 0; } - - /** Prueft, ob ein gueltiger APP_KEY konfiguriert ist (fuer Admin-Warnhinweis). */ - public static function hasKey() { - return self::key() !== null; - } } diff --git a/includes/Helpers.php b/includes/Helpers.php deleted file mode 100644 index 4b1e8a7..0000000 --- a/includes/Helpers.php +++ /dev/null @@ -1,53 +0,0 @@ - $type, 'message' => $message]; -} - -/** @return array|null ['type' => ..., 'message' => ...] oder null */ -function flashGet() { - $flash = $_SESSION['flash'] ?? null; - unset($_SESSION['flash']); - return $flash; -} - -/** - * Zaehlt eine Aktion fuer die aktuelle IP und prueft das Limit. - * - * @param Database $db - * @param string $action Logischer Name, z.B. 'voucher_create' - * @param int $maxWeight Erlaubte Summe im Zeitfenster - * @param int $windowMinutes Zeitfenster in Minuten - * @param int $weight Gewicht dieser Anfrage (z.B. Bulk-Anzahl) - * @return bool|null true = limitiert, false = erlaubt (und gezaehlt), - * null = Tabelle fehlt (Aufrufer entscheidet ueber Fallback) - */ -function throttleHit($db, $action, $maxWeight, $windowMinutes, $weight = 1) { - $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; - try { - $db->query("DELETE FROM request_throttle WHERE requested_at < DATE_SUB(NOW(), INTERVAL 1 DAY)"); - $row = $db->fetchOne( - "SELECT COALESCE(SUM(weight), 0) AS cnt FROM request_throttle - WHERE action = ? AND ip_address = ? - AND requested_at > DATE_SUB(NOW(), INTERVAL " . (int)$windowMinutes . " MINUTE)", - [$action, $ip] - ); - if ((int)$row['cnt'] + $weight > $maxWeight) { - return true; - } - $db->query( - "INSERT INTO request_throttle (ip_address, action, weight) VALUES (?, ?, ?)", - [$ip, $action, $weight] - ); - return false; - } catch (Exception $e) { - // Tabelle existiert noch nicht (Migration 0002 nicht gelaufen) - return null; - } -} diff --git a/includes/Kiosk.php b/includes/Kiosk.php new file mode 100644 index 0000000..ef25a56 --- /dev/null +++ b/includes/Kiosk.php @@ -0,0 +1,169 @@ +fetchOne( + "SELECT k.*, s.name AS site_name, s.is_active AS site_active, + t.name AS template_name, t.max_uses AS tpl_max_uses, t.expire_minutes AS tpl_expire_minutes, + t.qos_rate_max_down, t.qos_rate_max_up, t.qos_usage_quota + FROM kiosks k + INNER JOIN sites s ON s.id = k.site_id + LEFT JOIN voucher_templates t ON t.id = k.template_id + WHERE k.token = ? AND k.is_active = 1", + [$token] + ); + + if (!$row || (int)$row['site_active'] !== 1) { + return null; + } + + return $row; + } + + /** Wie viele Codes hat dieser Kiosk heute schon ausgegeben? */ + public static function usedToday($db, int $kioskId): int + { + $row = $db->fetchOne( + "SELECT COUNT(*) AS c FROM vouchers WHERE kiosk_id = ? AND DATE(created_at) = CURDATE()", + [$kioskId] + ); + + return (int)($row['c'] ?? 0); + } + + /** + * Darf gerade ein Code geholt werden? + * + * @return array{allowed:bool,reason:string,wait:int} + * reason: '' | 'cooldown' | 'daily_limit' + */ + public static function checkLimits($db, array $kiosk): array + { + $cooldown = max(0, (int)$kiosk['cooldown_seconds']); + if ($cooldown > 0 && !empty($kiosk['last_used_at'])) { + $elapsed = time() - strtotime((string)$kiosk['last_used_at']); + if ($elapsed >= 0 && $elapsed < $cooldown) { + return ['allowed' => false, 'reason' => 'cooldown', 'wait' => $cooldown - $elapsed]; + } + } + + $limit = max(0, (int)$kiosk['daily_limit']); + if ($limit > 0 && self::usedToday($db, (int)$kiosk['id']) >= $limit) { + return ['allowed' => false, 'reason' => 'daily_limit', 'wait' => 0]; + } + + return ['allowed' => true, 'reason' => '', 'wait' => 0]; + } + + /** Nach erfolgreicher Ausgabe den Zeitstempel fortschreiben. */ + public static function markUsed($db, int $kioskId): void + { + $db->execute("UPDATE kiosks SET last_used_at = NOW() WHERE id = ?", [$kioskId]); + } + + /** + * Voucher-Eckdaten eines Kiosks: entweder aus dem verknüpften Profil + * oder aus den globalen Standardwerten. + */ + public static function voucherSettings($db, array $kiosk): array + { + $maxUses = (int)($kiosk['tpl_max_uses'] ?? 0); + $expire = (int)($kiosk['tpl_expire_minutes'] ?? 0); + + if ($maxUses < 1) { + $maxUses = max(1, (int)$db->getSetting('default_max_uses', 1)); + } + if ($expire < 1) { + $expire = max(1, (int)$db->getSetting('default_expire_minutes', 480)); + } + + return [ + 'max_uses' => $maxUses, + 'expire_minutes' => $expire, + 'qos' => [ + 'down' => max(0, (int)($kiosk['qos_rate_max_down'] ?? 0)), + 'up' => max(0, (int)($kiosk['qos_rate_max_up'] ?? 0)), + 'quota_mb' => max(0, (int)($kiosk['qos_usage_quota'] ?? 0)), + ], + ]; + } + + /** + * Gestaltung einer Display-Seite: eigene Werte, sonst die des Systems. + * + * @return array{logo:string,background:string,overlay:float,accent:string,card:string} + */ + public static function appearance($db, array $kiosk): array + { + $accent = strtolower(trim((string)($kiosk['accent_color'] ?? ''))); + if (!preg_match('/^#[0-9a-f]{6}$/', $accent)) { + $accent = ''; + } + + $overlay = (int)($kiosk['bg_overlay'] ?? 45); + $overlay = max(0, min(90, $overlay)); + + $logo = trim((string)($kiosk['logo_url'] ?? '')); + if ($logo === '' && $db) { + $logo = (string)$db->getSetting('logo_url', ''); + } + + return [ + 'logo' => $logo, + 'background' => trim((string)($kiosk['background_url'] ?? '')), + 'overlay' => (float)$overlay / 100, // immer float, auch bei 0 + 'accent' => $accent, + 'card' => ($kiosk['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light', + ]; + } + + /** Öffentliche Adresse eines Kiosks. */ + public static function publicUrl(string $token, string $baseUrl = ''): string + { + if ($baseUrl === '') { + $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $path = dirname($_SERVER['SCRIPT_NAME'] ?? '/', 2); + $path = $path === '/' || $path === '\\' ? '' : $path; + $baseUrl = $protocol . '://' . $host . $path; + } + + return rtrim($baseUrl, '/') . '/kiosk.php?k=' . $token; + } +} diff --git a/includes/Mailer.php b/includes/Mailer.php index 7fea381..7f5d1ad 100644 --- a/includes/Mailer.php +++ b/includes/Mailer.php @@ -1,275 +1,339 @@ -db = Database::getInstance(); - $this->loadSettings(); - } - - private function loadSettings() { - $this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1'; - $this->smtpHost = $this->db->getSetting('smtp_host', ''); - $this->smtpPort = (int)$this->db->getSetting('smtp_port', '587'); - $this->smtpUsername = $this->db->getSetting('smtp_username', ''); - $this->smtpPassword = $this->db->getSetting('smtp_password', ''); - $this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls'); - $this->smtpVerifySsl = $this->db->getSetting('smtp_verify_ssl', '0') === '1'; - $this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost')); - $this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System')); - } - - public function sendRaw($to, $subject, $plainBody) { - return $this->send($to, $subject, $plainBody, false); - } - - public function send($to, $subject, $body, $isHtml = false) { - // Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry). - $attempts = 2; - for ($i = 1; $i <= $attempts; $i++) { - if (!$this->smtpEnabled || empty($this->smtpHost)) { - $ok = $this->sendWithPhpMail($to, $subject, $body); - } else { - $ok = $this->sendWithSmtp($to, $subject, $body, $isHtml); - } - if ($ok) { - return true; - } - if ($i < $attempts) { - usleep(500000); // 0,5s vor erneutem Versuch - } - } - error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen."); - return false; - } - - private function sendWithPhpMail($to, $subject, $body) { - $headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n"; - $headers .= "Reply-To: {$this->fromEmail}\r\n"; - $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; - - return mail($to, $subject, $body, $headers); - } - - private function sendWithSmtp($to, $subject, $body, $isHtml = false) { - try { - // Hostname auch im CLI-Kontext (Cron) verfuegbar - $heloHost = $_SERVER['HTTP_HOST'] ?? (gethostname() ?: 'localhost'); - - // Verbindung aufbauen - $socket = $this->connectToSmtp(); - - // EHLO - $this->smtpCommand($socket, "EHLO " . $heloHost); - - // STARTTLS wenn nötig - if ($this->smtpEncryption === 'tls') { - $this->smtpCommand($socket, "STARTTLS"); - stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); - $this->smtpCommand($socket, "EHLO " . $heloHost); - } - - // AUTH LOGIN – nur wenn Zugangsdaten konfiguriert sind - // (Server ohne Auth lehnen ein leeres AUTH LOGIN sonst ab) - if ($this->smtpUsername !== '') { - $this->smtpCommand($socket, "AUTH LOGIN"); - $this->smtpCommand($socket, base64_encode($this->smtpUsername)); - $this->smtpCommand($socket, base64_encode($this->smtpPassword)); - } - - // MAIL FROM - $this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>"); - - // RCPT TO - $this->smtpCommand($socket, "RCPT TO:<{$to}>"); - - // DATA - $this->smtpCommand($socket, "DATA"); - - // Headers - $message = "From: {$this->fromName} <{$this->fromEmail}>\r\n"; - $message .= "To: {$to}\r\n"; - $message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n"; - $message .= "MIME-Version: 1.0\r\n"; - - if ($isHtml) { - $message .= "Content-Type: text/html; charset=UTF-8\r\n"; - } else { - $message .= "Content-Type: text/plain; charset=UTF-8\r\n"; - } - - $message .= "\r\n"; - - // Zeilenumbrueche auf CRLF normalisieren (der fruehere - // nl2br/str_replace-Umweg hat Umbrueche verdoppelt) - $body = preg_replace("/\r\n|\r|\n/", "\r\n", $body); - // SMTP-Dot-Stuffing: Zeilen, die mit '.' beginnen, wuerden sonst - // die DATA-Phase vorzeitig beenden (RFC 5321, 4.5.2) - $body = preg_replace('/^\./m', '..', $body); - - $message .= $body; - $message .= "\r\n.\r\n"; - - fwrite($socket, $message); - $response = fgets($socket); - - // QUIT - $this->smtpCommand($socket, "QUIT"); - fclose($socket); - - return strpos($response, '250') === 0; - - } catch (Exception $e) { - error_log("SMTP Error: " . $e->getMessage()); - return false; - } - } - - private function connectToSmtp() { - // Zertifikatspruefung optional aktivierbar (Setting smtp_verify_ssl) - $context = stream_context_create([ - 'ssl' => [ - 'verify_peer' => $this->smtpVerifySsl, - 'verify_peer_name' => $this->smtpVerifySsl, - 'allow_self_signed' => !$this->smtpVerifySsl - ] - ]); - - if ($this->smtpEncryption === 'ssl') { - $host = 'ssl://' . $this->smtpHost; - } else { - $host = $this->smtpHost; - } - - $socket = stream_socket_client( - $host . ':' . $this->smtpPort, - $errno, - $errstr, - 30, - STREAM_CLIENT_CONNECT, - $context - ); - - if (!$socket) { - throw new Exception("SMTP Connection failed: $errstr ($errno)"); - } - - // Willkommensnachricht lesen - fgets($socket); - - return $socket; - } - - private function smtpCommand($socket, $command) { - fwrite($socket, $command . "\r\n"); - $response = fgets($socket); - - // Prüfen auf Fehler (4xx oder 5xx) - if (preg_match('/^[45]/', $response)) { - throw new Exception("SMTP Error: $response"); - } - - return $response; - } - - // Vordefinierte E-Mail-Templates - public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) { - $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); - $instructionHeader = $this->db->getSetting('instruction_header', ''); - $instructionText = $this->db->getSetting('instruction_text', ''); - - // System-URL aus Einstellungen oder automatisch erkennen - $systemUrl = $this->db->getSetting('system_url', ''); - if (empty($systemUrl)) { - $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; - $host = $_SERVER['HTTP_HOST']; - $scriptPath = dirname($_SERVER['SCRIPT_NAME']); - $scriptPath = $scriptPath === '/' ? '' : $scriptPath; - $systemUrl = $protocol . '://' . $host . $scriptPath; - } - - // Template aus Datenbank laden - $subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'); - $bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nIhr WLAN-Zugangscode lautet:\n\n{VOUCHER_CODE}\n\nGültigkeit: 8 Stunden ab Erstellung\nMaximale Geräte: {MAX_USES}\nStandort: {SITE_NAME}\n\n{INSTRUCTIONS}\n\nMit freundlichen Grüßen\n{APP_TITLE}"); - - // Anleitung formatieren - $instructions = ''; - if ($instructionText) { - $instructions = $instructionHeader . "\n" . $instructionText; - } - - // Platzhalter ersetzen - $placeholders = [ - '{VOUCHER_CODE}' => $voucherCode, - '{SITE_NAME}' => $siteName, - '{MAX_USES}' => $maxUses, - '{APP_TITLE}' => $appTitle, - '{INSTRUCTIONS}' => $instructions, - '{SYSTEM_URL}' => $systemUrl - ]; - - $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); - $body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate); - - // HTML oder Plain Text prüfen - $isHtml = strip_tags($body) !== $body; - - return $this->send($to, $subject, $body, $isHtml); - } - - public function sendTestEmail($to) { - $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); - $subject = '[Test] E-Mail-Konfiguration – ' . $appTitle; - $body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet."; - return $this->send($to, $subject, $body, false); - } - - public function sendUserNotification($to, $userName, $changes) { - $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); - - // System-URL aus Einstellungen oder automatisch erkennen - $systemUrl = $this->db->getSetting('system_url', ''); - if (empty($systemUrl)) { - $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; - $host = $_SERVER['HTTP_HOST']; - $scriptPath = dirname($_SERVER['SCRIPT_NAME']); - $scriptPath = $scriptPath === '/' ? '' : $scriptPath; - $systemUrl = $protocol . '://' . $host . $scriptPath; - } - - // Template aus Datenbank laden - $subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert'); - $bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}"); - - // Änderungen formatieren - $changesText = ''; - foreach ($changes as $change) { - $changesText .= "• $change\n"; - } - - // Platzhalter ersetzen - $placeholders = [ - '{USER_NAME}' => $userName, - '{CHANGES}' => $changesText, - '{APP_TITLE}' => $appTitle, - '{SYSTEM_URL}' => $systemUrl - ]; - - $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); - $body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate); - - // HTML oder Plain Text prüfen - $isHtml = strip_tags($body) !== $body; - - return $this->send($to, $subject, $body, $isHtml); - } +db = Database::getInstance(); + $this->loadSettings(); + } + + private function loadSettings() { + $this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1'; + $this->smtpHost = $this->db->getSetting('smtp_host', ''); + $this->smtpPort = (int)$this->db->getSetting('smtp_port', '587'); + $this->smtpUsername = $this->db->getSetting('smtp_username', ''); + $this->smtpPassword = $this->db->getSetting('smtp_password', ''); + $this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls'); + $this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']); + $this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System')); + } + + public function sendRaw($to, $subject, $plainBody) { + return $this->send($to, $subject, $plainBody, false); + } + + public function send($to, $subject, $body, $isHtml = false) { + // Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry). + $attempts = 2; + for ($i = 1; $i <= $attempts; $i++) { + if (!$this->smtpEnabled || empty($this->smtpHost)) { + $ok = $this->sendWithPhpMail($to, $subject, $body); + } else { + $ok = $this->sendWithSmtp($to, $subject, $body, $isHtml); + } + if ($ok) { + return true; + } + if ($i < $attempts) { + usleep(500000); // 0,5s vor erneutem Versuch + } + } + error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen."); + return false; + } + + private function sendWithPhpMail($to, $subject, $body) { + $headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n"; + $headers .= "Reply-To: {$this->fromEmail}\r\n"; + $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; + + return mail($to, $subject, $body, $headers); + } + + private function sendWithSmtp($to, $subject, $body, $isHtml = false) { + try { + // Verbindung aufbauen + $socket = $this->connectToSmtp(); + + // EHLO + $this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); + + // STARTTLS wenn nötig + if ($this->smtpEncryption === 'tls') { + $this->smtpCommand($socket, "STARTTLS"); + stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + $this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); + } + + // AUTH LOGIN + $this->smtpCommand($socket, "AUTH LOGIN"); + $this->smtpCommand($socket, base64_encode($this->smtpUsername)); + $this->smtpCommand($socket, base64_encode($this->smtpPassword)); + + // MAIL FROM + $this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>"); + + // RCPT TO + $this->smtpCommand($socket, "RCPT TO:<{$to}>"); + + // DATA + $this->smtpCommand($socket, "DATA"); + + // Headers + $message = "From: {$this->fromName} <{$this->fromEmail}>\r\n"; + $message .= "To: {$to}\r\n"; + $message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n"; + $message .= "MIME-Version: 1.0\r\n"; + + if ($isHtml) { + $message .= "Content-Type: text/html; charset=UTF-8\r\n"; + } else { + $message .= "Content-Type: text/plain; charset=UTF-8\r\n"; + } + + $message .= "\r\n"; + + // Body - bei Plain Text Zeilenumbrüche konvertieren + if (!$isHtml) { + $body = nl2br($body, false); // Für Plain Text + $body = str_replace('
', "\r\n", $body); + } + + $message .= $body; + $message .= "\r\n.\r\n"; + + fwrite($socket, $message); + $response = fgets($socket); + + // QUIT + $this->smtpCommand($socket, "QUIT"); + fclose($socket); + + return strpos($response, '250') === 0; + + } catch (Exception $e) { + error_log("SMTP Error: " . $e->getMessage()); + return false; + } + } + + private function connectToSmtp() { + $context = stream_context_create([ + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true + ] + ]); + + if ($this->smtpEncryption === 'ssl') { + $host = 'ssl://' . $this->smtpHost; + } else { + $host = $this->smtpHost; + } + + $socket = stream_socket_client( + $host . ':' . $this->smtpPort, + $errno, + $errstr, + 30, + STREAM_CLIENT_CONNECT, + $context + ); + + if (!$socket) { + throw new Exception("SMTP Connection failed: $errstr ($errno)"); + } + + // Willkommensnachricht lesen + fgets($socket); + + return $socket; + } + + private function smtpCommand($socket, $command) { + fwrite($socket, $command . "\r\n"); + $response = fgets($socket); + + // Prüfen auf Fehler (4xx oder 5xx) + if (preg_match('/^[45]/', $response)) { + throw new Exception("SMTP Error: $response"); + } + + return $response; + } + + // Vordefinierte E-Mail-Templates + /** + * Legt den Nachrichtentext in ein schlichtes, markentreues HTML-Gerüst. + * Bewusst Tabellen + Inline-Styles: nur so rendern Outlook & Co. zuverlässig. + */ + private function brandedHtml(string $title, string $contentHtml, string $footerNote = ''): string + { + $accent = $this->db->getSetting('brand_gradient_from', '') ?: '#5b5bd6'; + $accent2 = $this->db->getSetting('brand_gradient_to', '') ?: '#8b5cf6'; + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent)) { $accent = '#5b5bd6'; } + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent2)) { $accent2 = '#8b5cf6'; } + + $safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); + $year = date('Y'); + $footer = $footerNote !== '' ? '
' . htmlspecialchars($footerNote, ENT_QUOTES, 'UTF-8') . '
' : ''; + + return '' + . '' + . '' . $safeTitle . '' + . '' + . '' + . '
' + . '' + . '' + . '' + . '' + . '
' + . '
' . $safeTitle . '
' + . '
' . $contentHtml . '
' + . '© ' . $year . ' ' . $safeTitle . $footer + . '
'; + } + + /** + * Voucher-Code als hervorgehobene Karte für die E-Mail. + */ + private function voucherCardHtml(string $code, string $siteName, $maxUses): string + { + return '' + . '
' + . '
' + . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '
' + . '
' + . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '
' + . '
' + . htmlspecialchars((string)$maxUses, ENT_QUOTES, 'UTF-8') . ' ' + . htmlspecialchars(function_exists('__') ? __('label_devices') : 'Geräte', ENT_QUOTES, 'UTF-8') . '
' + . '
'; + } + + public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) { + $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); + $instructionHeader = $this->db->getSetting('instruction_header', ''); + $instructionText = $this->db->getSetting('instruction_text', ''); + + // System-URL aus Einstellungen oder automatisch erkennen + $systemUrl = $this->db->getSetting('system_url', ''); + if (empty($systemUrl)) { + $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST']; + $scriptPath = dirname($_SERVER['SCRIPT_NAME']); + $scriptPath = $scriptPath === '/' ? '' : $scriptPath; + $systemUrl = $protocol . '://' . $host . $scriptPath; + } + + // Template aus Datenbank laden + $subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'); + $bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\nMaximale Geräte: {MAX_USES}
\nStandort: {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}"); + + // Anleitung formatieren + $instructions = ''; + if ($instructionText) { + $instructions = $instructionHeader . "\n" . $instructionText; + } + + // Platzhalter ersetzen + $placeholders = [ + '{VOUCHER_CARD}' => $this->voucherCardHtml($voucherCode, (string)$siteName, $maxUses), + '{VOUCHER_CODE}' => $voucherCode, + '{SITE_NAME}' => $siteName, + '{MAX_USES}' => $maxUses, + '{APP_TITLE}' => $appTitle, + '{INSTRUCTIONS}' => $instructions, + '{SYSTEM_URL}' => $systemUrl + ]; + + $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); + + // Umbrueche der Vorlage vor dem Einsetzen der Platzhalter umwandeln, + // sonst wuerde das Markup der Voucher-Karte die Erkennung stoeren. + $isHtml = strip_tags($bodyTemplate) !== $bodyTemplate || strpos($bodyTemplate, '{VOUCHER_CARD}') !== false; + $template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate; + $body = str_replace(array_keys($placeholders), array_values($placeholders), $template); + + if ($isHtml) { + $body = $this->brandedHtml($appTitle, $body); + } + + return $this->send($to, $subject, $body, $isHtml); + } + + public function sendTestEmail($to) { + $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); + $subject = '[Test] E-Mail-Konfiguration – ' . $appTitle; + $body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet."; + return $this->send($to, $subject, $body, false); + } + + public function sendUserNotification($to, $userName, $changes) { + $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); + + // System-URL aus Einstellungen oder automatisch erkennen + $systemUrl = $this->db->getSetting('system_url', ''); + if (empty($systemUrl)) { + $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST']; + $scriptPath = dirname($_SERVER['SCRIPT_NAME']); + $scriptPath = $scriptPath === '/' ? '' : $scriptPath; + $systemUrl = $protocol . '://' . $host . $scriptPath; + } + + // Template aus Datenbank laden + $subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert'); + $bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}"); + + // Änderungen formatieren + $changesText = ''; + foreach ($changes as $change) { + $changesText .= "• $change\n"; + } + + // Platzhalter ersetzen + $placeholders = [ + '{USER_NAME}' => $userName, + '{CHANGES}' => $changesText, + '{APP_TITLE}' => $appTitle, + '{SYSTEM_URL}' => $systemUrl + ]; + + $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); + + $isHtml = strip_tags($bodyTemplate) !== $bodyTemplate; + $template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate; + $body = str_replace(array_keys($placeholders), array_values($placeholders), $template); + + if ($isHtml) { + $body = $this->brandedHtml($appTitle, $body); + } + + return $this->send($to, $subject, $body, $isHtml); + } + + /** + * Zeilenumbrüche aus dem Vorlagentext in HTML übernehmen, ohne bereits + * vorhandenes Markup (z. B. aus dem WYSIWYG-Editor) zu zerstören. + */ + private function textToHtml(string $body): string + { + if (preg_match('#<(p|div|ul|ol|h[1-6])[\s>]#i', $body)) { + return $body; + } + + return nl2br($body, false); + } } \ No newline at end of file diff --git a/includes/Ui.php b/includes/Ui.php new file mode 100644 index 0000000..b7faa7a --- /dev/null +++ b/includes/Ui.php @@ -0,0 +1,259 @@ + mit Versionsstempel. */ + public static function script(string $path, string $base = '', bool $defer = false): string + { + return ''; + } + + /** + * Theme-Bootstrap: gespeicherte Auswahl, sonst Systemeinstellung. + * Muss im stehen, damit nichts hell aufblitzt. + */ + public static function themeScript(): string + { + return ''; + } + + /** Gueltige Hex-Farbe oder Fallback. */ + private static function color(?string $value, string $fallback): string + { + $value = trim((string)$value); + + return preg_match('/^#[0-9a-fA-F]{6}$/', $value) ? strtolower($value) : $fallback; + } + + /** + * CSS-Overrides fuer die Markenfarben. Gibt einen leeren String zurueck, + * wenn nichts vom Standard abweicht. + */ + public static function brandingStyle($db = null): string + { + if (!$db) { + return ''; + } + + $accent = self::color($db->getSetting('brand_accent', ''), self::DEFAULT_ACCENT); + $accentDark = self::color($db->getSetting('brand_accent_dark', ''), self::DEFAULT_ACCENT_DARK); + $from = self::color($db->getSetting('brand_gradient_from', ''), self::DEFAULT_GRADIENT_FROM); + $to = self::color($db->getSetting('brand_gradient_to', ''), self::DEFAULT_GRADIENT_TO); + $radius = (int)$db->getSetting('brand_radius', (string)self::DEFAULT_RADIUS); + $radius = max(0, min(28, $radius)); + + $isDefault = $accent === self::DEFAULT_ACCENT + && $accentDark === self::DEFAULT_ACCENT_DARK + && $from === self::DEFAULT_GRADIENT_FROM + && $to === self::DEFAULT_GRADIENT_TO + && $radius === self::DEFAULT_RADIUS; + + if ($isDefault) { + return ''; + } + + // Abgeleitete Töne über color-mix – so genügt eine einzige Grundfarbe. + return ''; + } + + /** + * Bildfeld mit Vorschau, Upload und URL-Eingabe. + * Wird von den Einstellungen und der Kiosk-Verwaltung genutzt. + */ + public static function imageField( + string $name, + string $label, + ?string $value, + string $hint = '', + string $accept = 'image/*', + string $base = '../' + ): string { + $value = (string)$value; + $preview = self::mediaUrl($value, $base); + $esc = static fn ($text) => htmlspecialchars((string)$text, ENT_QUOTES); + $t = static fn ($key, $fallback) => function_exists('__') ? __($key) : $fallback; + + $thumb = $preview !== '' + ? '' + : ''; + + $remove = $value !== '' + ? '' + : ''; + + return '
' + . '' + . '
' + . '
' . $thumb . '
' + . '
' + . '' + . '' + . $remove + . '
' + . ($hint !== '' ? '
' . $esc($hint) . '
' : '') + . '
'; + } + + /** + * Version aus der Datei VERSION im Projektstamm. + * Damit tragen Oberfläche und Release-Paket dieselbe Nummer. + */ + public static function version(): string + { + static $version = null; + if ($version === null) { + $file = self::root() . '/VERSION'; + $version = is_file($file) ? trim((string)file_get_contents($file)) : ''; + } + + return $version; + } + + /** Entwicklerhinweis – bewusst an einer Stelle gepflegt. */ + public const CREDIT_NAME = 'Loheide.eu'; + public const CREDIT_URL = 'https://loheide.eu'; + + /** + * 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 '

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

'; + } + + /** + * Standard-Druckvorlage (wird nur verwendet, solange keine eigene + * Vorlage gespeichert ist). {QR_CODE} fuellt der Browser. + */ + public static function defaultPrintTemplate(): string + { + $validUntil = function_exists('__') ? __('print_valid_until') : 'Gültig bis'; + $devices = function_exists('__') ? __('print_devices') : 'Geräte'; + + return '
' + . '
{APP_TITLE}
' + . '
{SITE_NAME}
' + . '{QR_CODE}' + . '
{VOUCHER_CODE}
' + . '
' . $validUntil . ' {EXPIRY_DATE} {EXPIRY_TIME} · {MAX_USES} ' . $devices . '
' + . '
{INSTRUCTIONS}
' + . '
'; + } + + /** + * URL eines Bildes aus den Einstellungen. + * Hochgeladene Dateien liegen relativ zur Projektwurzel (uploads/…), + * externe Adressen bleiben unveraendert. + */ + public static function mediaUrl(string $value, string $base = ''): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + if (preg_match('#^(https?:)?//#i', $value) || strncmp($value, 'data:', 5) === 0 || $value[0] === '/') { + return $value; + } + + return $base . $value; + } + + /** + * Kompletter Standard-Kopf: Favicon, Schrift, Icons, Design-System, + * Theme-Bootstrap und Branding. + */ + public static function head($db = null, string $base = ''): string + { + $out = []; + + $favicon = $db ? self::mediaUrl((string)$db->getSetting('favicon_url', ''), $base) : ''; + if ($favicon !== '') { + $out[] = ''; + } + + $out[] = ''; + $out[] = ''; + $out[] = ''; + $out[] = ''; + $out[] = self::themeScript(); + + $branding = self::brandingStyle($db); + if ($branding !== '') { + $out[] = $branding; + } + + return implode("\n ", $out); + } +} diff --git a/includes/UniFiController.php b/includes/UniFiController.php index c545ecb..d740603 100644 --- a/includes/UniFiController.php +++ b/includes/UniFiController.php @@ -10,15 +10,12 @@ class UniFiController { private $csrfToken = null; private $sessionCookie = null; private $loggedIn = false; - /** SSL-Zertifikat pruefen? Default aus, da UnifFi-Controller meist self-signed sind. */ - private $sslVerify = false; - public function __construct($controllerUrl, $username, $password, $siteId, $sslVerify = false) { + public function __construct($controllerUrl, $username, $password, $siteId) { $this->controllerUrl = rtrim($controllerUrl, '/'); $this->username = $username; $this->password = $password; $this->siteId = $siteId; - $this->sslVerify = (bool)$sslVerify; $this->cookieFile = tempnam(sys_get_temp_dir(), 'UNIFI_'); } @@ -44,8 +41,7 @@ class UniFiController { 'password' => $this->password ]), CURLOPT_RETURNTRANSFER => true, - CURLOPT_SSL_VERIFYPEER => $this->sslVerify, - CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0, + CURLOPT_SSL_VERIFYPEER => false, CURLOPT_COOKIEJAR => $this->cookieFile, CURLOPT_COOKIEFILE => $this->cookieFile, CURLOPT_TIMEOUT => 10, @@ -120,8 +116,7 @@ class UniFiController { $options = [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, - CURLOPT_SSL_VERIFYPEER => $this->sslVerify, - CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0, + CURLOPT_SSL_VERIFYPEER => false, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_HTTPHEADER => $headers @@ -160,31 +155,13 @@ class UniFiController { return json_decode($response, true); } - // Einzelnen Voucher erstellen + // Voucher erstellen // $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB] public function createVoucher($voucherName, $maxUses, $expireMinutes = 480, $options = []) { - $vouchers = $this->createVouchers($voucherName, $maxUses, $expireMinutes, 1, $options); - return $vouchers[0]; - } - - /** - * Erstellt $count Voucher in EINEM API-Call (UniFi 'n'-Parameter) statt - * pro Voucher Login + Full-Fetch auszufuehren. - * - * Matching: Die create-voucher-Antwort liefert die create_time der neuen - * Voucher; darueber (plus note) werden exakt die soeben erstellten Codes - * identifiziert. Der fruehere Fallback "global neuester Voucher" konnte - * bei parallelen Erstellungen fremde Codes liefern und wurde entfernt. - * - * @param array $options Optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB] - * @return array Liste von ['code','formatted_code','unifi_id','create_time'] - */ - public function createVouchers($voucherName, $maxUses, $expireMinutes = 480, $count = 1, $options = []) { - $count = max(1, (int)$count); $data = [ 'cmd' => 'create-voucher', 'expire' => (int)$expireMinutes, - 'n' => $count, + 'n' => 1, 'note' => $voucherName, 'quota' => (int)$maxUses ]; @@ -205,51 +182,51 @@ class UniFiController { if (!isset($response['data'][0]['create_time'])) { throw new Exception("Voucher konnte nicht erstellt werden"); } - $createTime = $response['data'][0]['create_time']; + + // Voucher-Code abrufen. WICHTIG: getVouchers() liefert die Voucher + // unsortiert zurueck – ein blindes reset() kann bei parallelen + // Erstellungen den falschen (fremden) Code liefern. Daher gezielt + // nach dem soeben erstellten Voucher suchen: gleiche note + neueste + // create_time. + $vouchers = $this->getVouchers(); - $all = $this->getVouchers(); - - // Exakte Treffer: gleiche note UND die vom Controller gemeldete create_time - $matches = []; - foreach ($all as $voucher) { - if (($voucher['note'] ?? null) === $voucherName - && ($voucher['create_time'] ?? null) == $createTime) { - $matches[] = $voucher; - } - } - - // Fallback: nur note matchen (falls der Controller create_time leicht - // abweichend meldet), neueste zuerst, auf $count begrenzen. - if (empty($matches)) { - foreach ($all as $voucher) { - if (($voucher['note'] ?? null) === $voucherName) { - $matches[] = $voucher; - } - } - usort($matches, function ($a, $b) { - return ($b['create_time'] ?? 0) <=> ($a['create_time'] ?? 0); - }); - $matches = array_slice($matches, 0, $count); - } - - $result = []; - foreach ($matches as $voucher) { - if (empty($voucher['code'])) { - continue; - } - $result[] = [ - 'code' => $voucher['code'], - 'formatted_code' => $this->formatVoucherCode($voucher['code']), - 'unifi_id' => $voucher['_id'] ?? null, - 'create_time' => $voucher['create_time'] ?? null - ]; - } - - if (empty($result)) { + if (empty($vouchers)) { throw new Exception("Voucher-Code konnte nicht abgerufen werden"); } - return $result; + $latestVoucher = null; + foreach ($vouchers as $voucher) { + // Nur Voucher mit passender Notiz beruecksichtigen + if (($voucher['note'] ?? null) !== $voucherName) { + continue; + } + if ($latestVoucher === null + || ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) { + $latestVoucher = $voucher; + } + } + + // Fallback: falls keine note-Uebereinstimmung (z.B. Sonderzeichen), + // den global neuesten Voucher nehmen. + if ($latestVoucher === null) { + foreach ($vouchers as $voucher) { + if ($latestVoucher === null + || ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) { + $latestVoucher = $voucher; + } + } + } + + if ($latestVoucher === null || empty($latestVoucher['code'])) { + throw new Exception("Voucher-Code konnte nicht abgerufen werden"); + } + + return [ + 'code' => $latestVoucher['code'], + 'formatted_code' => $this->formatVoucherCode($latestVoucher['code']), + 'unifi_id' => $latestVoucher['_id'] ?? null, + 'create_time' => $latestVoucher['create_time'] ?? null + ]; } // Alle Voucher abrufen @@ -343,9 +320,9 @@ class UniFiController { } // Verbindung testen - public static function testConnection($controllerUrl, $username, $password, $siteId, $sslVerify = false) { + public static function testConnection($controllerUrl, $username, $password, $siteId) { try { - $controller = new self($controllerUrl, $username, $password, $siteId, $sslVerify); + $controller = new self($controllerUrl, $username, $password, $siteId); $controller->login(); return true; } catch (Exception $e) { @@ -374,26 +351,17 @@ class UniFiController { // Alle aktuellen UniFi-IDs sammeln $unifiIds = []; - // Bestehende Voucher der Site einmal als Map laden statt pro Voucher - // ein SELECT auszufuehren (halbiert die Query-Anzahl bei grossen Syncs) - $existingRows = $db->fetchAll( - "SELECT id, unifi_voucher_id FROM vouchers WHERE site_id = ? AND unifi_voucher_id IS NOT NULL", - [$dbSiteId] - ); - $existingMap = []; - foreach ($existingRows as $row) { - $existingMap[$row['unifi_voucher_id']] = $row['id']; - } - foreach ($vouchers as $voucher) { $unifiIds[] = $voucher['_id']; // Status zählen $stats[$voucher['status']]++; - $existing = isset($existingMap[$voucher['_id']]) - ? ['id' => $existingMap[$voucher['_id']]] - : null; + // Prüfen ob Voucher bereits existiert + $existing = $db->fetchOne( + "SELECT id, status, used_count FROM vouchers WHERE unifi_voucher_id = ? AND site_id = ?", + [$voucher['_id'], $dbSiteId] + ); $expiresAt = date('Y-m-d H:i:s', $voucher['expire_time']); $createdAt = date('Y-m-d H:i:s', $voucher['create_time']); diff --git a/includes/Upload.php b/includes/Upload.php new file mode 100644 index 0000000..2a97b90 --- /dev/null +++ b/includes/Upload.php @@ -0,0 +1,175 @@ + ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'], + 'favicon' => ['ico', 'png', 'svg'], + ]; + + /** + * Uebersetzte Meldung – faellt auf Deutsch zurueck, wenn die Klasse + * ausserhalb einer Seite mit geladener I18n verwendet wird. + */ + private static function msg(string $key, string $fallback): string + { + return function_exists('__') ? __($key) : $fallback; + } + + private static function dir(): string + { + return dirname(__DIR__) . '/uploads'; + } + + /** Legt das Upload-Verzeichnis inkl. Schutzdatei an. */ + public static function ensureDir(): bool + { + $dir = self::dir(); + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { + return false; + } + + $htaccess = $dir . '/.htaccess'; + if (!file_exists($htaccess)) { + @file_put_contents($htaccess, "php_flag engine off\nOptions -ExecCGI\n\n Require all denied\n\n"); + } + + return is_writable($dir); + } + + /** Ist der Pfad eine von uns gespeicherte Datei? */ + public static function isLocal(string $path): bool + { + return $path !== '' && strncmp($path, 'uploads/', 8) === 0 && strpos($path, '..') === false; + } + + /** Loescht eine zuvor hochgeladene Datei (externe URLs bleiben unberuehrt). */ + public static function delete(string $path): void + { + if (!self::isLocal($path)) { + return; + } + $file = dirname(__DIR__) . '/' . $path; + if (is_file($file)) { + @unlink($file); + } + } + + /** + * Nimmt einen Upload entgegen und gibt den relativen Pfad zurueck. + * + * @param array $file Eintrag aus $_FILES + * @param string $kind 'image' oder 'favicon' + * @throws RuntimeException bei ungueltigen Dateien + */ + public static function store(array $file, string $kind = 'image'): string + { + if (!isset($file['error']) || $file['error'] === UPLOAD_ERR_NO_FILE) { + return ''; + } + if ($file['error'] !== UPLOAD_ERR_OK) { + throw new RuntimeException(self::msg('upload_error_generic', 'Die Datei konnte nicht hochgeladen werden.')); + } + if (!is_uploaded_file($file['tmp_name'])) { + throw new RuntimeException(self::msg('upload_error_generic', 'Die Datei konnte nicht hochgeladen werden.')); + } + if ($file['size'] > self::MAX_BYTES) { + throw new RuntimeException(self::msg('upload_error_size', 'Die Datei ist zu groß (maximal 3 MB).')); + } + + $allowed = self::ALLOWED[$kind] ?? self::ALLOWED['image']; + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + if ($ext === 'jpeg') { + $ext = 'jpg'; + } + if (!in_array($ext, $allowed, true)) { + throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.')); + } + + $data = (string)file_get_contents($file['tmp_name']); + + if ($ext === 'svg') { + $data = self::sanitizeSvg($data); + } elseif ($ext !== 'ico') { + // Raster: muss als Bild lesbar sein + if (@getimagesize($file['tmp_name']) === false) { + throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.')); + } + } + + if (!self::ensureDir()) { + throw new RuntimeException(self::msg('upload_error_dir', 'Der Ordner uploads/ ist nicht beschreibbar.')); + } + + $name = bin2hex(random_bytes(8)) . '.' . $ext; + $dest = self::dir() . '/' . $name; + if (file_put_contents($dest, $data) === false) { + throw new RuntimeException(self::msg('upload_error_dir', 'Der Ordner uploads/ ist nicht beschreibbar.')); + } + @chmod($dest, 0644); + + return 'uploads/' . $name; + } + + /** + * Neuer Wert eines Bildfeldes aus dem Formular. + * + * Reihenfolge: hochgeladene Datei schlaegt alles, danach der + * Entfernen-Schalter, sonst gilt das URL-Feld. Wird eine zuvor + * hochgeladene Datei ersetzt oder entfernt, verschwindet sie auch + * von der Platte. + * + * @param string $name Feldname (erwartet , _file, _remove) + * @param string $current bisher gespeicherter Wert + * @param string $kind 'image' oder 'favicon' + */ + public static function resolveField(string $name, string $current, string $kind = 'image'): string + { + $uploaded = self::store($_FILES[$name . '_file'] ?? [], $kind); + if ($uploaded !== '') { + self::delete($current); + return $uploaded; + } + + if (!empty($_POST[$name . '_remove'])) { + self::delete($current); + return ''; + } + + $value = trim((string)($_POST[$name] ?? '')); + if ($value !== $current && self::isLocal($current) && !self::isLocal($value)) { + self::delete($current); + } + + return $value; + } + + /** + * Entfernt aktive Inhalte aus SVG-Dateien (Skripte, Event-Handler, + * externe Verweise). Lieber eine Grafik verlieren als eine XSS-Luecke. + */ + private static function sanitizeSvg(string $svg): string + { + if (stripos($svg, ']*>.*?<\s*/\s*\1\s*>#is', '', $svg); + $svg = preg_replace('#<\s*(script|foreignObject|iframe|embed|object|animate|set)\b[^>]*/?>#i', '', $svg); + $svg = preg_replace('#\son[a-z]+\s*=\s*"[^"]*"#i', '', $svg); + $svg = preg_replace("#\son[a-z]+\s*=\s*'[^']*'#i", '', $svg); + $svg = preg_replace('#(href|xlink:href)\s*=\s*([\'"])\s*(javascript|data):[^\'"]*\2#i', '', $svg); + $svg = preg_replace('#]*>#i', '', $svg); + + return (string)$svg; + } +} diff --git a/includes/VoucherService.php b/includes/VoucherService.php new file mode 100644 index 0000000..9ddcaa7 --- /dev/null +++ b/includes/VoucherService.php @@ -0,0 +1,63 @@ + kbit, 'up' => kbit, 'quota_mb' => MB] + * @param int|null $userId angemeldeter Benutzer, sonst null + * @param int|null $kioskId Herkunft, falls ueber eine Display-Seite geholt + * + * @return array{code:string,site_name:string,max_uses:int,expire_min:int,expiry_date:string,expiry_time:string} + * @throws Exception wenn der Controller keinen gueltigen Voucher liefert + */ + public static function create( + $db, + array $site, + string $voucherName, + int $maxUses, + int $expireMinutes, + ?int $userId = null, + array $qos = [], + ?int $kioskId = null + ): array { + $fullName = date('Y-m-d') . '_' . $voucherName; + + $controller = new UniFiController( + $site['unifi_controller_url'], + $site['unifi_username'], + Crypto::decrypt($site['unifi_password']), + $site['site_id'] + ); + + $voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos); + if (!is_array($voucher) || empty($voucher['formatted_code'])) { + throw new Exception(function_exists('__') ? __('error_voucher_invalid') : 'Ungueltige Antwort des Controllers'); + } + + $db->execute( + "INSERT INTO vouchers (site_id, user_id, kiosk_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [$site['id'], $userId, $kioskId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null] + ); + + $expiryTs = time() + ($expireMinutes * 60); + + return [ + 'code' => $voucher['formatted_code'], + 'site_name' => $site['name'], + 'max_uses' => $maxUses, + 'expire_min' => $expireMinutes, + 'expiry_date' => date('d.m.Y', $expiryTs), + 'expiry_time' => date('H:i', $expiryTs), + ]; + } +} diff --git a/includes/admin_nav.php b/includes/admin_nav.php index 2c39349..167c9ff 100644 --- a/includes/admin_nav.php +++ b/includes/admin_nav.php @@ -4,142 +4,122 @@ * Expects $currentPage (string), $appTitle (string), $auth, $db to be set before include. * Expects I18n to be initialized. */ +require_once __DIR__ . '/Ui.php'; + $currentPage = $currentPage ?? ''; -$faviconUrl = isset($db) ? $db->getSetting('favicon_url', '') : ''; $currentUser = isset($auth) ? $auth->getCurrentUser() : null; $lang = I18n::getLanguage(); -?> - - - - - - +$base = $adminBase ?? ''; // Prefix bis zum admin/-Ordner +$rootBase = $base === '' ? '../' : ''; // Prefix bis zum Projekt-Root - +/** Navigation als Datenstruktur – Reihenfolge = Darstellung. */ +$navGroups = [ + 'nav_group_overview' => [ + ['dashboard', 'index.php', 'fa-chart-pie', 'nav_dashboard'], + ['reports', 'reports.php', 'fa-chart-line', 'nav_reports'], + ['audit_log', 'audit_log.php', 'fa-clock-rotate-left','nav_audit_log'], + ], + 'nav_group_manage' => [ + ['vouchers', 'vouchers.php', 'fa-ticket', 'nav_vouchers'], + ['templates', 'templates.php', 'fa-layer-group', 'nav_templates'], + ['import', 'import.php', 'fa-file-arrow-up', 'nav_import'], + ['kiosks', 'kiosks.php', 'fa-display', 'nav_kiosks'], + ['sites', 'sites.php', 'fa-location-dot', 'nav_sites'], + ['users', 'users.php', 'fa-users', 'nav_users'], + ], + 'nav_group_system' => [ + ['settings', 'settings.php', 'fa-sliders', 'nav_settings'], + ['integrations', 'integrations.php', 'fa-plug', 'nav_integrations'], + ['api_keys', 'api_keys.php', 'fa-key', 'nav_api_keys'], + ['security', 'security.php', 'fa-shield-halved','nav_security'], + ['backup', 'backup.php', 'fa-database', 'nav_backup'], + ['update', 'update.php', 'fa-rotate', 'nav_update'], + ], +]; + +/** Aktuellen Seitentitel für die Breadcrumb finden. */ +$currentLabel = __('nav_dashboard'); +foreach ($navGroups as $items) { + foreach ($items as $item) { + if ($item[0] === $currentPage) { $currentLabel = __($item[3]); } + } +} +?> + - + -
+ + +
- -
- - +
- -
+
$label): ?>
- - - - - + + + - - -
-
-
-
-
-
+
- - -
- +
diff --git a/index.php b/index.php index ad69293..020c796 100644 --- a/index.php +++ b/index.php @@ -19,8 +19,9 @@ require_once __DIR__ . '/includes/Mailer.php'; require_once __DIR__ . '/includes/Notifier.php'; require_once __DIR__ . '/includes/Captcha.php'; require_once __DIR__ . '/includes/Sms.php'; +require_once __DIR__ . '/includes/Ui.php'; +require_once __DIR__ . '/includes/VoucherService.php'; require_once __DIR__ . '/includes/I18n.php'; -require_once __DIR__ . '/includes/Helpers.php'; $auth = new Auth(); $db = Database::getInstance(); @@ -28,68 +29,27 @@ $mailer = new Mailer(); I18n::init(); /** - * Throttle fuer die anonyme oeffentliche Voucher-Erstellung: - * max. 10 Voucher in 10 Minuten. Primaer IP-basiert ueber die Tabelle - * request_throttle (laesst sich nicht per Cookie-Loeschen umgehen); - * Fallback auf den Session-Zaehler, falls die Tabelle auf einer alten - * Installation noch fehlt (Migration 0002 nicht gelaufen). + * Session-basierter Throttle fuer die anonyme oeffentliche Voucher-Erstellung. + * Erlaubt max. 10 Erstellungen in 10 Minuten pro Session. Verhindert, dass + * der oeffentliche Modus zum Spammen des UniFi-Controllers missbraucht wird. */ -function isVoucherRateLimited($db, $voucherCount = 1) { - $window = 600; // 10 Minuten - $maxVouchers = 10; - - $limited = throttleHit($db, 'voucher_create', $maxVouchers, 10, $voucherCount); - if ($limited !== null) { - return $limited; - } - // Tabelle existiert noch nicht (Migration 0002 nicht gelaufen) - // -> Session-Fallback (Legacy-Verhalten) +function isVoucherRateLimited() { + $window = 600; // 10 Minuten + $maxRequests = 10; $now = time(); $timestamps = $_SESSION['voucher_create_times'] ?? []; $timestamps = array_values(array_filter($timestamps, function ($t) use ($now, $window) { return ($now - $t) < $window; })); - if (count($timestamps) + $voucherCount > $maxVouchers) { + if (count($timestamps) >= $maxRequests) { $_SESSION['voucher_create_times'] = $timestamps; return true; } - for ($i = 0; $i < $voucherCount; $i++) { - $timestamps[] = $now; - } + $timestamps[] = $now; $_SESSION['voucher_create_times'] = $timestamps; return false; } -/** - * Validiert die Voucher-Gueltigkeit (Minuten). Anonyme Nutzer duerfen nur den - * konfigurierten Default oder Werte aktiver Templates verwenden – das Feld ist - * ein Hidden-Input und damit beliebig manipulierbar. Eingeloggte Nutzer werden - * auf maximal 1 Jahr begrenzt. - */ -function sanitizeExpireMinutes($expireMinutes, $isLoggedIn, $templates, $defaultExpire) { - $expireMinutes = (int)$expireMinutes; - if ($isLoggedIn) { - return max(1, min(525600, $expireMinutes)); - } - $allowed = array_map(function ($t) { return (int)$t['expire_minutes']; }, $templates); - $allowed[] = $defaultExpire; - return in_array($expireMinutes, $allowed, true) ? $expireMinutes : $defaultExpire; -} - -/** Minuten menschenlesbar formatieren (z.B. 480 -> "8 Stunden"). */ -function formatDuration($minutes) { - $minutes = (int)$minutes; - if ($minutes >= 1440 && $minutes % 1440 === 0) { - $days = $minutes / 1440; - return $days === 1 ? __('dur_day_one') : __('dur_days', ['n' => $days]); - } - if ($minutes >= 60 && $minutes % 60 === 0) { - $hours = $minutes / 60; - return $hours === 1 ? __('dur_hour_one') : __('dur_hours', ['n' => $hours]); - } - return __('dur_minutes', ['n' => $minutes]); -} - /** * Optionales Tageslimit pro (Nicht-Admin-)Benutzer (Setting * user_daily_voucher_limit, 0 = aus). Verhindert übermäßige Erstellung. @@ -112,12 +72,11 @@ function userDailyLimitExceeded($db, $auth, $additional = 1) { $appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $logoUrl = $db->getSetting('logo_url', ''); -$faviconUrl = $db->getSetting('favicon_url', ''); $instructionHeader = $db->getSetting('instruction_header', ''); $instructionText = $db->getSetting('instruction_text', ''); $publicAccess = $db->getSetting('public_access', 0); $smtpEnabled = $db->getSetting('smtp_enabled', '0') === '1'; -$printTemplate = $db->getSetting('print_template', '

{APP_TITLE}

WLAN Zugangscode

{VOUCHER_CODE}

Gültig bis: {EXPIRY_DATE} {EXPIRY_TIME}

Standort: {SITE_NAME}

Maximale Geräte: {MAX_USES}


{INSTRUCTIONS}
'); +$printTemplate = $db->getSetting('print_template', Ui::defaultPrintTemplate()); $defaultExpire = max(1, (int)$db->getSetting('default_expire_minutes', 480)); $defaultMaxUses = max(1, (int)$db->getSetting('default_max_uses', 1)); $maxUsesLimit = max(1, (int)$db->getSetting('max_uses_limit', 10)); @@ -157,35 +116,9 @@ if ($auth->isLoggedIn()) { $autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0; -// Helper: create one voucher and save to DB +// Voucher-Erstellung liegt gebuendelt in includes/VoucherService.php. function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos = []) { - $datum = date('Y-m-d'); - $fullName = $datum . '_' . $voucherName; - $controller = new UniFiController( - $site['unifi_controller_url'], - $site['unifi_username'], - Crypto::decrypt($site['unifi_password']), - $site['site_id'], - $site['ssl_verify'] ?? 0 - ); - $voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos); - if (!is_array($voucher) || empty($voucher['formatted_code'])) { - throw new Exception(__('error_voucher_invalid')); - } - $db->execute( - "INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id) - VALUES (?, ?, ?, ?, ?, ?, ?)", - [$site['id'], $userId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null] - ); - $expiryTs = time() + ($expireMinutes * 60); - return [ - 'code' => $voucher['formatted_code'], - 'site_name' => $site['name'], - 'max_uses' => $maxUses, - 'expire_min' => $expireMinutes, - 'expiry_date' => date('d.m.Y', $expiryTs), - 'expiry_time' => date('H:i', $expiryTs), - ]; + return VoucherService::create($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos); } // Single voucher @@ -195,8 +128,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) { } elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { // CSRF fuer ALLE (auch anonyme oeffentliche Erstellung) $error = __('error_csrf'); - } elseif (!$auth->isLoggedIn() && isVoucherRateLimited($db)) { - $error = __('error_rate_limited'); + } elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) { + $error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.'; } elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) { $error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.'; } else { @@ -204,7 +137,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) { $siteId = (int)($_POST['site_id'] ?? 0); $voucherName = trim((string)($_POST['voucher_name'] ?? '')); $maxUses = (int)($_POST['max_uses'] ?? $defaultMaxUses); - $expireMinutes = sanitizeExpireMinutes($_POST['expire_minutes'] ?? $defaultExpire, $auth->isLoggedIn(), $templates, $defaultExpire); + $expireMinutes = max(1, (int)($_POST['expire_minutes'] ?? $defaultExpire)); $sendEmail = isset($_POST['send_email']) && !empty($_POST['recipient_email']); $recipientEmail= trim((string)($_POST['recipient_email'] ?? '')); @@ -230,11 +163,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) { $voucherCreated = true; Notifier::voucherCreated(1, $site['name'], $_SESSION['user_name'] ?? null); + $success = 'Voucher erfolgreich erstellt!'; if ($sendEmail && !empty($recipientEmail)) { $mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses); - $success = __('voucher_created_mail'); - } else { - $success = __('voucher_created_ok'); + $success .= ' E-Mail versendet.'; } // Optional: Code per SMS (Twilio) $recipientPhone = trim((string)($_POST['recipient_phone'] ?? '')); @@ -242,128 +174,62 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) { $smsText = ($appTitle ? $appTitle . ': ' : '') . 'WLAN-Code ' . $voucherCode; $success .= Sms::send($db, $recipientPhone, $smsText) ? ' SMS versendet.' : ' (SMS fehlgeschlagen)'; } - - $auth->writeAuditLog($userId, 'voucher_create', 'voucher', null, - "Voucher '{$voucherName}' für {$site['name']}" . ($userId === null ? ' (öffentlich)' : '')); - - // PRG-Pattern: Redirect nach erfolgreichem POST, damit ein Reload - // (F5) keinen Duplikat-Voucher erzeugt. Ergebnis via Session-Flash. - $_SESSION['voucher_flash'] = ['type' => 'single', 'data' => $voucherData, 'success' => $success]; - header('Location: index.php?created=1'); - exit; } catch (Exception $e) { $error = 'Fehler: ' . $e->getMessage(); } } } -// Bulk voucher creation – nur fuer eingeloggte Nutzer. Das Formular wird -// Anonymen zwar nicht angezeigt, der POST-Endpunkt muss es aber ebenfalls -// serverseitig erzwingen (sonst 20 Voucher pro Request im Public-Modus). +// Bulk voucher creation if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) { - if (!$auth->isLoggedIn()) { + if (!$publicAccess && !$auth->isLoggedIn()) { $error = __('error_login_req'); } elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + // CSRF fuer ALLE (auch anonyme oeffentliche Erstellung) $error = __('error_csrf'); + } elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) { + $error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.'; + } elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) { + $error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.'; } else { try { $siteId = (int)($_POST['site_id'] ?? 0); $voucherName = trim((string)($_POST['voucher_name'] ?? '')); $maxUses = (int)($_POST['max_uses'] ?? $defaultMaxUses); - $expireMinutes = sanitizeExpireMinutes($_POST['expire_minutes'] ?? $defaultExpire, true, $templates, $defaultExpire); + $expireMinutes = max(1, (int)($_POST['expire_minutes'] ?? $defaultExpire)); $bulkCount = max(1, min(20, (int)($_POST['bulk_count'] ?? 1))); if (empty($voucherName)) throw new Exception(__('error_name_req')); if ($maxUses < 1 || $maxUses > $maxUsesLimit) throw new Exception(__('error_devices_range', ['max' => $maxUsesLimit])); if ($siteId <= 0) throw new Exception(__('error_site_req')); - if (!$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm')); + if ($auth->isLoggedIn() && !$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm')); if (userDailyLimitExceeded($db, $auth, $bulkCount)) throw new Exception('Tageslimit für Voucher erreicht.'); $site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]); if (!$site) throw new Exception(__('error_site_not_found')); - $userId = $_SESSION['user_id'] ?? null; + $userId = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : null; $qos = [ 'down' => max(0, (int)($_POST['qos_down'] ?? 0)), 'up' => max(0, (int)($_POST['qos_up'] ?? 0)), 'quota_mb' => max(0, (int)($_POST['qos_quota'] ?? 0)), ]; - // Alle Voucher in EINEM UniFi-API-Call erstellen ('n'-Parameter) - // statt pro Voucher Login + Voucherliste abzurufen. - $fullName = date('Y-m-d') . '_' . $voucherName; - $controller = new UniFiController( - $site['unifi_controller_url'], - $site['unifi_username'], - Crypto::decrypt($site['unifi_password']), - $site['site_id'], - $site['ssl_verify'] ?? 0 - ); - $created = $controller->createVouchers($fullName, $maxUses, $expireMinutes, $bulkCount, $qos); - $expiryTs = time() + ($expireMinutes * 60); - - foreach ($created as $i => $voucher) { - $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 . '_' . ($i + 1), $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null] - ); - $bulkVouchers[] = [ - '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), - ]; + for ($i = 0; $i < $bulkCount; $i++) { + $bulkVouchers[] = doCreateVoucher($db, $site, $voucherName . '_' . ($i + 1), $maxUses, $expireMinutes, $userId, $qos); } - Notifier::voucherCreated(count($created), $site['name'], $_SESSION['user_name'] ?? null); - $auth->writeAuditLog($userId, 'voucher_bulk', 'voucher', null, - count($created) . " Vouchers '{$voucherName}' für {$site['name']}"); - - $success = str_replace('{count}', count($created), __('bulk_success')); - - // PRG-Pattern: Reload darf die Bulk-Erstellung nicht wiederholen. - $_SESSION['voucher_flash'] = ['type' => 'bulk', 'data' => $bulkVouchers, 'success' => $success]; - header('Location: index.php?created=1'); - exit; + Notifier::voucherCreated($bulkCount, $site['name'], $_SESSION['user_name'] ?? null); + $bulkCreated = true; + $success = str_replace('{count}', $bulkCount, __('bulk_success')); } catch (Exception $e) { $error = 'Fehler: ' . $e->getMessage(); } } } -// PRG: Ergebnis nach Redirect aus dem Session-Flash wiederherstellen. -// Der Flash bleibt fuer Reloads der Ergebnisseite erhalten und wird beim -// Zurueckkehren zum Formular (GET ohne ?created) verworfen. -if (isset($_GET['created']) && !empty($_SESSION['voucher_flash'])) { - $flash = $_SESSION['voucher_flash']; - if (($flash['type'] ?? '') === 'bulk') { - $bulkVouchers = $flash['data']; - $bulkCreated = true; - } else { - $voucherData = $flash['data']; - $voucherCode = $voucherData['code']; - $voucherCreated = true; - } - $success = $flash['success'] ?? ''; -} elseif ($_SERVER['REQUEST_METHOD'] !== 'POST') { - unset($_SESSION['voucher_flash']); -} - $currentUser = $auth->isLoggedIn() ? $auth->getCurrentUser() : null; -// Bei Validierungsfehlern: eingegebene Werte und aktiven Tab erhalten -$activeMode = ($error && isset($_POST['create_bulk'])) ? 'bulk' : 'single'; -$stickyName = $error ? trim((string)($_POST['voucher_name'] ?? '')) : ''; -$stickyMaxUses = $error ? (int)($_POST['max_uses'] ?? $defaultMaxUses) : $defaultMaxUses; -$stickyBulkCount = $error ? max(1, min(20, (int)($_POST['bulk_count'] ?? 5))) : 5; -$stickySiteId = $error ? (int)($_POST['site_id'] ?? 0) : 0; -if ($stickyMaxUses < 1 || $stickyMaxUses > $maxUsesLimit) $stickyMaxUses = $defaultMaxUses; -// Anonyme Gaeste wissen oft nicht, was sie als Namen eintragen sollen -> Default -if ($stickyName === '' && !$auth->isLoggedIn()) $stickyName = __('voucher_name_default'); - // Captcha nur für anonyme öffentliche Erstellung $captchaMode = !$auth->isLoggedIn() ? Captcha::mode($db) : 'off'; $captchaQuestion = $captchaMode === 'math' ? Captcha::newMathChallenge() : ''; @@ -382,9 +248,12 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, $instructions = $instructionHeader || $instructionText ? htmlspecialchars($instructionHeader) . "\n" . $instructionText : ''; + // {QR_CODE} wird erst im Browser gefuellt (siehe renderPrintQr()). + $qr = ''; + return str_replace( - ['{VOUCHER_CODE}', '{SITE_NAME}', '{MAX_USES}', '{APP_TITLE}', '{INSTRUCTIONS}', '{EXPIRY_DATE}', '{EXPIRY_TIME}'], - [$data['code'], htmlspecialchars($data['site_name']), $data['max_uses'], htmlspecialchars($appTitle), $instructions, $data['expiry_date'], $data['expiry_time']], + ['{QR_CODE}', '{VOUCHER_CODE}', '{SITE_NAME}', '{MAX_USES}', '{APP_TITLE}', '{INSTRUCTIONS}', '{EXPIRY_DATE}', '{EXPIRY_TIME}'], + [$qr, $data['code'], htmlspecialchars($data['site_name']), $data['max_uses'], htmlspecialchars($appTitle), $instructions, $data['expiry_date'], $data['expiry_time']], $template ); } @@ -395,120 +264,76 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, <?= htmlspecialchars($appTitle) ?> - - - - + - - - + + - + -
-
- $label): ?> - - -
- -
- - -
-
👋 htmlspecialchars($currentUser['name'])]) ?>
-
- isAdmin()): ?> - ⚙️ + +
+ + + + +
+
+ $label): ?> + + +
+ + + isAdmin()): ?> + + + + +
+
+
+ + + +
+ + + + -
-
- -
-
-
- 🔐 -
-
- + -
+
- + -

+
+

+

+
@@ -521,13 +346,19 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
-
-
+
+
- +
+
+ + + +
+
@@ -541,15 +372,22 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
-
- -
- +
+
+ +
+ +
-

+
+

+

+
$bv): ?> @@ -574,8 +412,8 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, - + @@ -583,21 +421,21 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, -

+

-
- +
-
📶
+


isAdmin()): ?> @@ -653,14 +491,13 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,

+ min="1" max="" value="" required>
@@ -670,7 +507,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, - @@ -682,7 +519,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
-
+
+ +
diff --git a/install.php b/install.php index 39b7338..679f6f1 100644 --- a/install.php +++ b/install.php @@ -1,483 +1,381 @@ - Admin-Authentifizierung erzwingen - require_once __DIR__ . '/config.php'; - require_once __DIR__ . '/includes/Database.php'; - require_once __DIR__ . '/includes/Auth.php'; - try { - $reinstallAuth = new Auth(); - if (!$reinstallAuth->isAdmin()) { - die('Neuinstallation nicht erlaubt: Bitte zuerst als Administrator anmelden.'); - } - } catch (Exception $e) { - die('Neuinstallation nicht moeglich (Konfigurationsfehler).'); - } -} - -$step = isset($_POST['step']) ? (int)$_POST['step'] : 1; -$errors = []; -$success = false; - -// Step 1: Datenbankverbindung testen -if ($step === 2 && $_SERVER['REQUEST_METHOD'] === 'POST') { - $db_host = $_POST['db_host'] ?? ''; - $db_name = $_POST['db_name'] ?? ''; - $db_user = $_POST['db_user'] ?? ''; - $db_pass = $_POST['db_pass'] ?? ''; - - try { - $pdo = new PDO("mysql:host=$db_host;charset=utf8mb4", $db_user, $db_pass); - $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - // Datenbank erstellen falls nicht vorhanden - $pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); - $pdo->exec("USE `$db_name`"); - - // Tabellen erstellen - $sql = file_get_contents(__DIR__ . '/database.sql'); - $pdo->exec($sql); - - $_SESSION['install_db'] = [ - 'host' => $db_host, - 'name' => $db_name, - 'user' => $db_user, - 'pass' => $db_pass - ]; - - } catch (PDOException $e) { - $errors[] = "Datenbankfehler: " . $e->getMessage(); - $step = 1; - } -} - -// Step 2: Admin-Account erstellen -if ($step === 3 && $_SERVER['REQUEST_METHOD'] === 'POST') { - $admin_email = filter_var($_POST['admin_email'] ?? '', FILTER_VALIDATE_EMAIL); - $admin_name = $_POST['admin_name'] ?? ''; - $admin_password = $_POST['admin_password'] ?? ''; - $admin_password_confirm = $_POST['admin_password_confirm'] ?? ''; - - if (!$admin_email) { - $errors[] = "Ungültige E-Mail-Adresse"; - $step = 2; - } elseif (strlen($admin_password) < 8) { - $errors[] = "Passwort muss mindestens 8 Zeichen lang sein"; - $step = 2; - } elseif ($admin_password !== $admin_password_confirm) { - $errors[] = "Passwörter stimmen nicht überein"; - $step = 2; - } else { - $_SESSION['install_admin'] = [ - 'email' => $admin_email, - 'name' => $admin_name, - 'password' => password_hash($admin_password, PASSWORD_DEFAULT) - ]; - } -} - -// Step 3: Allgemeine Einstellungen -if ($step === 4 && $_SERVER['REQUEST_METHOD'] === 'POST') { - $app_title = $_POST['app_title'] ?? 'UniFi Voucher System'; - $logo_url = $_POST['logo_url'] ?? ''; - $instruction_header = $_POST['instruction_header'] ?? ''; - $instruction_text = $_POST['instruction_text'] ?? ''; - $public_access = isset($_POST['public_access']) ? 1 : 0; - - // Microsoft 365 OAuth (optional) - $m365_client_id = $_POST['m365_client_id'] ?? ''; - $m365_client_secret = $_POST['m365_client_secret'] ?? ''; - $m365_tenant_id = $_POST['m365_tenant_id'] ?? ''; - - $_SESSION['install_settings'] = [ - 'app_title' => $app_title, - 'logo_url' => $logo_url, - 'instruction_header' => $instruction_header, - 'instruction_text' => $instruction_text, - 'public_access' => $public_access, - 'm365_client_id' => $m365_client_id, - 'm365_client_secret' => $m365_client_secret, - 'm365_tenant_id' => $m365_tenant_id - ]; -} - -// Step 4: Installation abschließen -if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') { - try { - $db = $_SESSION['install_db']; - $admin = $_SESSION['install_admin']; - $settings = $_SESSION['install_settings']; - - $pdo = new PDO("mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4", $db['user'], $db['pass']); - $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - // Admin-User erstellen - $stmt = $pdo->prepare("INSERT INTO users (email, name, password_hash, is_admin, is_active) VALUES (?, ?, ?, 1, 1)"); - $stmt->execute([$admin['email'], $admin['name'], $admin['password']]); - - // Settings speichern - $settingsData = [ - 'app_title' => $settings['app_title'], - 'logo_url' => $settings['logo_url'], - 'instruction_header' => $settings['instruction_header'], - 'instruction_text' => $settings['instruction_text'], - 'public_access' => $settings['public_access'], - 'm365_client_id' => $settings['m365_client_id'], - 'm365_client_secret' => $settings['m365_client_secret'], - 'm365_tenant_id' => $settings['m365_tenant_id'] - ]; - - $stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"); - foreach ($settingsData as $key => $value) { - $stmt->execute([$key, $value]); - } - - // config.php erstellen - $configContent = "\n"; - $htaccess .= " Require all denied\n"; - $htaccess .= "\n\n"; - $htaccess .= "DirectoryIndex index.php\n"; - file_put_contents(__DIR__ . '/.htaccess', $htaccess); - - $success = true; - - // Session-Daten löschen - unset($_SESSION['install_db'], $_SESSION['install_admin'], $_SESSION['install_settings']); - - } catch (Exception $e) { - $errors[] = "Fehler bei der Installation: " . $e->getMessage(); - $step = 4; - } -} -?> - - - - - - UniFi Voucher System - Installation - - - -
-

🚀 UniFi Voucher System

-

Installation

- -
-
1
-
2
-
3
-
4
-
5
-
- - -
- -
- -
- - - -
- ✓ Installation erfolgreich abgeschlossen!
- Sie können sich jetzt mit Ihren Admin-Zugangsdaten anmelden. -
- Zum Login - -
- -

Schritt 1: Datenbank-Konfiguration

- -
- - -
Meist "localhost"
-
- -
- - -
Name der Datenbank (wird erstellt falls nicht vorhanden)
-
- -
- - -
- -
- - -
- - -
- - -
- -

Schritt 2: Administrator-Account

- -
- - -
- -
- - -
- -
- - -
Mindestens 8 Zeichen
-
- -
- - -
- - -
- - -
- -

Schritt 3: Allgemeine Einstellungen

- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
-

Microsoft 365 Login (Optional)

-
- - -
-
- - -
-
- - -
-
Leer lassen, wenn M365-Login nicht verwendet werden soll
-
- - -
- - -
- -

Schritt 4: Installation abschließen

- -

- Klicken Sie auf "Installation abschließen", um die Einrichtung zu beenden. - Die Datenbank und alle notwendigen Dateien werden erstellt. -

- - -
- -
- + Admin-Authentifizierung erzwingen + require_once __DIR__ . '/config.php'; + require_once __DIR__ . '/includes/Database.php'; + require_once __DIR__ . '/includes/Auth.php'; + try { + $reinstallAuth = new Auth(); + if (!$reinstallAuth->isAdmin()) { + die('Neuinstallation nicht erlaubt: Bitte zuerst als Administrator anmelden.'); + } + } catch (Exception $e) { + die('Neuinstallation nicht moeglich (Konfigurationsfehler).'); + } +} + +$step = isset($_POST['step']) ? (int)$_POST['step'] : 1; +$errors = []; +$success = false; + +// Step 1: Datenbankverbindung testen +if ($step === 2 && $_SERVER['REQUEST_METHOD'] === 'POST') { + $db_host = $_POST['db_host'] ?? ''; + $db_name = $_POST['db_name'] ?? ''; + $db_user = $_POST['db_user'] ?? ''; + $db_pass = $_POST['db_pass'] ?? ''; + + try { + $pdo = new PDO("mysql:host=$db_host;charset=utf8mb4", $db_user, $db_pass); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // Datenbank erstellen falls nicht vorhanden + $pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $pdo->exec("USE `$db_name`"); + + // Tabellen erstellen + $sql = file_get_contents(__DIR__ . '/database.sql'); + $pdo->exec($sql); + + $_SESSION['install_db'] = [ + 'host' => $db_host, + 'name' => $db_name, + 'user' => $db_user, + 'pass' => $db_pass + ]; + + } catch (PDOException $e) { + $errors[] = "Datenbankfehler: " . $e->getMessage(); + $step = 1; + } +} + +// Step 2: Admin-Account erstellen +if ($step === 3 && $_SERVER['REQUEST_METHOD'] === 'POST') { + $admin_email = filter_var($_POST['admin_email'] ?? '', FILTER_VALIDATE_EMAIL); + $admin_name = $_POST['admin_name'] ?? ''; + $admin_password = $_POST['admin_password'] ?? ''; + $admin_password_confirm = $_POST['admin_password_confirm'] ?? ''; + + if (!$admin_email) { + $errors[] = "Ungültige E-Mail-Adresse"; + $step = 2; + } elseif (strlen($admin_password) < 8) { + $errors[] = "Passwort muss mindestens 8 Zeichen lang sein"; + $step = 2; + } elseif ($admin_password !== $admin_password_confirm) { + $errors[] = "Passwörter stimmen nicht überein"; + $step = 2; + } else { + $_SESSION['install_admin'] = [ + 'email' => $admin_email, + 'name' => $admin_name, + 'password' => password_hash($admin_password, PASSWORD_DEFAULT) + ]; + } +} + +// Step 3: Allgemeine Einstellungen +if ($step === 4 && $_SERVER['REQUEST_METHOD'] === 'POST') { + $app_title = $_POST['app_title'] ?? 'UniFi Voucher System'; + $logo_url = $_POST['logo_url'] ?? ''; + $instruction_header = $_POST['instruction_header'] ?? ''; + $instruction_text = $_POST['instruction_text'] ?? ''; + $public_access = isset($_POST['public_access']) ? 1 : 0; + + // Microsoft 365 OAuth (optional) + $m365_client_id = $_POST['m365_client_id'] ?? ''; + $m365_client_secret = $_POST['m365_client_secret'] ?? ''; + $m365_tenant_id = $_POST['m365_tenant_id'] ?? ''; + + $_SESSION['install_settings'] = [ + 'app_title' => $app_title, + 'logo_url' => $logo_url, + 'instruction_header' => $instruction_header, + 'instruction_text' => $instruction_text, + 'public_access' => $public_access, + 'm365_client_id' => $m365_client_id, + 'm365_client_secret' => $m365_client_secret, + 'm365_tenant_id' => $m365_tenant_id + ]; +} + +// Step 4: Installation abschließen +if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') { + try { + $db = $_SESSION['install_db']; + $admin = $_SESSION['install_admin']; + $settings = $_SESSION['install_settings']; + + $pdo = new PDO("mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4", $db['user'], $db['pass']); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // Admin-User erstellen + $stmt = $pdo->prepare("INSERT INTO users (email, name, password_hash, is_admin, is_active) VALUES (?, ?, ?, 1, 1)"); + $stmt->execute([$admin['email'], $admin['name'], $admin['password']]); + + // Settings speichern + $settingsData = [ + 'app_title' => $settings['app_title'], + 'logo_url' => $settings['logo_url'], + 'instruction_header' => $settings['instruction_header'], + 'instruction_text' => $settings['instruction_text'], + 'public_access' => $settings['public_access'], + 'm365_client_id' => $settings['m365_client_id'], + 'm365_client_secret' => $settings['m365_client_secret'], + 'm365_tenant_id' => $settings['m365_tenant_id'] + ]; + + $stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"); + foreach ($settingsData as $key => $value) { + $stmt->execute([$key, $value]); + } + + // config.php erstellen + $configContent = "\n"; + $htaccess .= " Order Allow,Deny\n"; + $htaccess .= " Deny from all\n"; + $htaccess .= "\n\n"; + $htaccess .= "DirectoryIndex index.php\n"; + file_put_contents(__DIR__ . '/.htaccess', $htaccess); + + $success = true; + + // Session-Daten löschen + unset($_SESSION['install_db'], $_SESSION['install_admin'], $_SESSION['install_settings']); + + } catch (Exception $e) { + $errors[] = "Fehler bei der Installation: " . $e->getMessage(); + $step = 4; + } +} +?> + + + + + + UniFi Voucher System - Installation + + + + + +
+
+ +
+

UniFi Voucher System

+

Installation in fünf Schritten

+
+
+ +
+
1
+
2
+
3
+
4
+
5
+
+ + +
+ +
+ +
+ + + +
+ ✓ Installation erfolgreich abgeschlossen!
+ Sie können sich jetzt mit Ihren Admin-Zugangsdaten anmelden. +
+ Zum Login + +
+ +

Schritt 1: Datenbank-Konfiguration

+ +
+ + +
Meist "localhost"
+
+ +
+ + +
Name der Datenbank (wird erstellt falls nicht vorhanden)
+
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ +

Schritt 2: Administrator-Account

+ +
+ + +
+ +
+ + +
+ +
+ + +
Mindestens 8 Zeichen
+
+ +
+ + +
+ + +
+ + +
+ +

Schritt 3: Allgemeine Einstellungen

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

Microsoft 365 Login (Optional)

+
+ + +
+
+ + +
+
+ + +
+
Leer lassen, wenn M365-Login nicht verwendet werden soll
+
+ + +
+ + +
+ +

Schritt 4: Installation abschließen

+ +

+ Klicken Sie auf "Installation abschließen", um die Einrichtung zu beenden. + Die Datenbank und alle notwendigen Dateien werden erstellt. +

+ + +
+ + +
+ \ No newline at end of file diff --git a/kiosk.php b/kiosk.php new file mode 100644 index 0000000..9118ce4 --- /dev/null +++ b/kiosk.php @@ -0,0 +1,245 @@ + + * + * Gedacht für ein Tablet oder einen Bildschirm im Empfangsbereich: ein großer + * Knopf, ein Klick, ein Zugangscode. Gäste ohne Zugriff auf den Bildschirm + * können denselben Link über den QR-Code am Handy öffnen. + */ +error_reporting(E_ALL); +ini_set('display_errors', 0); +ini_set('log_errors', 1); + +require_once __DIR__ . '/config.php'; +require_once __DIR__ . '/includes/Database.php'; +require_once __DIR__ . '/includes/Auth.php'; +require_once __DIR__ . '/includes/I18n.php'; +require_once __DIR__ . '/includes/Ui.php'; +require_once __DIR__ . '/includes/Kiosk.php'; +require_once __DIR__ . '/includes/VoucherService.php'; + +I18n::init(); + +try { + $db = Database::getInstance(); + $auth = new Auth(); +} catch (Exception $e) { + http_response_code(500); + die('Datenbankfehler'); +} + +$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); +$token = Kiosk::sanitizeToken($_GET['k'] ?? ''); +$kiosk = $token !== '' ? Kiosk::findByToken($db, $token) : null; + +if (!$kiosk) { + http_response_code(404); + $notFound = true; +} else { + $notFound = false; +} + +$voucher = null; // erzeugter Code +$error = ''; +$waitSecs = 0; + +if (!$notFound && $_SERVER['REQUEST_METHOD'] === 'POST') { + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $error = __('error_csrf'); + } else { + $limits = Kiosk::checkLimits($db, $kiosk); + if (!$limits['allowed']) { + $waitSecs = (int)$limits['wait']; + $error = $limits['reason'] === 'cooldown' + ? str_replace('{seconds}', (string)$waitSecs, __('kiosk_error_cooldown')) + : __('kiosk_error_limit'); + } else { + try { + $site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [(int)$kiosk['site_id']]); + if (!$site) { + throw new Exception(__('error_site_not_found')); + } + + $settings = Kiosk::voucherSettings($db, $kiosk); + $voucher = VoucherService::create( + $db, + $site, + $kiosk['name'], + $settings['max_uses'], + $settings['expire_minutes'], + null, + $settings['qos'], + (int)$kiosk['id'] + ); + + Kiosk::markUsed($db, (int)$kiosk['id']); + $auth->writeAuditLog(null, 'voucher_kiosk', 'kiosk', (int)$kiosk['id'], + $kiosk['name'] . ' · ' . $voucher['code']); + } catch (Exception $e) { + error_log('Kiosk-Fehler: ' . $e->getMessage()); + $error = __('kiosk_error_generic'); + } + } + } +} + +$headline = trim((string)($kiosk['headline'] ?? '')) ?: __('kiosk_default_headline'); +$subline = trim((string)($kiosk['subline'] ?? '')) ?: __('kiosk_default_subline'); +$display = max(10, (int)($kiosk['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS)); +$selfUrl = $kiosk ? Kiosk::publicUrl($kiosk['token']) : ''; + +// Gestaltung dieser Display-Seite (eigene Werte, sonst die des Systems) +$look = $kiosk ? Kiosk::appearance($db, $kiosk) : ['logo'=>'','background'=>'','overlay'=>0.45,'accent'=>'','card'=>'light']; +$logoUrl = $look['logo']; +$bodyClass = 'kiosk-body'; +$bodyStyle = ''; +if ($look['background'] !== '') { + $bodyClass .= ' has-background'; + $bodyStyle .= "--kiosk-bg:url('" . htmlspecialchars(Ui::mediaUrl($look['background']), ENT_QUOTES) . "');" + . '--kiosk-overlay:' . $look['overlay'] . ';'; +} +if ($look['card'] === 'dark') { + $bodyClass .= ' kiosk-dark'; +} +if ($look['accent'] !== '') { + // Nur die Akzentfarbe dieser Seite überschreiben – der Rest bleibt Design-System. + $bodyStyle .= '--accent:' . $look['accent'] . ';' + . '--accent-hover:color-mix(in srgb, ' . $look['accent'] . ' 84%, #000);' + . '--accent-soft:color-mix(in srgb, ' . $look['accent'] . ' 14%, #fff);' + . '--accent-border:color-mix(in srgb, ' . $look['accent'] . ' 32%, #fff);'; +} +?> + + + + + + + <?= htmlspecialchars($appTitle) ?> + + + + + +> + + +
+
+
+
+

+

+
+
+
+ +
+
+ + + +

+
+
+ + + +
+
+
+

+
+

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

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

+

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

+
+
+
+ + +
+ + + + + + diff --git a/lang/de.php b/lang/de.php index 0016d2c..7b07a87 100644 --- a/lang/de.php +++ b/lang/de.php @@ -1,6 +1,9 @@ 'Übersicht', + 'nav_group_manage' => 'Verwaltung', + 'nav_group_system' => 'System', 'nav_dashboard' => 'Dashboard', 'nav_sites' => 'Sites verwalten', 'nav_users' => 'Benutzer verwalten', @@ -98,13 +101,15 @@ return [ 'voucher_email_hint' => 'gast@example.com', 'voucher_create_btn' => 'Voucher erstellen', 'voucher_creating' => 'Erstelle Voucher...', - 'voucher_success_title' => '✓ Ihr Zugangs-Code', - 'voucher_validity' => 'Gültig für {duration} ab Erstellung', + 'voucher_success_title' => 'Zugangs-Code erstellt', + 'voucher_copy_hint' => 'Code antippen zum Kopieren', + 'app_subtitle' => 'WLAN-Zugangscodes in Sekunden erstellen – für Gäste, Teams und Events.', + 'voucher_validity' => 'Gültig für {minutes} Minuten ab Erstellung', 'voucher_qr_label' => 'QR-Code scannen zum Verbinden', 'voucher_print_btn' => 'Code ausdrucken', 'voucher_no_sites' => 'Keine verfügbaren Sites gefunden.', 'voucher_no_sites_admin'=> 'Klicken Sie hier, um Sites anzulegen', - 'voucher_no_sites_user' => 'Ihr Konto hat noch keinen Site-Zugriff. Bitte kontaktieren Sie Ihren Administrator, um Berechtigungen zu erhalten.', + 'voucher_no_sites_user' => 'Bitte kontaktieren Sie Ihren Administrator.', 'voucher_template_select'=> '-- Kein Profil (manuell) --', 'voucher_template_label'=> 'Schnellprofil (optional)', @@ -119,6 +124,7 @@ return [ 'bulk_success' => '{count} Vouchers erfolgreich erstellt!', 'bulk_print_all' => 'Alle ausdrucken', 'bulk_results' => 'Erstellte Vouchers ({count})', + 'bulk_results_hint' => 'Alle Codes sind sofort gültig und können gedruckt oder kopiert werden.', // Templates 'templates_title' => 'Voucher-Profile', @@ -214,6 +220,323 @@ return [ // Settings 'settings_title' => 'Einstellungen', 'settings_subtitle' => 'System-Konfiguration und Personalisierung', + 'settings_tab_branding' => 'Design', + 'settings_branding_intro' => 'Farben und Formen der gesamten Oberfläche – Frontend wie Administration.', + 'settings_brand_accent' => 'Akzentfarbe (hell)', + 'settings_brand_accent_hint' => 'Buttons, aktive Navigation, Links. Abgeleitete Töne werden automatisch berechnet.', + 'settings_brand_accent_dark' => 'Akzentfarbe (Dark Mode)', + 'settings_brand_accent_dark_hint' => 'Im Dark Mode meist eine hellere Variante der Grundfarbe.', + 'settings_brand_gradient_from'=> 'Markenverlauf: Start', + 'settings_brand_gradient_to' => 'Markenverlauf: Ende', + 'settings_brand_gradient_hint'=> 'Für Logo-Kachel, Avatare und die Voucher-Karte.', + 'settings_brand_radius' => 'Eckenradius', + 'settings_brand_radius_sharp' => 'Kantig', + 'settings_brand_radius_default'=> 'Standard', + 'settings_brand_radius_round' => 'Rund', + 'settings_brand_preview' => 'Vorschau', + 'settings_brand_link' => 'Beispiel-Link', + 'settings_image_url_placeholder' => 'https://… oder Datei hochladen', + 'settings_image_remove' => 'Bild entfernen', + 'settings_upload_hint' => 'PNG, JPG, WEBP, GIF oder SVG – maximal 3 MB.', + 'upload_error_generic' => 'Die Datei konnte nicht hochgeladen werden.', + 'upload_error_size' => 'Die Datei ist zu groß (maximal 3 MB).', + 'upload_error_type' => 'Dieser Dateityp wird nicht unterstützt.', + 'upload_error_dir' => 'Der Ordner uploads/ ist nicht beschreibbar.', + 'settings_tab_login' => 'Login-Seite', + 'settings_login_intro' => 'Aussehen und Texte der Anmeldeseite. Leere Felder verwenden die Standardwerte.', + 'settings_login_panel' => 'Linke Bildspalte (Split-Screen) anzeigen', + 'settings_login_brand' => 'Firmenname', + 'settings_login_brand_hint' => 'Leer = Anwendungstitel wird verwendet.', + 'settings_login_logo' => 'Logo (URL)', + 'settings_login_logo_hint' => 'Leer = allgemeines Logo aus dem Tab „Allgemein".', + 'settings_login_claim_title' => 'Überschrift', + 'settings_login_claim_text' => 'Beschreibungstext', + 'settings_login_features' => 'Stichpunkte', + 'settings_login_features_hint'=> 'Ein Stichpunkt pro Zeile. Leer lassen, um keine Liste anzuzeigen.', + 'settings_login_footer' => 'Fußzeile', + 'settings_login_bg_image' => 'Hintergrundbild (URL)', + 'settings_login_bg_image_hint'=> 'Optional. Wird formatfüllend angezeigt; ohne Bild gilt der Farbverlauf.', + 'settings_login_bg_from' => 'Verlauf: Startfarbe', + 'settings_login_bg_to' => 'Verlauf: Endfarbe', + 'settings_login_overlay' => 'Abdunklung des Bildes (%)', + 'settings_login_overlay_hint' => '0–90 %. Höhere Werte machen den Text auf hellen Bildern besser lesbar.', + 'settings_login_preview' => 'Vorschau öffnen', + 'api_title' => 'API-Schlüssel', + 'api_subtitle' => 'Zugänge für externe Systeme – mit Scope und Rate-Limit.', + 'api_new_key' => 'Neuer Schlüssel', + 'api_new_key_hint' => 'Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.', + 'api_create_title' => 'Neuen API-Schlüssel erstellen', + 'api_label_name' => 'Bezeichnung', + 'api_name_placeholder' => 'z.B. Buchungssystem, Terminal Foyer', + 'api_label_scope' => 'Berechtigung', + 'api_scope_write' => 'Lesen + Erstellen', + 'api_scope_read' => 'Nur Lesen', + 'api_scope_write_short' => 'Lesen+Erstellen', + 'api_scope_read_short' => 'nur Lesen', + 'api_label_limit' => 'Limit (Anfr./min)', + 'api_limit_title' => '0 = unbegrenzt', + 'api_existing' => 'Vorhandene Schlüssel', + 'api_none' => 'Noch keine API-Schlüssel angelegt.', + 'api_col_prefix' => 'Präfix', + 'api_col_scope' => 'Scope', + 'api_col_limit' => 'Limit', + 'api_col_last_used' => 'Zuletzt genutzt', + 'api_col_created_by' => 'Erstellt von', + 'api_state_active' => 'aktiv', + 'api_state_blocked' => 'gesperrt', + 'api_action_block' => 'Sperren', + 'api_action_unblock' => 'Aktivieren', + 'api_delete_confirm' => 'Schlüssel löschen?', + 'api_usage' => 'Verwendung', + 'api_usage_hint' => 'Authentifizierung per Header', + 'api_openapi' => 'OpenAPI-Spezifikation (Import in Postman/Swagger):', + 'api_created' => 'API-Schlüssel erstellt.', + 'api_deleted' => 'API-Schlüssel gelöscht.', + 'api_toggled' => 'Status geändert.', + 'api_name_required' => 'Bitte eine Bezeichnung angeben.', + 'api_created_once' => 'API-Schlüssel erstellt. Bitte JETZT kopieren – er wird nur einmal angezeigt!', + 'api_status_updated' => 'Status aktualisiert.', + 'int_title' => 'Integration & Wartung', + 'int_subtitle' => 'SSO, Webhooks, SMS-Versand und Aufbewahrungsfristen.', + 'int_security' => 'Sicherheitsrichtlinie', + 'int_security_hint' => 'Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.', + 'int_enforce_2fa' => '2FA für Administratoren verpflichtend', + 'int_daily_limit' => 'Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)', + 'int_session_driver' => 'Session-Speicher', + 'int_session_php' => 'PHP-Standard (Dateien)', + 'int_session_db' => 'Datenbank (ermöglicht „überall abmelden")', + 'int_captcha' => 'Captcha im öffentlichen Modus', + 'int_captcha_off' => 'Aus', + 'int_captcha_math' => 'Rechenaufgabe (ohne externen Dienst)', + 'int_secret_set' => ' (gesetzt)', + 'int_secret_placeholder' => '••••••• (leer = unverändert)', + 'int_proxy' => 'Reverse-Proxy', + 'int_proxy_hint' => 'IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus', + 'int_proxy_hint2' => 'für Rate-Limit & Audit verwendet.', + 'int_webhook' => 'Webhook-Benachrichtigungen', + 'int_webhook_hint' => 'Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.', + 'int_webhook_active' => 'Webhook aktiv', + 'int_webhook_url' => 'Webhook-URL', + 'int_webhook_test' => 'Test senden', + 'int_sms' => 'SMS-Versand (Twilio)', + 'int_sms_hint' => 'Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.', + 'int_sms_active' => 'SMS-Versand aktiv', + 'int_sms_from' => 'Absender (From)', + 'int_sso' => 'Single Sign-On (OpenID Connect)', + 'int_sso_hint' => 'Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI:', + 'int_sso_active' => 'OIDC-Login aktiv', + 'int_sso_button' => 'Button-Text', + 'int_cleanup' => 'Datenhaltung & Cleanup (DSGVO)', + 'int_cleanup_hint' => 'Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per', + 'int_cleanup_hint2' => '(täglich empfohlen).', + 'int_cleanup_last' => 'Letzter Lauf:', + 'int_cleanup_expired' => 'Abgelaufene Voucher', + 'int_cleanup_audit' => 'Audit-Log', + 'int_cleanup_logins' => 'Login-Versuche', + 'int_saved' => 'Einstellungen gespeichert.', + 'int_webhook_sent' => 'Test-Webhook gesendet.', + 'int_webhook_failed' => 'Test-Webhook fehlgeschlagen.', + 'int_webhook_test_sent' => 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).', + 'sec_token_invalid' => 'Ungültiges Sicherheits-Token', + 'sec_setup_expired' => 'Setup abgelaufen, bitte erneut starten.', + 'sec_code_invalid' => 'Code ungültig. Bitte erneut versuchen.', + 'sec_enabled' => 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!', + 'sec_sessions_closed' => 'Alle anderen Sitzungen wurden beendet.', + 'sec_codes_new' => 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.', + 'sec_disabled' => 'Zwei-Faktor-Authentifizierung wurde deaktiviert.', + 'backup_choose_file' => 'Bitte eine Backup-Datei auswählen.', + 'backup_invalid_file' => 'Ungültige oder fremde Backup-Datei.', + 'sec_title' => 'Zwei-Faktor-Authentifizierung', + 'sec_account' => 'Konto:', + 'sec_required_hint' => 'Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.', + 'sec_recovery_codes' => 'Recovery-Codes', + 'sec_recovery_hint' => 'Bewahren Sie diese sicher auf. Jeder Code funktioniert einmal, falls Sie keinen Zugriff auf Ihre App haben.', + 'sec_unavailable' => 'Nicht verfügbar', + 'sec_m365_hint' => 'Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.', + 'sec_active' => 'Aktiv', + 'sec_inactive' => 'Inaktiv', + 'sec_active_hint' => 'Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.', + 'sec_codes_left' => 'Verbleibende Recovery-Codes:', + 'sec_regen_codes' => 'Recovery-Codes neu erzeugen', + 'sec_disable_confirm' => '2FA wirklich deaktivieren?', + 'sec_disable' => '2FA deaktivieren', + 'sec_step_1' => 'Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator …)', + 'sec_step_2' => 'QR-Code scannen oder Secret manuell eingeben', + 'sec_step_3' => 'Den angezeigten 6-stelligen Code unten eingeben', + 'sec_code_label' => '6-stelliger Code', + 'sec_enable' => '2FA aktivieren', + 'sec_sessions' => 'Aktive Sitzungen:', + 'sec_logout_others_confirm' => 'Alle anderen Sitzungen abmelden?', + 'sec_logout_others' => 'Auf allen anderen Geräten abmelden', + 'import_title' => 'Voucher-Import', + 'import_subtitle' => 'Mehrere Vouchers auf einmal aus einer CSV-Liste erstellen.', + 'import_card_title' => 'Mehrere Voucher erstellen', + 'import_format_hint' => 'Eine Zeile pro Voucher:', + 'import_format_hint2' => '– MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:', + 'import_site' => 'Standort', + 'import_file' => 'CSV-Datei (optional)', + 'import_paste' => '… oder direkt einfügen', + 'import_confirm' => 'Import jetzt starten?', + 'import_submit' => 'Importieren', + 'import_result' => 'Ergebnis', + 'import_col_code' => 'Code / Fehler', + 'import_created' => '{count} Voucher erstellt.', + 'backup_title' => 'Backup & Restore', + 'backup_subtitle' => 'Konfiguration und Daten sichern oder wiederherstellen.', + 'backup_export' => 'Export', + 'backup_export_hint' => 'Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem', + 'backup_export_hint2' => 'dieser Installation verschlüsselt – ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.', + 'backup_export_btn' => 'Konfiguration exportieren', + 'backup_import' => 'Import / Restore', + 'backup_import_hint' => 'Vorhandene Sites werden anhand von Name + Site-ID aktualisiert, neue hinzugefügt. Profile werden nur angelegt, wenn der Name noch nicht existiert. Der Cron-Token wird nie überschrieben.', + 'backup_opt_settings' => 'Einstellungen', + 'backup_opt_sites' => 'Sites', + 'backup_opt_templates' => 'Voucher-Profile', + 'backup_import_confirm' => 'Import jetzt durchführen?', + 'backup_imported' => 'Import abgeschlossen: {settings} Einstellungen, {sites} Sites, {templates} Profile.', + 'rep_title' => 'Reporting', + 'rep_subtitle' => 'Auswertungen nach Zeitraum, Site und Benutzer.', + 'rep_period' => 'Zeitraum', + 'rep_days' => 'Tage', + 'rep_csv_daily' => 'CSV (täglich)', + 'rep_csv_site' => 'CSV (pro Site)', + 'rep_csv_user' => 'CSV (pro Nutzer)', + 'rep_print' => 'Drucken/PDF', + 'rep_total' => 'Vouchers gesamt', + 'rep_in_period' => 'In {days} Tagen erstellt', + 'rep_chart_title' => 'Erstellte Voucher ({days} Tage)', + 'rep_per_site' => 'Pro Site', + 'rep_top_users' => 'Top-Nutzer', + 'rep_col_created' => 'Voucher erstellt', + 'rep_no_data' => 'Keine Daten', + 'label_total' => 'Gesamt', + 'label_user' => 'Benutzer', + 'js_confirm_delete_voucher' => 'Voucher wirklich löschen?', + 'js_confirm_delete_user' => 'Benutzer wirklich löschen?', + 'js_confirm_delete_site' => 'Möchten Sie diese Site wirklich löschen?', + 'js_confirm_delete_template' => 'Profil wirklich löschen?', + 'js_confirm_delete_token' => 'Token wirklich löschen?', + 'js_error' => 'Fehler', + 'js_enter_email' => 'Bitte E-Mail eingeben.', + 'js_running' => 'Läuft...', + 'js_run_now' => 'Jetzt ausführen', + 'js_copied' => 'Kopiert!', + 'js_code_copied' => 'Code kopiert!', + 'js_click_to_copy' => 'Klicken zum Kopieren', + 'settings_defaults_hint' => 'Diese Werte werden als Vorgabe im Voucher-Formular verwendet.', + 'settings_cron_what' => 'Was macht der Cron-Job?', + 'settings_smtp_from_name' => 'Absender Name', + 'settings_tpl_voucher_mail' => 'Voucher E-Mail', + 'settings_tpl_user_notify' => 'Benutzer-Benachrichtigung', + 'settings_editor_hint' => 'Der Editor (TinyMCE, GPL-Variante) wird lokal aus', + 'settings_editor_hint2' => 'geladen – es werden keine externen Dienste aufgerufen.', + 'settings_print_template' => 'HTML Template für Voucher-Druck', + 'settings_leave_empty' => 'Leer = nicht ändern', + 'sites_id_hint' => 'Zu finden in der UniFi Controller URL', + 'sites_pw_unchanged' => 'Leer lassen = nicht ändern', + 'templates_minutes_hint' => '480 = 8 Stunden', + 'templates_desc_placeholder' => 'Kurze Beschreibung für Ihr Team', + 'audit_all_users' => 'Alle Benutzer', + 'label_expires_at' => 'Gültig bis:', + 'label_site_colon' => 'Standort:', + 'audit_action_voucher_created' => 'Voucher erstellt', + 'audit_action_voucher_bulk' => 'Bulk Voucher', + 'audit_action_user_login' => 'Login', + 'audit_action_user_logout' => 'Logout', + 'audit_action_user_created' => 'Benutzer erstellt', + 'audit_action_user_updated' => 'Benutzer geändert', + 'audit_action_user_deleted' => 'Benutzer gelöscht', + 'audit_action_site_added' => 'Site hinzugefügt', + 'audit_action_site_updated' => 'Site geändert', + 'audit_action_site_deleted' => 'Site gelöscht', + 'audit_action_settings_saved' => 'Einstellungen gespeichert', + 'audit_action_password_reset' => 'Passwort-Reset', + 'audit_action_template_created' => 'Profil erstellt', + 'audit_action_template_updated' => 'Profil geändert', + 'audit_action_template_deleted' => 'Profil gelöscht', + 'audit_page_info' => 'Seite {page} von {pages} ({total} Einträge)', + 'a11y_skip' => 'Zum Inhalt springen', + 'a11y_theme' => 'Darstellung umschalten (hell/dunkel)', + 'a11y_menu' => 'Navigation öffnen', + 'a11y_language' => 'Sprache', + 'a11y_notifications' => 'Benachrichtigungen', + 'audit_system_anon' => 'System/Anonym', + 'vouchers_resend' => 'Per E-Mail senden', + 'label_minutes_short' => 'Min.', + 'js_copy' => 'Kopieren', + 'settings_placeholders' => 'Platzhalter:', + 'settings_card_hint' => '{VOUCHER_CARD} rendert den Code als hervorgehobene Karte im Marken-Layout.', + 'settings_qr_hint' => '{QR_CODE} fügt einen QR-Code mit dem Voucher-Code ein.', + 'print_valid_until' => 'Gültig bis', + 'print_devices' => 'Geräte', + 'credit_by' => 'Entwickelt von', + 'kiosk_default_headline' => 'Kostenloses Gäste-WLAN', + 'kiosk_default_subline' => 'Tippen Sie auf den Knopf – Sie erhalten sofort einen persönlichen Zugangscode.', + 'kiosk_button' => 'Zugangscode holen', + 'kiosk_working' => 'Einen Moment …', + 'kiosk_ready' => 'Ihr Zugangscode', + 'kiosk_scan_code' => 'QR-Code scannen oder Code eintippen', + 'kiosk_reset_in' => 'Der Bildschirm wird in {seconds} Sekunden zurückgesetzt.', + 'kiosk_done' => 'Fertig', + 'kiosk_phone_hint' => 'Oder mit dem Handy scannen und dort öffnen', + 'kiosk_error_cooldown' => 'Gerade wurde ein Code ausgegeben. Bitte {seconds} Sekunden warten.', + 'kiosk_error_limit' => 'Für heute sind keine Zugänge mehr verfügbar. Bitte wenden Sie sich an den Empfang.', + 'kiosk_error_generic' => 'Der Zugang konnte gerade nicht erstellt werden. Bitte erneut versuchen.', + 'kiosk_unknown' => 'Diese Seite ist nicht verfügbar', + 'kiosk_unknown_hint' => 'Der Link ist ungültig oder wurde deaktiviert.', + 'audit_action_voucher_kiosk' => 'Voucher am Display geholt', + 'nav_kiosks' => 'Display-Seiten', + 'kiosks_title' => 'Display-Seiten', + 'kiosks_subtitle' => 'Öffentliche Seiten für Bildschirme und Tablets – Gäste holen sich den Zugang selbst.', + 'kiosks_add' => 'Display-Seite anlegen', + 'kiosks_edit' => 'Display-Seite bearbeiten', + 'kiosks_empty' => 'Noch keine Display-Seite angelegt.', + 'kiosks_no_sites' => 'Legen Sie zuerst eine Site an, dann können Sie dafür eine Display-Seite erstellen.', + 'kiosks_name' => 'Bezeichnung', + 'kiosks_name_placeholder' => 'z.B. Empfang Erdgeschoss', + 'kiosks_name_hint' => 'Erscheint im Audit-Log und als Voucher-Name.', + 'kiosks_template' => 'Voucher-Profil', + 'kiosks_no_template' => 'Standardwerte verwenden', + 'kiosks_template_hint' => 'Bestimmt Laufzeit, Geräteanzahl und Bandbreite der ausgegebenen Codes.', + 'kiosks_headline' => 'Überschrift auf dem Bildschirm', + 'kiosks_subline' => 'Text darunter', + 'kiosks_daily_limit' => 'Codes pro Tag', + 'kiosks_daily_limit_hint' => '0 = unbegrenzt. Schützt vor Missbrauch, wenn der Link weitergegeben wird.', + 'kiosks_cooldown' => 'Wartezeit (Sekunden)', + 'kiosks_cooldown_hint' => 'Abstand zwischen zwei Codes an diesem Display.', + 'kiosks_display' => 'Anzeigedauer (Sekunden)', + 'kiosks_display_hint' => 'Danach springt der Bildschirm zurück auf den Startbildschirm.', + 'kiosks_active' => 'Display-Seite aktiv', + 'kiosks_link' => 'Öffentlicher Link', + 'kiosks_open' => 'Öffnen', + 'kiosks_qr' => 'QR-Code', + 'kiosks_qr_hint' => 'Am Bildschirm aufhängen oder abfotografieren, um die Seite auf einem Tablet zu öffnen.', + 'kiosks_today' => 'heute', + 'kiosks_total' => 'insgesamt', + 'kiosks_renew' => 'Link erneuern', + 'kiosks_renew_confirm' => 'Neuen Link erzeugen? Der bisherige Link funktioniert danach nicht mehr.', + 'kiosks_delete_confirm' => 'Display-Seite wirklich löschen?', + 'kiosks_added' => 'Display-Seite angelegt.', + 'kiosks_updated' => 'Display-Seite gespeichert.', + 'kiosks_deleted' => 'Display-Seite gelöscht.', + 'kiosks_token_renewed' => 'Neuer Link erzeugt – der alte ist ab sofort ungültig.', + 'audit_action_kiosk_created' => 'Display-Seite angelegt', + 'audit_action_kiosk_updated' => 'Display-Seite geändert', + 'audit_action_kiosk_deleted' => 'Display-Seite gelöscht', + 'kiosks_appearance' => 'Erscheinungsbild', + 'kiosks_logo' => 'Logo', + 'kiosks_logo_hint' => 'Leer = allgemeines Logo aus den Einstellungen.', + 'kiosks_background' => 'Hintergrundbild', + 'kiosks_background_hint' => 'Formatfüllend hinter der Karte – z.B. ein Foto des Hauses.', + 'kiosks_overlay' => 'Abdunklung des Bildes (%)', + 'kiosks_overlay_hint' => '0–90 %. Höhere Werte machen die Karte auf hellen Bildern lesbarer.', + 'kiosks_accent' => 'Akzentfarbe', + 'kiosks_accent_default' => 'Standardfarbe verwenden', + 'kiosks_accent_hint' => 'Färbt den Knopf auf dieser Seite. Leer = Farbe aus dem Design-Tab.', + 'kiosks_card_style' => 'Karte', + 'kiosks_card_light' => 'Hell', + 'kiosks_card_dark' => 'Dunkel', + 'kiosks_card_hint' => 'Auf Fotos wirkt die dunkle Karte meist ruhiger.', 'settings_tab_general' => 'Allgemein', 'settings_tab_defaults' => 'Voucher-Standards', 'settings_tab_cron' => 'Cron-Sync', @@ -224,8 +547,8 @@ return [ 'settings_tab_password' => 'Passwort', 'settings_saved' => 'Einstellungen erfolgreich gespeichert!', 'settings_app_title' => 'Anwendungs-Titel *', - 'settings_logo_url' => 'Logo-URL', - 'settings_favicon_url' => 'Favicon-URL', + 'settings_logo_url' => 'Logo', + 'settings_favicon_url' => 'Favicon', 'settings_favicon_hint' => 'Icon im Browser-Tab (.ico, .png, .svg)', 'settings_instr_header' => 'Anleitung - Überschrift', 'settings_instr_text' => 'Anleitung - Text', @@ -244,13 +567,18 @@ return [ // Login / Auth 'login_title' => 'Anmelden', + 'auth_claim_title' => 'WLAN-Gastzugänge, zentral verwaltet.', + 'auth_claim_text' => 'Vouchers für alle UniFi-Sites erstellen, versenden und auswerten – aus einer Oberfläche.', + 'auth_feature_1' => 'Voucher per E-Mail, SMS oder QR-Code', + 'auth_feature_2' => 'Rollen, 2FA und vollständiges Audit-Log', + 'auth_feature_3' => 'Live-Statistiken je Site und Benutzer', 'login_subtitle' => 'Melden Sie sich an, um fortzufahren', 'login_email' => 'E-Mail', 'login_password' => 'Passwort', 'login_btn' => 'Anmelden', 'login_ms' => 'Mit Microsoft anmelden', 'login_local' => 'Mit Benutzername und Passwort anmelden', - 'login_back' => '← Zurück zur Code-Erstellung', + 'login_back' => 'Zurück zur Code-Erstellung', 'login_forgot' => 'Passwort vergessen?', 'login_error_empty' => 'Bitte E-Mail und Passwort eingeben', 'login_error_rate' => 'Zu viele Fehlversuche. Bitte warten Sie 10 Minuten.', @@ -262,7 +590,7 @@ return [ 'reset_email_label' => 'E-Mail-Adresse', 'reset_send_btn' => 'Reset-Link senden', 'reset_success' => 'Falls ein Konto mit dieser E-Mail existiert, erhalten Sie in Kürze eine E-Mail.', - 'reset_back_login' => '← Zurück zum Login', + 'reset_back_login' => 'Zurück zum Login', 'reset_new_pw' => 'Neues Passwort festlegen', 'reset_new_pw_label'=> 'Neues Passwort', 'reset_confirm_label'=> 'Passwort bestätigen', @@ -292,64 +620,4 @@ return [ 'never' => 'Noch nie', 'unknown' => 'Unbekannt', 'or' => 'oder', - - // Durations (human readable) - 'dur_minutes' => '{n} Minuten', - 'dur_hour_one' => '1 Stunde', - 'dur_hours' => '{n} Stunden', - 'dur_day_one' => '1 Tag', - 'dur_days' => '{n} Tage', - - // Voucher creation (messages) - 'voucher_created_ok' => 'Voucher erfolgreich erstellt!', - 'voucher_created_mail' => 'Voucher erstellt. E-Mail versendet.', - 'voucher_name_default' => 'Gast', - 'voucher_deleted' => 'Voucher erfolgreich gelöscht!', - 'voucher_delete_failed'=> 'Voucher konnte nicht gelöscht werden', - 'error_rate_limited' => 'Zu viele Anfragen. Bitte warten Sie einen Moment.', - - // Clipboard / UI - 'toast_copied' => 'Kopiert!', - 'click_to_copy' => 'Klicken zum Kopieren', - 'copy_hint' => 'Code anklicken zum Kopieren', - 'loading' => 'Lade…', - 'syncing' => 'Synchronisiere…', - 'vouchers_loaded' => '{count} Vouchers geladen', - - // Confirm dialogs - 'confirm_delete_user' => 'Benutzer wirklich löschen?', - 'confirm_delete_site' => 'Möchten Sie diese Site wirklich löschen?', - 'confirm_delete_template' => 'Profil wirklich löschen?', - 'confirm_delete_voucher' => 'Voucher wirklich löschen?', - 'confirm_send_reset' => 'Passwort-Reset-Link senden an {email}?', - 'confirm_delete_token' => 'Token wirklich löschen?', - - // Admin messages - 'users_status_updated' => 'Benutzer-Status aktualisiert!', - 'sites_status_updated' => 'Site-Status aktualisiert!', - 'error_self_delete' => 'Sie können sich nicht selbst löschen', - 'error_self_deactivate' => 'Sie können sich nicht selbst deaktivieren', - 'error_self_demote' => 'Sie können sich nicht selbst die Administrator-Rechte entziehen', - 'error_pw_mismatch' => 'Passwörter stimmen nicht überein', - 'error_pw_current' => 'Aktuelles Passwort ist falsch', - 'error_email_exists' => 'E-Mail bereits vorhanden', - 'error_user_create' => 'Benutzer konnte nicht erstellt werden', - 'reset_link_sent' => 'Passwort-Reset-Link wurde an {email} gesendet.', - 'reset_link_failed' => 'Benutzer nicht gefunden oder kein lokales Passwort.', - 'cron_token_generated' => 'Neuer Cron-Token wurde generiert!', - 'cron_token_deleted' => 'Cron-Token wurde gelöscht!', - 'm365_secret_hint' => 'Leer lassen = nicht ändern. Zum Deaktivieren des M365-Logins die Client ID leeren.', - - // SSL-Verifizierung - 'sites_ssl_verify' => 'SSL-Zertifikat des Controllers prüfen', - 'sites_ssl_verify_hint' => 'Nur aktivieren, wenn der Controller ein gültiges Zertifikat besitzt (UniFi nutzt standardmäßig self-signed).', - 'smtp_verify_ssl' => 'SSL-Zertifikat des SMTP-Servers prüfen', - - // Site connection test - 'site_test_btn' => 'Verbindung testen', - 'site_test_ok' => 'Verbindung erfolgreich', - 'site_test_fail' => 'Verbindung fehlgeschlagen', - - // System warnings - 'crypto_warning' => 'Verschlüsselung inaktiv: In der config.php ist kein gültiger APP_KEY gesetzt. UniFi-Passwörter werden im Klartext gespeichert. Bei einem Serverumzug mit verändertem APP_KEY schlagen Controller-Logins still fehl.', ]; diff --git a/lang/en.php b/lang/en.php index 6fd4f28..b3bf315 100644 --- a/lang/en.php +++ b/lang/en.php @@ -1,6 +1,9 @@ 'Overview', + 'nav_group_manage' => 'Management', + 'nav_group_system' => 'System', 'nav_dashboard' => 'Dashboard', 'nav_sites' => 'Manage Sites', 'nav_users' => 'Manage Users', @@ -98,13 +101,15 @@ return [ 'voucher_email_hint' => 'guest@example.com', 'voucher_create_btn' => 'Create Voucher', 'voucher_creating' => 'Creating Voucher...', - 'voucher_success_title' => '✓ Your Access Code', - 'voucher_validity' => 'Valid for {duration} from creation', + 'voucher_success_title' => 'Access code created', + 'voucher_copy_hint' => 'Tap the code to copy it', + 'app_subtitle' => 'Create Wi-Fi access codes in seconds – for guests, teams and events.', + 'voucher_validity' => 'Valid for {minutes} minutes from creation', 'voucher_qr_label' => 'Scan QR code to connect', 'voucher_print_btn' => 'Print Code', 'voucher_no_sites' => 'No available sites found.', 'voucher_no_sites_admin'=> 'Click here to create sites', - 'voucher_no_sites_user' => 'Your account has no site access yet. Please contact your administrator to be granted permissions.', + 'voucher_no_sites_user' => 'Please contact your administrator.', 'voucher_template_select'=> '-- No Profile (manual) --', 'voucher_template_label'=> 'Quick Profile (optional)', @@ -119,6 +124,7 @@ return [ 'bulk_success' => '{count} vouchers created successfully!', 'bulk_print_all' => 'Print All', 'bulk_results' => 'Created Vouchers ({count})', + 'bulk_results_hint' => 'All codes are valid immediately and can be printed or copied.', // Templates 'templates_title' => 'Voucher Profiles', @@ -214,6 +220,323 @@ return [ // Settings 'settings_title' => 'Settings', 'settings_subtitle' => 'System configuration and customization', + 'settings_tab_branding' => 'Design', + 'settings_branding_intro' => 'Colours and shapes for the whole interface – front end and administration.', + 'settings_brand_accent' => 'Accent colour (light)', + 'settings_brand_accent_hint' => 'Buttons, active navigation, links. Derived shades are calculated automatically.', + 'settings_brand_accent_dark' => 'Accent colour (dark mode)', + 'settings_brand_accent_dark_hint' => 'Usually a lighter variant of the base colour for dark mode.', + 'settings_brand_gradient_from'=> 'Brand gradient: start', + 'settings_brand_gradient_to' => 'Brand gradient: end', + 'settings_brand_gradient_hint'=> 'Used for the logo tile, avatars and the voucher card.', + 'settings_brand_radius' => 'Corner radius', + 'settings_brand_radius_sharp' => 'Sharp', + 'settings_brand_radius_default'=> 'Default', + 'settings_brand_radius_round' => 'Round', + 'settings_brand_preview' => 'Preview', + 'settings_brand_link' => 'Example link', + 'settings_image_url_placeholder' => 'https://… or upload a file', + 'settings_image_remove' => 'Remove image', + 'settings_upload_hint' => 'PNG, JPG, WEBP, GIF or SVG – 3 MB maximum.', + 'upload_error_generic' => 'The file could not be uploaded.', + 'upload_error_size' => 'The file is too large (3 MB maximum).', + 'upload_error_type' => 'This file type is not supported.', + 'upload_error_dir' => 'The uploads/ directory is not writable.', + 'settings_tab_login' => 'Login page', + 'settings_login_intro' => 'Appearance and wording of the sign-in page. Empty fields fall back to the defaults.', + 'settings_login_panel' => 'Show left image column (split screen)', + 'settings_login_brand' => 'Company name', + 'settings_login_brand_hint' => 'Empty = application title is used.', + 'settings_login_logo' => 'Logo (URL)', + 'settings_login_logo_hint' => 'Empty = general logo from the "General" tab.', + 'settings_login_claim_title' => 'Headline', + 'settings_login_claim_text' => 'Description', + 'settings_login_features' => 'Bullet points', + 'settings_login_features_hint'=> 'One bullet per line. Leave empty to hide the list.', + 'settings_login_footer' => 'Footer', + 'settings_login_bg_image' => 'Background image (URL)', + 'settings_login_bg_image_hint'=> 'Optional. Displayed full-bleed; without an image the gradient is used.', + 'settings_login_bg_from' => 'Gradient: start colour', + 'settings_login_bg_to' => 'Gradient: end colour', + 'settings_login_overlay' => 'Image dimming (%)', + 'settings_login_overlay_hint' => '0–90%. Higher values keep text readable on bright images.', + 'settings_login_preview' => 'Open preview', + 'api_title' => 'API keys', + 'api_subtitle' => 'Access for external systems – with scope and rate limit.', + 'api_new_key' => 'New key', + 'api_new_key_hint' => 'Copy it now – for security reasons it will not be shown again.', + 'api_create_title' => 'Create a new API key', + 'api_label_name' => 'Label', + 'api_name_placeholder' => 'e.g. booking system, lobby terminal', + 'api_label_scope' => 'Permission', + 'api_scope_write' => 'Read + create', + 'api_scope_read' => 'Read only', + 'api_scope_write_short' => 'Read+create', + 'api_scope_read_short' => 'read only', + 'api_label_limit' => 'Limit (req./min)', + 'api_limit_title' => '0 = unlimited', + 'api_existing' => 'Existing keys', + 'api_none' => 'No API keys created yet.', + 'api_col_prefix' => 'Prefix', + 'api_col_scope' => 'Scope', + 'api_col_limit' => 'Limit', + 'api_col_last_used' => 'Last used', + 'api_col_created_by' => 'Created by', + 'api_state_active' => 'active', + 'api_state_blocked' => 'blocked', + 'api_action_block' => 'Block', + 'api_action_unblock' => 'Activate', + 'api_delete_confirm' => 'Delete key?', + 'api_usage' => 'Usage', + 'api_usage_hint' => 'Authenticate using header', + 'api_openapi' => 'OpenAPI specification (import into Postman/Swagger):', + 'api_created' => 'API key created.', + 'api_deleted' => 'API key deleted.', + 'api_toggled' => 'Status changed.', + 'api_name_required' => 'Please enter a label.', + 'api_created_once' => 'API key created. Copy it NOW – it is shown only once!', + 'api_status_updated' => 'Status updated.', + 'int_title' => 'Integration & maintenance', + 'int_subtitle' => 'SSO, webhooks, SMS delivery and retention periods.', + 'int_security' => 'Security policy', + 'int_security_hint' => 'Enforces two-factor authentication for all administrator accounts (local accounts). Admins without 2FA are sent to the setup on their next action.', + 'int_enforce_2fa' => 'Two-factor authentication mandatory for administrators', + 'int_daily_limit' => 'Daily voucher limit per non-admin user (0 = unlimited)', + 'int_session_driver' => 'Session storage', + 'int_session_php' => 'PHP default (files)', + 'int_session_db' => 'Database (enables "sign out everywhere")', + 'int_captcha' => 'Captcha in public mode', + 'int_captcha_off' => 'Off', + 'int_captcha_math' => 'Arithmetic question (no external service)', + 'int_secret_set' => ' (set)', + 'int_secret_placeholder' => '••••••• (empty = unchanged)', + 'int_proxy' => 'Reverse proxy', + 'int_proxy_hint' => 'IP addresses of trusted proxies (comma separated). Only then is the real client IP taken from', + 'int_proxy_hint2' => 'for rate limiting and the audit log.', + 'int_webhook' => 'Webhook notifications', + 'int_webhook_hint' => 'Slack, Microsoft Teams or generic JSON webhook URL. Triggered when a voucher is created.', + 'int_webhook_active' => 'Webhook active', + 'int_webhook_url' => 'Webhook URL', + 'int_webhook_test' => 'Send test', + 'int_sms' => 'SMS delivery (Twilio)', + 'int_sms_hint' => 'Optionally send voucher codes by SMS. Requires a Twilio account.', + 'int_sms_active' => 'SMS delivery active', + 'int_sms_from' => 'Sender (from)', + 'int_sso' => 'Single sign-on (OpenID Connect)', + 'int_sso_hint' => 'Generic OIDC provider (e.g. Keycloak, Authentik, Google, Auth0). Redirect URI:', + 'int_sso_active' => 'OIDC login active', + 'int_sso_button' => 'Button label', + 'int_cleanup' => 'Data retention & cleanup (GDPR)', + 'int_cleanup_hint' => 'Retention periods in days (0 = disabled). Executed via', + 'int_cleanup_hint2' => '(daily recommended).', + 'int_cleanup_last' => 'Last run:', + 'int_cleanup_expired' => 'Expired vouchers', + 'int_cleanup_audit' => 'Audit log', + 'int_cleanup_logins' => 'Login attempts', + 'int_saved' => 'Settings saved.', + 'int_webhook_sent' => 'Test webhook sent.', + 'int_webhook_failed' => 'Test webhook failed.', + 'int_webhook_test_sent' => 'Test notification sent (provided the webhook is active and the URL valid).', + 'sec_token_invalid' => 'Invalid security token', + 'sec_setup_expired' => 'Setup expired, please start again.', + 'sec_code_invalid' => 'Invalid code. Please try again.', + 'sec_enabled' => 'Two-factor authentication is now active. Please store the recovery codes safely!', + 'sec_sessions_closed' => 'All other sessions have been signed out.', + 'sec_codes_new' => 'New recovery codes generated. The previous ones are now invalid.', + 'sec_disabled' => 'Two-factor authentication has been disabled.', + 'backup_choose_file' => 'Please choose a backup file.', + 'backup_invalid_file' => 'Invalid or foreign backup file.', + 'sec_title' => 'Two-factor authentication', + 'sec_account' => 'Account:', + 'sec_required_hint' => 'For security reasons two-factor authentication is mandatory for administrators. Please set it up now.', + 'sec_recovery_codes' => 'Recovery codes', + 'sec_recovery_hint' => 'Keep them somewhere safe. Each code works once if you lose access to your app.', + 'sec_unavailable' => 'Not available', + 'sec_m365_hint' => 'Your account signs in via Microsoft 365. Two-factor authentication is managed in your Microsoft account.', + 'sec_active' => 'Active', + 'sec_inactive' => 'Inactive', + 'sec_active_hint' => 'Every sign-in additionally asks for a code from your authenticator app.', + 'sec_codes_left' => 'Remaining recovery codes:', + 'sec_regen_codes' => 'Generate new recovery codes', + 'sec_disable_confirm' => 'Really disable two-factor authentication?', + 'sec_disable' => 'Disable two-factor authentication', + 'sec_step_1' => 'Open your authenticator app (Google Authenticator, Authy, Microsoft Authenticator …)', + 'sec_step_2' => 'Scan the QR code or enter the secret manually', + 'sec_step_3' => 'Enter the six-digit code shown below', + 'sec_code_label' => 'Six-digit code', + 'sec_enable' => 'Enable two-factor authentication', + 'sec_sessions' => 'Active sessions:', + 'sec_logout_others_confirm' => 'Sign out all other sessions?', + 'sec_logout_others' => 'Sign out on all other devices', + 'import_title' => 'Voucher import', + 'import_subtitle' => 'Create several vouchers at once from a CSV list.', + 'import_card_title' => 'Create several vouchers', + 'import_format_hint' => 'One line per voucher:', + 'import_format_hint2' => '– max. devices and minutes are optional (defaults apply). 200 lines maximum. Example:', + 'import_site' => 'Site', + 'import_file' => 'CSV file (optional)', + 'import_paste' => '… or paste directly', + 'import_confirm' => 'Start the import now?', + 'import_submit' => 'Import', + 'import_result' => 'Result', + 'import_col_code' => 'Code / error', + 'import_created' => '{count} vouchers created.', + 'backup_title' => 'Backup & restore', + 'backup_subtitle' => 'Back up or restore configuration and data.', + 'backup_export' => 'Export', + 'backup_export_hint' => 'Downloads settings, sites and voucher profiles as JSON. Site passwords stay encrypted with the', + 'backup_export_hint2' => 'of this installation – a restore on an installation with a different APP_KEY cannot decrypt them.', + 'backup_export_btn' => 'Export configuration', + 'backup_import' => 'Import / restore', + 'backup_import_hint' => 'Existing sites are matched by name + site ID and updated, new ones are added. Profiles are only created if the name does not exist yet. The cron token is never overwritten.', + 'backup_opt_settings' => 'Settings', + 'backup_opt_sites' => 'Sites', + 'backup_opt_templates' => 'Voucher profiles', + 'backup_import_confirm' => 'Run the import now?', + 'backup_imported' => 'Import finished: {settings} settings, {sites} sites, {templates} profiles.', + 'rep_title' => 'Reporting', + 'rep_subtitle' => 'Analytics by period, site and user.', + 'rep_period' => 'Period', + 'rep_days' => 'days', + 'rep_csv_daily' => 'CSV (daily)', + 'rep_csv_site' => 'CSV (per site)', + 'rep_csv_user' => 'CSV (per user)', + 'rep_print' => 'Print / PDF', + 'rep_total' => 'Vouchers total', + 'rep_in_period' => 'Created in {days} days', + 'rep_chart_title' => 'Vouchers created ({days} days)', + 'rep_per_site' => 'Per site', + 'rep_top_users' => 'Top users', + 'rep_col_created' => 'Vouchers created', + 'rep_no_data' => 'No data', + 'label_total' => 'Total', + 'label_user' => 'User', + 'js_confirm_delete_voucher' => 'Really delete this voucher?', + 'js_confirm_delete_user' => 'Really delete this user?', + 'js_confirm_delete_site' => 'Do you really want to delete this site?', + 'js_confirm_delete_template' => 'Really delete this profile?', + 'js_confirm_delete_token' => 'Really delete this token?', + 'js_error' => 'Error', + 'js_enter_email' => 'Please enter an email address.', + 'js_running' => 'Running…', + 'js_run_now' => 'Run now', + 'js_copied' => 'Copied!', + 'js_code_copied' => 'Code copied!', + 'js_click_to_copy' => 'Click to copy', + 'settings_defaults_hint' => 'These values are used as defaults in the voucher form.', + 'settings_cron_what' => 'What does the cron job do?', + 'settings_smtp_from_name' => 'Sender name', + 'settings_tpl_voucher_mail' => 'Voucher email', + 'settings_tpl_user_notify' => 'User notification', + 'settings_editor_hint' => 'The editor (TinyMCE, GPL build) is served locally from', + 'settings_editor_hint2' => '– no external services are called.', + 'settings_print_template' => 'HTML template for voucher printing', + 'settings_leave_empty' => 'Empty = leave unchanged', + 'sites_id_hint' => 'Found in the UniFi controller URL', + 'sites_pw_unchanged' => 'Leave empty = unchanged', + 'templates_minutes_hint' => '480 = 8 hours', + 'templates_desc_placeholder' => 'Short description for your team', + 'audit_all_users' => 'All users', + 'label_expires_at' => 'Valid until:', + 'label_site_colon' => 'Site:', + 'audit_action_voucher_created' => 'Voucher created', + 'audit_action_voucher_bulk' => 'Bulk vouchers', + 'audit_action_user_login' => 'Login', + 'audit_action_user_logout' => 'Logout', + 'audit_action_user_created' => 'User created', + 'audit_action_user_updated' => 'User updated', + 'audit_action_user_deleted' => 'User deleted', + 'audit_action_site_added' => 'Site added', + 'audit_action_site_updated' => 'Site updated', + 'audit_action_site_deleted' => 'Site deleted', + 'audit_action_settings_saved' => 'Settings saved', + 'audit_action_password_reset' => 'Password reset', + 'audit_action_template_created' => 'Profile created', + 'audit_action_template_updated' => 'Profile updated', + 'audit_action_template_deleted' => 'Profile deleted', + 'audit_page_info' => 'Page {page} of {pages} ({total} entries)', + 'a11y_skip' => 'Skip to content', + 'a11y_theme' => 'Toggle appearance (light/dark)', + 'a11y_menu' => 'Open navigation', + 'a11y_language' => 'Language', + 'a11y_notifications' => 'Notifications', + 'audit_system_anon' => 'System/anonymous', + 'vouchers_resend' => 'Send by email', + 'label_minutes_short' => 'min', + 'js_copy' => 'Copy', + 'settings_placeholders' => 'Placeholders:', + 'settings_card_hint' => '{VOUCHER_CARD} renders the code as a highlighted card in the branded layout.', + 'settings_qr_hint' => '{QR_CODE} inserts a QR code containing the voucher code.', + 'print_valid_until' => 'Valid until', + 'print_devices' => 'devices', + 'credit_by' => 'Developed by', + 'kiosk_default_headline' => 'Free guest Wi-Fi', + 'kiosk_default_subline' => 'Tap the button – you will get your personal access code right away.', + 'kiosk_button' => 'Get access code', + 'kiosk_working' => 'One moment…', + 'kiosk_ready' => 'Your access code', + 'kiosk_scan_code' => 'Scan the QR code or type the code', + 'kiosk_reset_in' => 'This screen resets in {seconds} seconds.', + 'kiosk_done' => 'Done', + 'kiosk_phone_hint' => 'Or scan with your phone and open it there', + 'kiosk_error_cooldown' => 'A code was just issued. Please wait {seconds} seconds.', + 'kiosk_error_limit' => 'No more access codes available today. Please ask at the reception desk.', + 'kiosk_error_generic' => 'The access code could not be created. Please try again.', + 'kiosk_unknown' => 'This page is not available', + 'kiosk_unknown_hint' => 'The link is invalid or has been deactivated.', + 'audit_action_voucher_kiosk' => 'Voucher taken at display', + 'nav_kiosks' => 'Display pages', + 'kiosks_title' => 'Display pages', + 'kiosks_subtitle' => 'Public pages for screens and tablets – guests get their access themselves.', + 'kiosks_add' => 'Add display page', + 'kiosks_edit' => 'Edit display page', + 'kiosks_empty' => 'No display page created yet.', + 'kiosks_no_sites' => 'Create a site first, then you can add a display page for it.', + 'kiosks_name' => 'Label', + 'kiosks_name_placeholder' => 'e.g. reception ground floor', + 'kiosks_name_hint' => 'Appears in the audit log and as the voucher name.', + 'kiosks_template' => 'Voucher profile', + 'kiosks_no_template' => 'Use default values', + 'kiosks_template_hint' => 'Defines duration, device count and bandwidth of the codes issued.', + 'kiosks_headline' => 'Headline on screen', + 'kiosks_subline' => 'Text below', + 'kiosks_daily_limit' => 'Codes per day', + 'kiosks_daily_limit_hint' => '0 = unlimited. Protects against misuse if the link gets shared.', + 'kiosks_cooldown' => 'Cooldown (seconds)', + 'kiosks_cooldown_hint' => 'Delay between two codes on this display.', + 'kiosks_display' => 'Display duration (seconds)', + 'kiosks_display_hint' => 'After that the screen returns to the start screen.', + 'kiosks_active' => 'Display page active', + 'kiosks_link' => 'Public link', + 'kiosks_open' => 'Open', + 'kiosks_qr' => 'QR code', + 'kiosks_qr_hint' => 'Put it up next to the screen or photograph it to open the page on a tablet.', + 'kiosks_today' => 'today', + 'kiosks_total' => 'in total', + 'kiosks_renew' => 'Renew link', + 'kiosks_renew_confirm' => 'Generate a new link? The previous link will stop working.', + 'kiosks_delete_confirm' => 'Really delete this display page?', + 'kiosks_added' => 'Display page created.', + 'kiosks_updated' => 'Display page saved.', + 'kiosks_deleted' => 'Display page deleted.', + 'kiosks_token_renewed' => 'New link generated – the old one is no longer valid.', + 'audit_action_kiosk_created' => 'Display page created', + 'audit_action_kiosk_updated' => 'Display page updated', + 'audit_action_kiosk_deleted' => 'Display page deleted', + 'kiosks_appearance' => 'Appearance', + 'kiosks_logo' => 'Logo', + 'kiosks_logo_hint' => 'Empty = general logo from the settings.', + 'kiosks_background' => 'Background image', + 'kiosks_background_hint' => 'Displayed full-bleed behind the card – e.g. a photo of the building.', + 'kiosks_overlay' => 'Image dimming (%)', + 'kiosks_overlay_hint' => '0–90%. Higher values keep the card readable on bright images.', + 'kiosks_accent' => 'Accent colour', + 'kiosks_accent_default' => 'Use default colour', + 'kiosks_accent_hint' => 'Colours the button on this page. Empty = colour from the Design tab.', + 'kiosks_card_style' => 'Card', + 'kiosks_card_light' => 'Light', + 'kiosks_card_dark' => 'Dark', + 'kiosks_card_hint' => 'On photos the dark card usually looks calmer.', 'settings_tab_general' => 'General', 'settings_tab_defaults' => 'Voucher Defaults', 'settings_tab_cron' => 'Cron Sync', @@ -224,8 +547,8 @@ return [ 'settings_tab_password' => 'Password', 'settings_saved' => 'Settings saved successfully!', 'settings_app_title' => 'Application Title *', - 'settings_logo_url' => 'Logo URL', - 'settings_favicon_url' => 'Favicon URL', + 'settings_logo_url' => 'Logo', + 'settings_favicon_url' => 'Favicon', 'settings_favicon_hint' => 'Browser tab icon (.ico, .png, .svg)', 'settings_instr_header' => 'Instructions - Headline', 'settings_instr_text' => 'Instructions - Text', @@ -244,13 +567,18 @@ return [ // Login / Auth 'login_title' => 'Sign In', + 'auth_claim_title' => 'Wi-Fi guest access, centrally managed.', + 'auth_claim_text' => 'Create, deliver and analyse vouchers for every UniFi site from a single interface.', + 'auth_feature_1' => 'Vouchers via email, SMS or QR code', + 'auth_feature_2' => 'Roles, 2FA and a complete audit log', + 'auth_feature_3' => 'Live statistics per site and user', 'login_subtitle' => 'Sign in to continue', 'login_email' => 'Email', 'login_password' => 'Password', 'login_btn' => 'Sign In', 'login_ms' => 'Sign in with Microsoft', 'login_local' => 'Sign in with username and password', - 'login_back' => '← Back to Code Creation', + 'login_back' => 'Back to Code Creation', 'login_forgot' => 'Forgot password?', 'login_error_empty' => 'Please enter email and password', 'login_error_rate' => 'Too many failed attempts. Please wait 10 minutes.', @@ -262,7 +590,7 @@ return [ 'reset_email_label' => 'Email Address', 'reset_send_btn' => 'Send Reset Link', 'reset_success' => 'If an account with this email exists, you will receive an email shortly.', - 'reset_back_login' => '← Back to Login', + 'reset_back_login' => 'Back to Login', 'reset_new_pw' => 'Set New Password', 'reset_new_pw_label'=> 'New Password', 'reset_confirm_label'=> 'Confirm Password', @@ -292,64 +620,4 @@ return [ 'never' => 'Never', 'unknown' => 'Unknown', 'or' => 'or', - - // Durations (human readable) - 'dur_minutes' => '{n} minutes', - 'dur_hour_one' => '1 hour', - 'dur_hours' => '{n} hours', - 'dur_day_one' => '1 day', - 'dur_days' => '{n} days', - - // Voucher creation (messages) - 'voucher_created_ok' => 'Voucher created successfully!', - 'voucher_created_mail' => 'Voucher created. Email sent.', - 'voucher_name_default' => 'Guest', - 'voucher_deleted' => 'Voucher deleted successfully!', - 'voucher_delete_failed'=> 'Voucher could not be deleted', - 'error_rate_limited' => 'Too many requests. Please wait a moment.', - - // Clipboard / UI - 'toast_copied' => 'Copied!', - 'click_to_copy' => 'Click to copy', - 'copy_hint' => 'Click a code to copy it', - 'loading' => 'Loading…', - 'syncing' => 'Syncing…', - 'vouchers_loaded' => '{count} vouchers loaded', - - // Confirm dialogs - 'confirm_delete_user' => 'Really delete this user?', - 'confirm_delete_site' => 'Really delete this site?', - 'confirm_delete_template' => 'Really delete this profile?', - 'confirm_delete_voucher' => 'Really delete this voucher?', - 'confirm_send_reset' => 'Send a password reset link to {email}?', - 'confirm_delete_token' => 'Really delete the token?', - - // Admin messages - 'users_status_updated' => 'User status updated!', - 'sites_status_updated' => 'Site status updated!', - 'error_self_delete' => 'You cannot delete yourself', - 'error_self_deactivate' => 'You cannot deactivate yourself', - 'error_self_demote' => 'You cannot remove your own administrator rights', - 'error_pw_mismatch' => 'Passwords do not match', - 'error_pw_current' => 'Current password is incorrect', - 'error_email_exists' => 'Email address already exists', - 'error_user_create' => 'User could not be created', - 'reset_link_sent' => 'Password reset link has been sent to {email}.', - 'reset_link_failed' => 'User not found or no local password set.', - 'cron_token_generated' => 'New cron token generated!', - 'cron_token_deleted' => 'Cron token deleted!', - 'm365_secret_hint' => 'Leave empty to keep the current secret. To disable M365 login, clear the Client ID.', - - // SSL verification - 'sites_ssl_verify' => "Verify the controller's SSL certificate", - 'sites_ssl_verify_hint' => 'Only enable if the controller has a valid certificate (UniFi uses self-signed certificates by default).', - 'smtp_verify_ssl' => "Verify the SMTP server's SSL certificate", - - // Site connection test - 'site_test_btn' => 'Test connection', - 'site_test_ok' => 'Connection successful', - 'site_test_fail' => 'Connection failed', - - // System warnings - 'crypto_warning' => 'Encryption inactive: no valid APP_KEY is set in config.php. UniFi passwords are stored in plain text. If the APP_KEY changes (e.g. after a server move), controller logins will silently fail.', ]; diff --git a/login.php b/login.php index c73b0bd..ccc325d 100644 --- a/login.php +++ b/login.php @@ -6,11 +6,15 @@ 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/Ui.php'; require_once __DIR__ . '/includes/I18n.php'; try { $auth = new Auth(); - if ($auth->isLoggedIn()) { header('Location: index.php'); exit; } + // Angemeldete Admins koennen die Login-Seite mit ?preview=1 ansehen + // (Vorschau aus den Einstellungen), ohne abgemeldet zu werden. + $isPreview = isset($_GET['preview']) && $auth->isLoggedIn() && $auth->isAdmin(); + if ($auth->isLoggedIn() && !$isPreview) { header('Location: index.php'); exit; } } catch (Exception $e) { die('Fehler beim Initialisieren: ' . $e->getMessage()); } @@ -67,7 +71,6 @@ try { $db = Database::getInstance(); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $logoUrl = $db->getSetting('logo_url', ''); - $faviconUrl = $db->getSetting('favicon_url', ''); $m365ClientId = $db->getSetting('m365_client_id', ''); $m365ClientSecret = $db->getSetting('m365_client_secret', ''); @@ -118,6 +121,34 @@ try { $showLocalLogin = isset($_GET['local']) && $_GET['local'] === '1'; + // --- Individualisierung der Login-Seite ------------------------------- + // Alle Werte sind optional; leer bedeutet "Standard verwenden". + $loginBrand = $db->getSetting('login_brand_name', '') ?: $appTitle; + $loginLogo = $db->getSetting('login_logo_url', '') ?: $logoUrl; + $showPanel = $db->getSetting('login_panel_enabled', '1') === '1'; + $claimTitle = $db->getSetting('login_claim_title', '') ?: __('auth_claim_title'); + $claimText = $db->getSetting('login_claim_text', '') ?: __('auth_claim_text'); + + $featureRaw = trim((string)$db->getSetting('login_features', '')); + if ($featureRaw !== '') { + $loginFeatures = array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $featureRaw)))); + } else { + $loginFeatures = [__('auth_feature_1'), __('auth_feature_2'), __('auth_feature_3')]; + } + + $loginFooter = $db->getSetting('login_footer', '') ?: ('© ' . date('Y') . ' ' . $loginBrand); + $loginBgImage = trim((string)$db->getSetting('login_bg_image', '')); + $loginBgFrom = $db->getSetting('login_bg_from', '') ?: '#3b2f8f'; + $loginBgTo = $db->getSetting('login_bg_to', '') ?: '#6d5ce7'; + $loginOverlay = max(0, min(90, (int)$db->getSetting('login_bg_overlay', '40'))); + + $visualStyle = '--login-from:' . htmlspecialchars($loginBgFrom, ENT_QUOTES) + . ';--login-to:' . htmlspecialchars($loginBgTo, ENT_QUOTES) + . ';--login-overlay:' . ($loginOverlay / 100); + if ($loginBgImage !== '') { + $visualStyle .= ";--login-image:url('" . htmlspecialchars(Ui::mediaUrl($loginBgImage), ENT_QUOTES) . "')"; + } + } catch (Exception $e) { die('Datenbankfehler: ' . $e->getMessage()); } @@ -128,57 +159,60 @@ try { <?= __('login_title') ?> – <?= htmlspecialchars($appTitle) ?> - - - - - - + - -
-
- $label): ?> - - + + + +
+
+ + + + + + +
+
+

+

+ +
    + +
  • + +
+ +
+
+
+ + +
+
+
+ $label): ?> + + +
+
- -