diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 7e20944..0000000 --- a/.gitattributes +++ /dev/null @@ -1,15 +0,0 @@ -# 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 e88d61a..5aaaad9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,40 +29,9 @@ jobs: php -l "$f" done - - name: Validate language files + - name: Validate JSON language/migration assets run: | - 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";' + 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";' test: name: Unit Tests & Static Analysis diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..4f2709a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,30 @@ +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 deleted file mode 100644 index 3fc3001..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index 2654739..0000000 --- a/.htaccess +++ /dev/null @@ -1,26 +0,0 @@ -# --------------------------------------------------------------------------- -# 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 9696aa2..3d0f28c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,14 +16,11 @@ 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-Verzeichnisse beschreibbar machen -RUN mkdir -p /var/www/html/updater/storage /var/www/html/uploads \ +# Laufzeit-Verzeichnis des Updaters beschreibbar machen +RUN mkdir -p /var/www/html/updater/storage \ && 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 46a8a84..7019def 100644 --- a/Readme.md +++ b/Readme.md @@ -4,15 +4,12 @@ **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.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) +![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) @@ -28,8 +25,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** ## ✨ Features - 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand -- 🖥️ **Display-Seiten (Kiosk)** – öffentliche Seite je Site, an der Gäste sich mit einem Klick selbst einen Zugang holen -- 🎨 **Jede Display-Seite eigenständig gestaltbar** – Logo, Hintergrundbild, Akzentfarbe, helle oder dunkle Karte - 📦 **Bulk-Erstellung** – bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout - 🧩 **Voucher-Profile/Templates** – vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl - 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten @@ -50,13 +45,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** - 💾 **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 @@ -71,52 +59,31 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** ## 📸 Screenshots -> Alle Screenshots stammen aus der Oberfläche in Version 2.5.0 (neues Design-System). - ### Anmeldung & Voucher-Erstellung
- Anmeldung - Anmeldung mit eigenem Branding + Login mit Microsoft 365 + Voucher erstellen + Voucher-Ergebnis
-
- Voucher erstellen - Einstellungen der Login-Seite -
- -### Display-Seite für Gäste +### Bulk-Erstellung & Dark Mode
- 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 Dashboard im Dark Mode
+### Administration & Updater +
- Live-Voucher-Verwaltung - Einstellungen + Dashboard + Auto-Updater
- Markenfarben einstellen - Ansicht auf dem Smartphone + Updater – Ausgangszustand + Wartungsmodus
### REST-API, 2FA & Integrationen @@ -130,17 +97,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** Zwei-Faktor-Authentifizierung -### Updater & Wartungsmodus - -
- Updater – Ausgangszustand - Auto-Updater mit verfügbarem Update -
- -
- Wartungsmodus -
- --- ## 📋 Anforderungen @@ -157,8 +113,8 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** ## 🚀 Installation ```bash -git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git -cd Unifi-Voucher-Tool +git clone https://github.com/friloo/unifi-voucher-tool.git +cd unifi-voucher-tool ``` 1. Dateien auf den Webserver hochladen @@ -228,64 +184,6 @@ 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` @@ -322,80 +220,6 @@ 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: @@ -411,30 +235,13 @@ 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) | -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. +Empfohlene zusätzliche Härtung am Server: -> **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; } } +```apache +# .htaccess – sensible Dateien sperren (wird vom Installer erzeugt) + + Require all denied + ``` ```sql @@ -557,108 +364,6 @@ 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) @@ -673,18 +378,11 @@ dem Git-Hoster. - [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.8.0** · Autor: **Friederich Loheide** · Lizenz: **MIT** - -Entwickelt von **[Loheide.eu](https://loheide.eu)** +**Version 2.4.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
diff --git a/VERSION b/VERSION deleted file mode 100644 index 834f262..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -2.8.0 diff --git a/admin/api_keys.php b/admin/api_keys.php index ccc0c24..a2bbbfb 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_created_once'); + $success = 'API-Schlüssel erstellt. Bitte JETZT kopieren – er wird nur einmal angezeigt!'; } } } @@ -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 = __('api_status_updated'); + $success = 'Status aktualisiert.'; } } 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_deleted'); + $success = 'API-Schlüssel gelöscht.'; } $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,80 +66,94 @@ $adminBase = ''; -<?= __('api_title') ?> – <?= htmlspecialchars($appTitle) ?> +API-Schlüssel – <?= 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
-
-

-

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

+

Verwendung

+

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

# Voucher erstellen
 curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
   -H "Authorization: Bearer uvt_…" \
@@ -148,10 +162,10 @@ curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
 
 # Sites auflisten
 curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"
-

/api/openapi.php

+

OpenAPI-Spezifikation (Import in Postman/Swagger): /api/openapi.php

- + diff --git a/admin/audit_log.php b/admin/audit_log.php index 8edff87..1cfb77e 100644 --- a/admin/audit_log.php +++ b/admin/audit_log.php @@ -45,15 +45,24 @@ $users = $db->fetchAll("SELECT id, name FROM users WHERE is_active = 1 ORDER BY $currentPage = 'audit_log'; $adminBase = ''; -// 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); -} +// 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', +]; ?> @@ -62,17 +71,46 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_ <?= __('audit_title') ?> - <?= htmlspecialchars($appTitle) ?> + +
-
+
@@ -89,7 +127,7 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
- - Zurücksetzen + + Zurücksetzen
@@ -106,15 +144,14 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
- + Einträge
-

+

-
- +
@@ -128,55 +165,54 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_ - - - - - - +
+
+ +
- + System/Anonym
+ : - +
-
1): ?> @@ -185,7 +221,7 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
- +
diff --git a/admin/backup.php b/admin/backup.php index bc4bfa6..0faf942 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 = __('backup_choose_file'); + $error = 'Bitte eine Backup-Datei auswählen.'; } 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 = __('backup_invalid_file'); + $error = 'Ungültige oder fremde Backup-Datei.'; } else { $importSites = isset($_POST['import_sites']); $importTemplates = isset($_POST['import_templates']); @@ -95,9 +95,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) { } } $auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert'); - $success = str_replace(['{settings}', '{sites}', '{templates}'], - [(string)$counts['settings'], (string)$counts['sites'], (string)$counts['templates']], - __('backup_imported')); + $success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile."; } catch (Exception $e) { $error = 'Import-Fehler: ' . $e->getMessage(); } @@ -114,38 +112,48 @@ $adminBase = ''; -<?= __('backup_title') ?> – <?= htmlspecialchars($appTitle) ?> +Backup & Restore – <?= htmlspecialchars($appTitle) ?> - + + + +

💾 Backup & Restore

-

-

APP_KEY

- +

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
-

-

+

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 d32b357..dfb9725 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 = str_replace('{count}', (string)$created, __('import_created')); + $success = "$created Voucher erstellt."; } catch (Exception $e) { $error = $e->getMessage(); } @@ -93,38 +93,48 @@ $adminBase = ''; CSV-Import – <?= htmlspecialchars($appTitle) ?> - + + + +

📥 Voucher-Import (CSV)

-

-

Name,MaxGeräte,Minuten
+

Mehrere Voucher erstellen

+

Eine Zeile pro Voucher: Name,MaxGeräte,Minuten – MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:
Gast Müller,1,480 · Konferenzraum A,5,240 · Tagespass

- + - + - + - +
-

- +

Ergebnis

+
@@ -132,7 +142,7 @@ $adminBase = ''; - + diff --git a/admin/index.php b/admin/index.php index 4f3ee40..2613073 100644 --- a/admin/index.php +++ b/admin/index.php @@ -6,7 +6,6 @@ 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'; @@ -27,9 +26,12 @@ 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']); + $ctrl = new UniFiController($site['unifi_controller_url'], $site['unifi_username'], Crypto::decrypt($site['unifi_password']), $site['site_id'], $site['ssl_verify'] ?? 0); $ctrl->syncVouchersToDatabase($db, $site['id']); } catch (Exception $e) { $syncErrors[$site['id']] = $e->getMessage(); @@ -91,8 +93,78 @@ $currentPage = 'dashboard'; <?= __('dashboard_title') ?> – <?= htmlspecialchars($appTitle) ?> - + + + + +
+ + +
+ @@ -114,43 +186,43 @@ $currentPage = 'dashboard';
-
+
-
+
-
-
+
🟢
+
-
-
+
🟡
+
-
-
+
🔴
+
-
-
+
📊
+
@@ -158,16 +230,16 @@ $currentPage = 'dashboard';
-

+

🔴

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

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

+

📊

-
-
-

-
+
+
+

🏆

+
-

+

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

📋

Live
-

+

-
-
NameCode / FehlerStatus
+
- - - - + + + @@ -247,26 +318,17 @@ $currentPage = 'dashboard';
+
-
- + -
+
diff --git a/admin/integrations.php b/admin/integrations.php index cfc7c83..6f6d1bd 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 = __('settings_saved'); + $success = 'Einstellungen gespeichert.'; } } if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) { Notifier::send('✅ Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']); - $success = __('int_webhook_test_sent'); + $success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).'; } $enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1'; @@ -92,14 +92,24 @@ $adminBase = ''; -<?= __('int_title') ?> – <?= htmlspecialchars($appTitle) ?> +Integration & Wartung – <?= htmlspecialchars($appTitle) ?> - + + + +

🔧 Integration & Wartung

@@ -108,64 +118,64 @@ $adminBase = '';
-

-

- - +

Sicherheitsrichtlinie

+

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

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

-

X-Forwarded-For

+

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.

-

-

- - +

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:

+
-
+
-
+
@@ -174,21 +184,21 @@ $adminBase = '';
-

-

cron_cleanup.php -
+

Datenhaltung & Cleanup (DSGVO)

+

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

-
-
-
+
+
+
- + - + diff --git a/admin/kiosks.php b/admin/kiosks.php deleted file mode 100644 index 50019af..0000000 --- a/admin/kiosks.php +++ /dev/null @@ -1,500 +0,0 @@ -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 7b2aebf..e43b056 100644 --- a/admin/reports.php +++ b/admin/reports.php @@ -6,7 +6,6 @@ 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(); @@ -96,46 +95,56 @@ $adminBase = ''; -<?= __('rep_title') ?> – <?= htmlspecialchars($appTitle) ?> - +Reporting – <?= 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

+
@@ -143,32 +152,22 @@ $adminBase = '';
-

-
SiteGesamtGültigVerwendetAbgelaufen
+

Top-Nutzer

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

@@ -128,54 +142,53 @@ $activeSessions = $dbSessions ? $auth->activeSessionCount() : 0; -
-

+
● Nicht verfügbar
+

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

-
-


- backupCodesRemaining($user) ?>

+
● Aktiv
+

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

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

-
+
+

Aktive Sitzungen:

+ - +
- + ← Zurück
diff --git a/admin/settings.php b/admin/settings.php index d5763aa..5f37b34 100644 --- a/admin/settings.php +++ b/admin/settings.php @@ -8,8 +8,7 @@ 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/Ui.php'; -require_once __DIR__ . '/../includes/Upload.php'; +require_once __DIR__ . '/../includes/Helpers.php'; $auth = new Auth(); $auth->requireAdmin(); @@ -53,37 +52,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if ($formType === 'general') { $settings['app_title'] = trim($_POST['app_title'] ?? ''); - $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['logo_url'] = trim($_POST['logo_url'] ?? ''); + $settings['favicon_url'] = trim($_POST['favicon_url'] ?? ''); $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); @@ -98,7 +73,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { if ($formType === 'm365') { $settings['m365_client_id'] = trim($_POST['m365_client_id'] ?? ''); - $settings['m365_client_secret'] = trim($_POST['m365_client_secret'] ?? ''); + // 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_tenant_id'] = trim($_POST['m365_tenant_id'] ?? ''); } @@ -111,6 +90,7 @@ 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'] ?? ''); } @@ -124,6 +104,7 @@ 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'] ?? ''; } @@ -131,9 +112,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) { $db->setSetting($key, $value); } - $success = __('settings_saved'); - } catch (RuntimeException $e) { - $error = $e->getMessage(); + // 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; } catch (Exception $e) { $error = 'Fehler: ' . $e->getMessage(); } @@ -146,7 +133,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['generate_cron_token'] $error = __('error_csrf'); } else { $db->setSetting('cron_token', bin2hex(random_bytes(32))); - $success = 'Neuer Cron-Token wurde generiert!'; + flashSet(__('cron_token_generated')); + header('Location: settings.php#cron'); + exit; } } if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token'])) { @@ -154,7 +143,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_cron_token'])) $error = __('error_csrf'); } else { $db->setSetting('cron_token', ''); - $success = 'Cron-Token wurde gelöscht!'; + flashSet(__('cron_token_deleted')); + header('Location: settings.php#cron'); + exit; } } @@ -166,23 +157,29 @@ 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('Aktuelles Passwort ist falsch'); + throw new Exception(__('error_pw_current')); } if (strlen($_POST['new_password']) < 8) { throw new Exception(__('settings_pw_minlength')); } if ($_POST['new_password'] !== $_POST['confirm_password']) { - throw new Exception('Passwörter stimmen nicht überein'); + throw new Exception(__('error_pw_mismatch')); } $db->query("UPDATE users SET password_hash = ? WHERE id = ?", [password_hash($_POST['new_password'], PASSWORD_DEFAULT), $user['id']]); - $success = __('settings_pw_changed'); + flashSet(__('settings_pw_changed')); + header('Location: settings.php#password'); + exit; } 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); @@ -208,30 +205,16 @@ $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\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_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_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}"), - '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'), + '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}
'), 'cron_token' => $db->getSetting('cron_token', ''), 'last_cron_sync' => $db->getSetting('last_cron_sync', ''), ]; @@ -246,63 +229,98 @@ $adminBase = ''; <?= __('settings_title') ?> - <?= htmlspecialchars($appTitle) ?> - - - + + + + + + + + -
+
-
+
- - - - - - - - - - + + + + + + + +
-
-

-
+
+

+
- - +
+

>
- +
-
-

-

+
+

+

Diese Werte werden als Vorgabe im Voucher-Formular verwendet.

@@ -324,184 +342,37 @@ $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
- +
@@ -522,7 +393,7 @@ $adminBase = '';
- +
@@ -531,24 +402,24 @@ $adminBase = '';
-

Microsoft 365

+

Microsoft 365

-

Azure AD App

+

Azure AD App

Redirect URI: /m365_callback.php

-
+
- +
-
-

SMTP

+
+

SMTP

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

SMTP testen

- +
-
-

E-Mail Templates

+
+

E-Mail Templates

Auto:

-

-

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

+

Voucher E-Mail

+

Platzhalter:

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

-

-

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

Benutzer-Benachrichtigung

+

Platzhalter:

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

System & Erweitert

+
+

System & Erweitert

-

WYSIWYG-Editor

-

assets/vendor/

+

TinyMCE API Key

+

Kostenlosen API Key: tiny.cloud/signup

- +
Für WYSIWYG-Editor in Anleitungen

Druck-Template

-

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

-
- +

Platzhalter:

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

System-Information

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

+
+

- +
- +
diff --git a/admin/sites.php b/admin/sites.php index 23b0e14..becda5e 100644 --- a/admin/sites.php +++ b/admin/sites.php @@ -8,6 +8,7 @@ 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(); @@ -18,6 +19,32 @@ 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']??'')) { @@ -31,18 +58,27 @@ 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); - 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]); + $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]); } else { - $db->execute("UPDATE sites SET name=?,site_id=?,unifi_controller_url=?,unifi_username=?,public_access=? WHERE id=?", - [$name,$siteIdStr,$controllerUrl,$username,$publicAccess,$siteId]); + // 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]); } $auth->writeAuditLog($_SESSION['user_id'],'site_edit','site',$siteId,"Site {$name} aktualisiert"); - $success = __('sites_updated'); + flashSet(__('sites_updated')); + header('Location: sites.php'); + exit; } catch (Exception $e) { $error = $e->getMessage(); } } } @@ -59,38 +95,49 @@ 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); - 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]); + $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]); $auth->writeAuditLog($_SESSION['user_id'],'site_create','site',$newId,"Site {$name} erstellt"); - $success = __('sites_added'); + flashSet(__('sites_added')); + header('Location: sites.php'); + exit; } catch (Exception $e) { $error = $e->getMessage(); } } } -// Delete site -if (isset($_GET['delete']) && isset($_GET['token'])) { - if ($auth->validateCsrfToken($_GET['token'])) { - $delId = (int)$_GET['delete']; +// Delete site (POST + PRG) +if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['delete_site'])) { + if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $delId = (int)$_POST['delete_site']; $db->query("DELETE FROM sites WHERE id=?", [$delId]); $auth->writeAuditLog($_SESSION['user_id'],'site_delete','site',$delId,'Site gelöscht'); - $success = __('sites_deleted'); + flashSet(__('sites_deleted')); + header('Location: sites.php'); + exit; } else { $error = __('error_csrf'); } } -// 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']]); +// 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']]); if ($site) { - $db->query("UPDATE sites SET is_active=? WHERE id=?", [$site['is_active']?0:1,(int)$_GET['toggle']]); - $success = 'Site-Status aktualisiert!'; + $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; } } 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'; ?> @@ -101,24 +148,61 @@ $currentPage = 'sites'; <?= __('sites_title') ?> – <?= htmlspecialchars($appTitle) ?> + -
+
-
+
- +

@@ -132,51 +216,62 @@ $currentPage = 'sites';
- + - + - +
- +
- +
- +
- - - - - - - - +
+ + + +
+ +
+ + + +
- +
- +
@@ -267,9 +367,14 @@ $currentPage = 'sites'; +
+ + +
+
@@ -278,13 +383,13 @@ $currentPage = 'sites'; -
+
diff --git a/admin/templates.php b/admin/templates.php index 292675d..7fae89d 100644 --- a/admin/templates.php +++ b/admin/templates.php @@ -7,6 +7,7 @@ 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(); @@ -41,7 +42,9 @@ 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']] ); - $success = __('templates_added'); + flashSet(__('templates_added')); + header('Location: templates.php'); + exit; } catch (Exception $e) { $error = $e->getMessage(); } @@ -71,23 +74,31 @@ 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] ); - $success = __('templates_updated'); + flashSet(__('templates_updated')); + header('Location: templates.php'); + exit; } catch (Exception $e) { $error = $e->getMessage(); } } } -// 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'); +// 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; } 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'; @@ -100,22 +111,56 @@ $adminBase = ''; <?= __('templates_title') ?> - <?= htmlspecialchars($appTitle) ?> + + -
+
-
+
@@ -125,15 +170,14 @@ $adminBase = '';
- +

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

@@ -110,11 +149,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- + - +
diff --git a/includes/.htaccess b/includes/.htaccess deleted file mode 100644 index 2ac81c4..0000000 --- a/includes/.htaccess +++ /dev/null @@ -1,2 +0,0 @@ -# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert. -Require all denied diff --git a/includes/Auth.php b/includes/Auth.php index 4caab35..c8705c6 100644 --- a/includes/Auth.php +++ b/includes/Auth.php @@ -5,7 +5,9 @@ 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(); @@ -18,6 +20,9 @@ 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 { @@ -243,6 +248,8 @@ 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] @@ -309,6 +316,12 @@ 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']; @@ -331,6 +344,7 @@ class Auth { // Ausloggen public function logout() { + $this->sessionUser = null; $_SESSION = []; if (isset($_COOKIE[session_name()])) { @@ -340,6 +354,25 @@ 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'])) { @@ -354,24 +387,32 @@ class Auth { return false; } + // Deaktivierte/geloeschte Konten sofort aussperren + if ($this->loadSessionUser() === null) { + $this->logout(); + return false; + } + return true; } - - // Prüfen ob Admin + + // Prüfen ob Admin (live aus der DB, nicht aus dem Session-Cache) public function isAdmin() { - return $this->isLoggedIn() && isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true; + if (!$this->isLoggedIn()) { + return false; + } + $user = $this->loadSessionUser(); + $isAdmin = $user !== null && (bool)$user['is_admin']; + $_SESSION['is_admin'] = $isAdmin; + return $isAdmin; } - + // Aktuellen Benutzer abrufen public function getCurrentUser() { if (!$this->isLoggedIn()) { return null; } - - return $this->db->fetchOne( - "SELECT * FROM users WHERE id = ?", - [$_SESSION['user_id']] - ); + return $this->loadSessionUser(); } // Prüfen ob Benutzer Zugriff auf Site hat diff --git a/includes/Crypto.php b/includes/Crypto.php index ae42b4b..61e74ff 100644 --- a/includes/Crypto.php +++ b/includes/Crypto.php @@ -114,4 +114,9 @@ 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 new file mode 100644 index 0000000..4b1e8a7 --- /dev/null +++ b/includes/Helpers.php @@ -0,0 +1,53 @@ + $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 deleted file mode 100644 index ef25a56..0000000 --- a/includes/Kiosk.php +++ /dev/null @@ -1,169 +0,0 @@ -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 7f5d1ad..7fea381 100644 --- a/includes/Mailer.php +++ b/includes/Mailer.php @@ -1,339 +1,275 @@ -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); - } +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); + } } \ No newline at end of file diff --git a/includes/Ui.php b/includes/Ui.php deleted file mode 100644 index b7faa7a..0000000 --- a/includes/Ui.php +++ /dev/null @@ -1,259 +0,0 @@ - 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 d740603..c545ecb 100644 --- a/includes/UniFiController.php +++ b/includes/UniFiController.php @@ -10,12 +10,15 @@ 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) { + public function __construct($controllerUrl, $username, $password, $siteId, $sslVerify = false) { $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_'); } @@ -41,7 +44,8 @@ class UniFiController { 'password' => $this->password ]), CURLOPT_RETURNTRANSFER => true, - CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYPEER => $this->sslVerify, + CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0, CURLOPT_COOKIEJAR => $this->cookieFile, CURLOPT_COOKIEFILE => $this->cookieFile, CURLOPT_TIMEOUT => 10, @@ -116,7 +120,8 @@ class UniFiController { $options = [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, - CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYPEER => $this->sslVerify, + CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_HTTPHEADER => $headers @@ -155,13 +160,31 @@ class UniFiController { return json_decode($response, true); } - // Voucher erstellen + // Einzelnen 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' => 1, + 'n' => $count, 'note' => $voucherName, 'quota' => (int)$maxUses ]; @@ -182,51 +205,51 @@ class UniFiController { if (!isset($response['data'][0]['create_time'])) { throw new Exception("Voucher konnte nicht erstellt werden"); } - - // 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(); + $createTime = $response['data'][0]['create_time']; - if (empty($vouchers)) { - throw new Exception("Voucher-Code konnte nicht abgerufen werden"); - } + $all = $this->getVouchers(); - $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; + // 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: 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; + // 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); } - if ($latestVoucher === null || empty($latestVoucher['code'])) { + $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)) { 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 - ]; + return $result; } // Alle Voucher abrufen @@ -320,9 +343,9 @@ class UniFiController { } // Verbindung testen - public static function testConnection($controllerUrl, $username, $password, $siteId) { + public static function testConnection($controllerUrl, $username, $password, $siteId, $sslVerify = false) { try { - $controller = new self($controllerUrl, $username, $password, $siteId); + $controller = new self($controllerUrl, $username, $password, $siteId, $sslVerify); $controller->login(); return true; } catch (Exception $e) { @@ -351,17 +374,26 @@ 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']]++; - // 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] - ); + $existing = isset($existingMap[$voucher['_id']]) + ? ['id' => $existingMap[$voucher['_id']]] + : null; $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 deleted file mode 100644 index 2a97b90..0000000 --- a/includes/Upload.php +++ /dev/null @@ -1,175 +0,0 @@ - ['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 deleted file mode 100644 index 9ddcaa7..0000000 --- a/includes/VoucherService.php +++ /dev/null @@ -1,63 +0,0 @@ - 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 167c9ff..2c39349 100644 --- a/includes/admin_nav.php +++ b/includes/admin_nav.php @@ -4,122 +4,142 @@ * 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 020c796..ad69293 100644 --- a/index.php +++ b/index.php @@ -19,9 +19,8 @@ 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(); @@ -29,27 +28,68 @@ $mailer = new Mailer(); I18n::init(); /** - * 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. + * 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). */ -function isVoucherRateLimited() { - $window = 600; // 10 Minuten - $maxRequests = 10; +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) $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) >= $maxRequests) { + if (count($timestamps) + $voucherCount > $maxVouchers) { $_SESSION['voucher_create_times'] = $timestamps; return true; } - $timestamps[] = $now; + for ($i = 0; $i < $voucherCount; $i++) { + $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. @@ -72,11 +112,12 @@ 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', Ui::defaultPrintTemplate()); +$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}
'); $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)); @@ -116,9 +157,35 @@ if ($auth->isLoggedIn()) { $autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0; -// Voucher-Erstellung liegt gebuendelt in includes/VoucherService.php. +// Helper: create one voucher and save to DB function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos = []) { - return VoucherService::create($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), + ]; } // Single voucher @@ -128,8 +195,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()) { - $error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.'; + } elseif (!$auth->isLoggedIn() && isVoucherRateLimited($db)) { + $error = __('error_rate_limited'); } elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) { $error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.'; } else { @@ -137,7 +204,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 = max(1, (int)($_POST['expire_minutes'] ?? $defaultExpire)); + $expireMinutes = sanitizeExpireMinutes($_POST['expire_minutes'] ?? $defaultExpire, $auth->isLoggedIn(), $templates, $defaultExpire); $sendEmail = isset($_POST['send_email']) && !empty($_POST['recipient_email']); $recipientEmail= trim((string)($_POST['recipient_email'] ?? '')); @@ -163,10 +230,11 @@ 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 .= ' E-Mail versendet.'; + $success = __('voucher_created_mail'); + } else { + $success = __('voucher_created_ok'); } // Optional: Code per SMS (Twilio) $recipientPhone = trim((string)($_POST['recipient_phone'] ?? '')); @@ -174,62 +242,128 @@ 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 +// 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). if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) { - if (!$publicAccess && !$auth->isLoggedIn()) { + if (!$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 = max(1, (int)($_POST['expire_minutes'] ?? $defaultExpire)); + $expireMinutes = sanitizeExpireMinutes($_POST['expire_minutes'] ?? $defaultExpire, true, $templates, $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->isLoggedIn() && !$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm')); + if (!$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 = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : null; + $userId = $_SESSION['user_id'] ?? 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)), ]; - for ($i = 0; $i < $bulkCount; $i++) { - $bulkVouchers[] = doCreateVoucher($db, $site, $voucherName . '_' . ($i + 1), $maxUses, $expireMinutes, $userId, $qos); + // 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), + ]; } - Notifier::voucherCreated($bulkCount, $site['name'], $_SESSION['user_name'] ?? null); - $bulkCreated = true; - $success = str_replace('{count}', $bulkCount, __('bulk_success')); + 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; } 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() : ''; @@ -248,12 +382,9 @@ 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( - ['{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']], + ['{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']], $template ); } @@ -264,76 +395,120 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, <?= htmlspecialchars($appTitle) ?> - + + + + - - + + + - + - -
- - - - -
-
- $label): ?> - - -
- - - isAdmin()): ?> - - - - -
-
-
- - - -
- - - - - +
+
+ $label): ?> + +
-
+ +
-
+ +
+
👋 htmlspecialchars($currentUser['name'])]) ?>
+
+ isAdmin()): ?> + ⚙️ + + +
+
+ +
+
+
+ 🔐 +
+
+ + +
- + -
-

-

-
+

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

-

-
+

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

+

-
- +
-
+
📶


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

+ min="1" max="" value="" required>
@@ -507,7 +670,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, - @@ -519,7 +682,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
+ -
- -
+
diff --git a/install.php b/install.php index 679f6f1..39b7338 100644 --- a/install.php +++ b/install.php @@ -1,381 +1,483 @@ - 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. -

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

+ + +
+ +
+ \ No newline at end of file diff --git a/kiosk.php b/kiosk.php deleted file mode 100644 index 9118ce4..0000000 --- a/kiosk.php +++ /dev/null @@ -1,245 +0,0 @@ - - * - * 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 7b07a87..0016d2c 100644 --- a/lang/de.php +++ b/lang/de.php @@ -1,9 +1,6 @@ 'Übersicht', - 'nav_group_manage' => 'Verwaltung', - 'nav_group_system' => 'System', 'nav_dashboard' => 'Dashboard', 'nav_sites' => 'Sites verwalten', 'nav_users' => 'Benutzer verwalten', @@ -101,15 +98,13 @@ return [ 'voucher_email_hint' => 'gast@example.com', 'voucher_create_btn' => 'Voucher erstellen', 'voucher_creating' => 'Erstelle Voucher...', - '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_success_title' => '✓ Ihr Zugangs-Code', + 'voucher_validity' => 'Gültig für {duration} 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' => 'Bitte kontaktieren Sie Ihren Administrator.', + 'voucher_no_sites_user' => 'Ihr Konto hat noch keinen Site-Zugriff. Bitte kontaktieren Sie Ihren Administrator, um Berechtigungen zu erhalten.', 'voucher_template_select'=> '-- Kein Profil (manuell) --', 'voucher_template_label'=> 'Schnellprofil (optional)', @@ -124,7 +119,6 @@ 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', @@ -220,323 +214,6 @@ 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', @@ -547,8 +224,8 @@ return [ 'settings_tab_password' => 'Passwort', 'settings_saved' => 'Einstellungen erfolgreich gespeichert!', 'settings_app_title' => 'Anwendungs-Titel *', - 'settings_logo_url' => 'Logo', - 'settings_favicon_url' => 'Favicon', + 'settings_logo_url' => 'Logo-URL', + 'settings_favicon_url' => 'Favicon-URL', 'settings_favicon_hint' => 'Icon im Browser-Tab (.ico, .png, .svg)', 'settings_instr_header' => 'Anleitung - Überschrift', 'settings_instr_text' => 'Anleitung - Text', @@ -567,18 +244,13 @@ 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.', @@ -590,7 +262,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', @@ -620,4 +292,64 @@ 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 b3bf315..6fd4f28 100644 --- a/lang/en.php +++ b/lang/en.php @@ -1,9 +1,6 @@ 'Overview', - 'nav_group_manage' => 'Management', - 'nav_group_system' => 'System', 'nav_dashboard' => 'Dashboard', 'nav_sites' => 'Manage Sites', 'nav_users' => 'Manage Users', @@ -101,15 +98,13 @@ return [ 'voucher_email_hint' => 'guest@example.com', 'voucher_create_btn' => 'Create Voucher', 'voucher_creating' => 'Creating Voucher...', - '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_success_title' => '✓ Your Access Code', + 'voucher_validity' => 'Valid for {duration} 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' => 'Please contact your administrator.', + 'voucher_no_sites_user' => 'Your account has no site access yet. Please contact your administrator to be granted permissions.', 'voucher_template_select'=> '-- No Profile (manual) --', 'voucher_template_label'=> 'Quick Profile (optional)', @@ -124,7 +119,6 @@ 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', @@ -220,323 +214,6 @@ 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', @@ -547,8 +224,8 @@ return [ 'settings_tab_password' => 'Password', 'settings_saved' => 'Settings saved successfully!', 'settings_app_title' => 'Application Title *', - 'settings_logo_url' => 'Logo', - 'settings_favicon_url' => 'Favicon', + 'settings_logo_url' => 'Logo URL', + 'settings_favicon_url' => 'Favicon URL', 'settings_favicon_hint' => 'Browser tab icon (.ico, .png, .svg)', 'settings_instr_header' => 'Instructions - Headline', 'settings_instr_text' => 'Instructions - Text', @@ -567,18 +244,13 @@ 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.', @@ -590,7 +262,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', @@ -620,4 +292,64 @@ 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 ccc325d..c73b0bd 100644 --- a/login.php +++ b/login.php @@ -6,15 +6,11 @@ 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(); - // 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; } + if ($auth->isLoggedIn()) { header('Location: index.php'); exit; } } catch (Exception $e) { die('Fehler beim Initialisieren: ' . $e->getMessage()); } @@ -71,6 +67,7 @@ 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', ''); @@ -121,34 +118,6 @@ 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()); } @@ -159,60 +128,57 @@ try { <?= __('login_title') ?> – <?= htmlspecialchars($appTitle) ?> - + + + + + + - - - -
-
- - - - - - -
-
-

-

- -
    - -
  • - -
- -
-
-
- - -
-
-
- $label): ?> - - -
- + +
+
+ $label): ?> + +
+ +
diff --git a/login_simple.php b/login_simple.php deleted file mode 100644 index 449d11e..0000000 --- a/login_simple.php +++ /dev/null @@ -1,186 +0,0 @@ -getMessage(); -} - -try { - if (!file_exists(__DIR__ . '/includes/Database.php')) { - throw new Exception('includes/Database.php nicht gefunden'); - } - require_once __DIR__ . '/includes/Database.php'; -} catch (Exception $e) { - $loadErrors[] = "Database: " . $e->getMessage(); -} - -try { - if (!file_exists(__DIR__ . '/includes/Auth.php')) { - throw new Exception('includes/Auth.php nicht gefunden'); - } - require_once __DIR__ . '/includes/Auth.php'; -require_once __DIR__ . '/includes/Ui.php'; -} catch (Exception $e) { - $loadErrors[] = "Auth: " . $e->getMessage(); -} - -// Wenn Ladefehler aufgetreten sind, zeige sie an -if (!empty($loadErrors)) { - die('

Fehler beim Laden der Dateien

  • ' . implode('
  • ', $loadErrors) . '
'); -} - -// Ab hier normal weiter -try { - $auth = new Auth(); -} catch (Exception $e) { - die('

Fehler bei Auth-Initialisierung

' . $e->getMessage() . '

'); -} - -// Wenn bereits eingeloggt, weiterleiten -if ($auth->isLoggedIn()) { - header('Location: index.php'); - exit; -} - -$error = ''; -$success = ''; - -// Login-Verarbeitung -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - try { - $email = $_POST['email'] ?? ''; - $password = $_POST['password'] ?? ''; - - if (empty($email) || empty($password)) { - $error = 'Bitte E-Mail und Passwort eingeben'; - } elseif ($auth->login($email, $password)) { - header('Location: index.php'); - exit; - } else { - $error = 'Ungültige E-Mail oder Passwort'; - } - } catch (Exception $e) { - $error = 'Login-Fehler: ' . $e->getMessage(); - } -} - -try { - $db = Database::getInstance(); - $appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); - $logoUrl = $db->getSetting('logo_url', ''); - $m365Enabled = !empty($db->getSetting('m365_client_id')) && - !empty($db->getSetting('m365_client_secret')) && - !empty($db->getSetting('m365_tenant_id')); - $publicAccess = $db->getSetting('public_access', 0); - - // M365 OAuth URL generieren falls aktiviert - $m365LoginUrl = ''; - if ($m365Enabled) { - $clientId = $db->getSetting('m365_client_id'); - $tenantId = $db->getSetting('m365_tenant_id'); - - // Dynamische Redirect URI basierend auf aktuellem Pfad - $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; - $host = $_SERVER['HTTP_HOST']; - $scriptPath = dirname($_SERVER['SCRIPT_NAME']); - $scriptPath = $scriptPath === '/' ? '' : $scriptPath; - $redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php'; - - $params = [ - 'client_id' => $clientId, - 'response_type' => 'code', - 'redirect_uri' => $redirectUri, - 'response_mode' => 'query', - 'scope' => 'openid profile email User.Read', - 'state' => bin2hex(random_bytes(16)) - ]; - - $_SESSION['m365_state'] = $params['state']; - - $m365LoginUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?" . http_build_query($params); - } -} catch (Exception $e) { - die('

Datenbankfehler

' . $e->getMessage() . '

'); -} -?> - - - - - - Login - <?= htmlspecialchars($appTitle) ?> - - - - - - - \ No newline at end of file diff --git a/m365_callback.php b/m365_callback.php index 584e77f..1aaee18 100644 --- a/m365_callback.php +++ b/m365_callback.php @@ -102,16 +102,20 @@ if (isset($_GET['code'])) { } $userData = json_decode($userResponse, true); - - if (!isset($userData['id']) || !isset($userData['mail'])) { + + // Graph liefert 'mail' nur bei Nutzern mit Exchange-Postfach – fuer alle + // anderen auf den userPrincipalName zurueckfallen. + $userEmail = $userData['mail'] ?? $userData['userPrincipalName'] ?? null; + + if (!isset($userData['id']) || empty($userEmail)) { die("Ungültige Benutzer-Daten erhalten: " . htmlspecialchars($userResponse) . "
Zurück zum Login"); } - + // Benutzer einloggen oder anlegen $microsoftUser = [ 'id' => $userData['id'], - 'email' => $userData['mail'] ?? $userData['userPrincipalName'], - 'name' => $userData['displayName'] ?? $userData['givenName'] . ' ' . $userData['surname'] + 'email' => $userEmail, + 'name' => $userData['displayName'] ?? trim(($userData['givenName'] ?? '') . ' ' . ($userData['surname'] ?? '')) ]; try { diff --git a/m365_debug.php b/m365_debug.php index 0327561..ef9636e 100644 --- a/m365_debug.php +++ b/m365_debug.php @@ -32,21 +32,52 @@ $redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php'; M365 Debug - - - +

🔍 Microsoft 365 OAuth Debug

diff --git a/phpstan.neon b/phpstan.neon index 2d305ab..818df76 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,12 +1,6 @@ parameters: level: 5 - # __() stammt aus der I18n-Klasse und wird global definiert - scanFiles: - - includes/I18n.php paths: - includes/Totp.php - includes/Crypto.php - includes/ApiKey.php - - includes/Ui.php - - includes/Upload.php - - includes/Kiosk.php diff --git a/reset_password.php b/reset_password.php index 7f1bc5e..24f2fb2 100644 --- a/reset_password.php +++ b/reset_password.php @@ -6,7 +6,6 @@ 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(); @@ -16,6 +15,7 @@ I18n::init(); $db = Database::getInstance(); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $logoUrl = $db->getSetting('logo_url', ''); +$faviconUrl = $db->getSetting('favicon_url', ''); $token = trim($_GET['token'] ?? ''); $error = ''; @@ -70,20 +70,38 @@ if ($valid && $_SERVER['REQUEST_METHOD'] === 'POST') { <?= __('reset_new_pw') ?> – <?= htmlspecialchars($appTitle) ?> - + + + + + - -
+ +
- + -
+

@@ -107,10 +125,10 @@ if ($valid && $_SERVER['REQUEST_METHOD'] === 'POST') {
- + - + diff --git a/test.php b/test.php index e51c70f..c70953f 100644 --- a/test.php +++ b/test.php @@ -4,8 +4,6 @@ ini_set('display_errors', 0); ini_set('log_errors', 1); // Diagnose-Seite nur fuer angemeldete Admins zugaenglich (verhindert Info-Leak) -require_once __DIR__ . '/includes/Ui.php'; - if (file_exists(__DIR__ . '/config.php')) { require_once __DIR__ . '/config.php'; require_once __DIR__ . '/includes/Database.php'; @@ -15,14 +13,7 @@ if (file_exists(__DIR__ . '/config.php')) { $auth->requireAdmin(); } -echo '' - . '' - . 'System-Test' - . Ui::head(isset($db) ? $db : null) - . '' - . '' - . '

System-Test

'; +echo "

System Test

"; // 1. PHP Version echo "

1. PHP Version

"; @@ -279,8 +270,6 @@ try { } echo "
"; -echo "

Test abgeschlossen

"; -echo "

Zum Login " - . "Zur Startseite

"; -echo "
"; +echo "

✓ Test abgeschlossen

"; +echo "

Zum Login | Zur Startseite

"; ?> \ No newline at end of file diff --git a/tests/.htaccess b/tests/.htaccess deleted file mode 100644 index 2ac81c4..0000000 --- a/tests/.htaccess +++ /dev/null @@ -1,2 +0,0 @@ -# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert. -Require all denied diff --git a/tests/KioskTest.php b/tests/KioskTest.php deleted file mode 100644 index 51280a9..0000000 --- a/tests/KioskTest.php +++ /dev/null @@ -1,205 +0,0 @@ - */ - private array $settings; - - /** @param array $settings */ - public function __construct(array $settings = []) - { - $this->settings = $settings; - } - - public function fetchOne($sql, $params = []) - { - return ['c' => $this->usedToday]; - } - - public function getSetting($key, $default = null) - { - return $this->settings[$key] ?? $default; - } -} - -/** - * Die Grenzen der Kiosk-Seite sind das, was sie vor Missbrauch schützt – - * der Link ist öffentlich, also muss diese Logik stimmen. - */ -class KioskTest extends TestCase -{ - /** @param array $overrides */ - private function kiosk(array $overrides = []): array - { - return array_merge([ - 'id' => 1, - 'daily_limit' => 100, - 'cooldown_seconds' => 20, - 'last_used_at' => null, - ], $overrides); - } - - public function testTokenHasFixedShape(): void - { - $token = \Kiosk::newToken(); - - $this->assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $token); - $this->assertNotSame($token, \Kiosk::newToken(), 'Tokens dürfen sich nicht wiederholen'); - } - - public function testSanitizeTokenRejectsAnythingElse(): void - { - $valid = \Kiosk::newToken(); - - $this->assertSame($valid, \Kiosk::sanitizeToken($valid)); - $this->assertSame($valid, \Kiosk::sanitizeToken(strtoupper($valid))); - $this->assertSame('', \Kiosk::sanitizeToken('kurz')); - $this->assertSame('', \Kiosk::sanitizeToken("' OR 1=1 --")); - $this->assertSame('', \Kiosk::sanitizeToken(null)); - $this->assertSame('', \Kiosk::sanitizeToken($valid . 'ff')); - } - - public function testCooldownBlocksSecondCode(): void - { - $db = new FakeKioskDb(); - $kiosk = $this->kiosk(['last_used_at' => date('Y-m-d H:i:s', time() - 5)]); - - $result = \Kiosk::checkLimits($db, $kiosk); - - $this->assertFalse($result['allowed']); - $this->assertSame('cooldown', $result['reason']); - $this->assertGreaterThan(0, $result['wait']); - $this->assertLessThanOrEqual(20, $result['wait']); - } - - public function testCooldownExpires(): void - { - $db = new FakeKioskDb(); - $kiosk = $this->kiosk(['last_used_at' => date('Y-m-d H:i:s', time() - 60)]); - - $this->assertTrue(\Kiosk::checkLimits($db, $kiosk)['allowed']); - } - - public function testDailyLimitBlocks(): void - { - $db = new FakeKioskDb(); - $db->usedToday = 100; - - $result = \Kiosk::checkLimits($db, $this->kiosk()); - - $this->assertFalse($result['allowed']); - $this->assertSame('daily_limit', $result['reason']); - } - - public function testZeroMeansUnlimited(): void - { - $db = new FakeKioskDb(); - $db->usedToday = 5000; - - $kiosk = $this->kiosk(['daily_limit' => 0, 'cooldown_seconds' => 0]); - - $this->assertTrue(\Kiosk::checkLimits($db, $kiosk)['allowed']); - } - - public function testVoucherSettingsPreferTemplate(): void - { - $db = new FakeKioskDb(['default_max_uses' => '1', 'default_expire_minutes' => '480']); - $kiosk = $this->kiosk([ - 'tpl_max_uses' => 5, - 'tpl_expire_minutes' => 240, - 'qos_rate_max_down' => 20000, - 'qos_rate_max_up' => 5000, - 'qos_usage_quota' => 1024, - ]); - - $settings = \Kiosk::voucherSettings($db, $kiosk); - - $this->assertSame(5, $settings['max_uses']); - $this->assertSame(240, $settings['expire_minutes']); - $this->assertSame(20000, $settings['qos']['down']); - $this->assertSame(1024, $settings['qos']['quota_mb']); - } - - public function testVoucherSettingsFallBackToDefaults(): void - { - $db = new FakeKioskDb(['default_max_uses' => '3', 'default_expire_minutes' => '120']); - - $settings = \Kiosk::voucherSettings($db, $this->kiosk()); - - $this->assertSame(3, $settings['max_uses']); - $this->assertSame(120, $settings['expire_minutes']); - $this->assertSame(0, $settings['qos']['down']); - } - - public function testAppearanceFallsBackToSystemLogo(): void - { - $db = new FakeKioskDb(['logo_url' => 'uploads/global.png']); - - $look = \Kiosk::appearance($db, $this->kiosk()); - - $this->assertSame('uploads/global.png', $look['logo']); - $this->assertSame('', $look['background']); - $this->assertSame('', $look['accent'], 'Ohne eigene Farbe bleibt das Design-System zuständig'); - $this->assertSame('light', $look['card']); - } - - public function testAppearanceUsesOwnValues(): void - { - $db = new FakeKioskDb(['logo_url' => 'uploads/global.png']); - $kiosk = $this->kiosk([ - 'logo_url' => 'uploads/hotel.svg', - 'background_url' => 'uploads/lobby.jpg', - 'bg_overlay' => 60, - 'accent_color' => '#0F766E', - 'card_style' => 'dark', - ]); - - $look = \Kiosk::appearance($db, $kiosk); - - $this->assertSame('uploads/hotel.svg', $look['logo']); - $this->assertSame('uploads/lobby.jpg', $look['background']); - $this->assertSame(0.6, $look['overlay']); - $this->assertSame('#0f766e', $look['accent']); - $this->assertSame('dark', $look['card']); - } - - public function testAppearanceRejectsUnsafeColour(): void - { - $db = new FakeKioskDb(); - - // Der Wert landet in einem style-Attribut – nur echte Hex-Farben durch. - $look = \Kiosk::appearance($db, $this->kiosk(['accent_color' => 'red;background:url(evil)'])); - - $this->assertSame('', $look['accent']); - } - - public function testAppearanceClampsOverlay(): void - { - $db = new FakeKioskDb(); - - $this->assertSame(0.9, \Kiosk::appearance($db, $this->kiosk(['bg_overlay' => 250]))['overlay']); - $this->assertSame(0.0, \Kiosk::appearance($db, $this->kiosk(['bg_overlay' => -10]))['overlay']); - } - - public function testPublicUrl(): void - { - $token = \Kiosk::newToken(); - - $this->assertSame( - 'https://wlan.example.com/kiosk.php?k=' . $token, - \Kiosk::publicUrl($token, 'https://wlan.example.com/') - ); - } -} diff --git a/tests/UiTest.php b/tests/UiTest.php deleted file mode 100644 index 8d55513..0000000 --- a/tests/UiTest.php +++ /dev/null @@ -1,93 +0,0 @@ - */ - private array $values; - - /** @param array $values */ - public function __construct(array $values = []) - { - $this->values = $values; - } - - public function getSetting(string $key, $default = null) - { - return $this->values[$key] ?? $default; - } -} - -class UiTest extends TestCase -{ - public function testAssetUrlCarriesVersionStamp(): void - { - $url = \Ui::asset('assets/global.css'); - - $this->assertStringStartsWith('assets/global.css?v=', $url); - $this->assertMatchesRegularExpression('/\?v=\d+$/', $url); - } - - public function testAssetUrlRespectsBasePath(): void - { - $this->assertStringStartsWith('../assets/global.css?v=', \Ui::asset('assets/global.css', '../')); - } - - public function testBrandingStyleIsEmptyForDefaults(): void - { - $db = new FakeSettings([ - 'brand_accent' => \Ui::DEFAULT_ACCENT, - 'brand_accent_dark' => \Ui::DEFAULT_ACCENT_DARK, - 'brand_gradient_from' => \Ui::DEFAULT_GRADIENT_FROM, - 'brand_gradient_to' => \Ui::DEFAULT_GRADIENT_TO, - 'brand_radius' => (string)\Ui::DEFAULT_RADIUS, - ]); - - $this->assertSame('', \Ui::brandingStyle($db)); - $this->assertSame('', \Ui::brandingStyle(null)); - } - - public function testBrandingStyleUsesCustomColour(): void - { - $style = \Ui::brandingStyle(new FakeSettings(['brand_accent' => '#0F766E'])); - - $this->assertStringContainsString('--accent:#0f766e', $style); - $this->assertStringContainsString('[data-theme="dark"]', $style); - } - - public function testBrandingStyleIgnoresInvalidColour(): void - { - $style = \Ui::brandingStyle(new FakeSettings(['brand_accent' => 'rot; background:url(x)'])); - - $this->assertSame('', $style, 'Ungueltige Farben duerfen keinen Override erzeugen'); - } - - public function testBrandingRadiusIsClamped(): void - { - $style = \Ui::brandingStyle(new FakeSettings(['brand_radius' => '999'])); - - $this->assertStringContainsString('--r-lg:28px', $style); - } - - public function testMediaUrlKeepsAbsoluteAddresses(): void - { - $this->assertSame('https://cdn.example.com/logo.svg', \Ui::mediaUrl('https://cdn.example.com/logo.svg', '../')); - $this->assertSame('/logo.svg', \Ui::mediaUrl('/logo.svg', '../')); - $this->assertSame('', \Ui::mediaUrl('', '../')); - } - - public function testMediaUrlPrefixesUploads(): void - { - $this->assertSame('../uploads/logo.png', \Ui::mediaUrl('uploads/logo.png', '../')); - } -} diff --git a/tests/UploadTest.php b/tests/UploadTest.php deleted file mode 100644 index 46ec8b6..0000000 --- a/tests/UploadTest.php +++ /dev/null @@ -1,72 +0,0 @@ -setAccessible(true); - - return $method->invoke(null, $svg); - } - - public function testRemovesScriptElement(): void - { - $clean = $this->sanitize(''); - - $this->assertStringNotContainsString('assertStringNotContainsString('alert(1)', $clean); - $this->assertStringContainsString('', $clean); - } - - public function testRemovesEventHandlers(): void - { - $clean = $this->sanitize(''); - - $this->assertStringNotContainsString('onload', $clean); - $this->assertStringNotContainsString('onclick', $clean); - } - - public function testRemovesJavascriptLinks(): void - { - $clean = $this->sanitize('x'); - - $this->assertStringNotContainsString('javascript:', $clean); - } - - public function testKeepsHarmlessMarkup(): void - { - $svg = ''; - - $this->assertSame($svg, $this->sanitize($svg)); - } - - public function testRejectsNonSvgContent(): void - { - $this->expectException(RuntimeException::class); - $this->sanitize('GIF89a'); - } - - public function testIsLocalOnlyAcceptsUploadPaths(): void - { - $this->assertTrue(\Upload::isLocal('uploads/abc.png')); - $this->assertFalse(\Upload::isLocal('https://example.com/logo.png')); - $this->assertFalse(\Upload::isLocal('uploads/../config.php')); - $this->assertFalse(\Upload::isLocal('')); - } -} diff --git a/tools/.htaccess b/tools/.htaccess deleted file mode 100644 index 2ac81c4..0000000 --- a/tools/.htaccess +++ /dev/null @@ -1,2 +0,0 @@ -# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert. -Require all denied diff --git a/tools/README.md b/tools/README.md deleted file mode 100644 index f3f431f..0000000 --- a/tools/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Werkzeuge - -## Demo-Instanz (`tools/demo/build.py`) - -Baut aus dem Projekt eine lauffähige Kopie **ohne Datenbank**: `Database` und -`Auth` werden durch Stubs mit festen Beispieldaten ersetzt (`tools/demo/overlay/`). -Das Original bleibt unverändert. - -```bash -python3 tools/demo/build.py /tmp/uvt-demo -php -S 127.0.0.1:8123 -t /tmp/uvt-demo -``` - -Nützlich für: - -* **Screenshots** für die Dokumentation – immer mit denselben Daten -* **Smoke-Test** – jede Seite einmal rendern, ohne MySQL aufzusetzen - -Demo-spezifische Zustände werden über Query-Parameter erreicht: - -| Parameter | Wirkung | -|---|---| -| `?demo=result` / `?demo=bulk` | Voucher-Ergebnis bzw. Bulk-Liste auf `index.php` | -| `?demo=new` | frisch erzeugter Schlüssel auf `admin/api_keys.php` | -| `?anon=1` | nicht angemeldet (für die Login-Seite) | -| `?theme=dark` | erzwingt den Dark Mode | -| `?brand=custom` / `?brand=nopanel` | Beispiel-Branding der Login-Seite | - -## Screenshots (`tools/screenshots.py`) - -Rendert die Bilder aus `docs/screenshots/` neu. Voraussetzung ist ein -headless Chromium; der Pfad kommt aus `$CHROME_BIN` oder wird in den -üblichen Verzeichnissen gesucht. - -```bash -python3 tools/demo/build.py /tmp/uvt-demo -php -S 127.0.0.1:8123 -t /tmp/uvt-demo & -CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py -``` - -## Actions-Runner (`tools/runner/`) - -Compose-Datei und Anleitung, um einen Forgejo-Actions-Runner einzurichten. -Ohne Runner bleiben `ci.yml` und `release.yml` in der Warteschlange stehen. -Details: [`tools/runner/README.md`](runner/README.md). - -Die Skripte sind Hilfsmittel für die Entwicklung – im Betrieb werden sie nicht -benötigt und sind per `.htaccess` nicht über HTTP erreichbar. diff --git a/tools/demo/build.py b/tools/demo/build.py deleted file mode 100755 index 27eb2e0..0000000 --- a/tools/demo/build.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python3 -"""Baut eine Demo-Instanz des Voucher-Tools ohne Datenbank. - -Die Demo dient zwei Zwecken: - * Screenshots fuer die Dokumentation reproduzierbar erzeugen - * Smoke-Test: rendert jede Seite einmal ohne echte Datenbank - -Ablauf: Projekt in ein Zielverzeichnis kopieren, das Overlay darueberlegen -(Stubs fuer Database/Auth) und ein paar klar benannte Demo-Zustaende in die -Kopie patchen. Das Original bleibt unveraendert. - - python3 tools/demo/build.py /tmp/uvt-demo - php -S 127.0.0.1:8123 -t /tmp/uvt-demo -""" -import os -import shutil -import sys - -ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -OVERLAY = os.path.join(ROOT, 'tools', 'demo', 'overlay') -# Nur auf oberster Ebene ueberspringen – assets/vendor gehoert dazu! -SKIP_TOP = {'.git', '.github', 'tools', 'docs', 'tests', 'vendor', 'node_modules'} - - -def copy_project(target: str) -> None: - if os.path.exists(target): - shutil.rmtree(target) - - def ignore(directory, names): - if os.path.abspath(directory) == ROOT: - return [n for n in names if n in SKIP_TOP] - return [] - - shutil.copytree(ROOT, target, ignore=ignore) - for name in os.listdir(OVERLAY): - src = os.path.join(OVERLAY, name) - dst = os.path.join(target, name) - if os.path.isdir(src): - shutil.copytree(src, dst, dirs_exist_ok=True) - else: - shutil.copy2(src, dst) - - -def patch(path: str, anchor: str, replacement: str) -> None: - """Ersetzt einen Anker in der Demo-Kopie. Fehlt der Anker, bricht der - Build ab – so faellt auf, wenn sich die Vorlage geaendert hat.""" - with open(path, encoding='utf-8') as fh: - content = fh.read() - if anchor not in content: - raise SystemExit('Anker nicht gefunden in %s:\n%s' % (path, anchor[:80])) - with open(path, 'w', encoding='utf-8') as fh: - fh.write(content.replace(anchor, replacement, 1)) - - -def apply_demo_states(target: str) -> None: - # Voucher-Ergebnis und Bulk-Ansicht ohne Controller-Zugriff zeigen - anchor = "$currentUser = $auth->isLoggedIn() ? $auth->getCurrentUser() : null;" - patch(os.path.join(target, 'index.php'), anchor, anchor + """ - -// --- Demo-Instanz: Zustaende ohne UniFi-Controller darstellen --- -if (($_GET['demo'] ?? '') === 'result') { - $voucherCreated = true; - $voucherCode = '4829-17364'; - $voucherData = ['code' => '4829-17364', 'site_name' => 'Hauptstandort Nord', 'max_uses' => 2, - 'expire_min' => 480, 'expiry_date' => '23.09.2026', 'expiry_time' => '08:00']; -} -if (($_GET['demo'] ?? '') === 'bulk') { - $bulkCreated = true; - $bulkVouchers = []; - foreach (['4829-17364', '5517-90422', '7731-64508', '2094-38177', '6640-52913'] as $code) { - $bulkVouchers[] = ['code' => $code, 'site_name' => 'Hauptstandort Nord', 'max_uses' => 2, - 'expire_min' => 480, 'expiry_date' => '23.09.2026', 'expiry_time' => '08:00']; - } -} -""") - - # Live-Voucher-Liste automatisch laden (sonst wartet sie auf Site-Auswahl) - patch(os.path.join(target, 'admin', 'vouchers.php'), "\n", """ - -""") - - # Frisch erzeugten API-Schluessel zeigen - patch(os.path.join(target, 'admin', 'api_keys.php'), '$keys = $db->fetchAll(', - "if (($_GET['demo'] ?? '') === 'new') { $newKey = 'uvt_3f9a2c7d41e8b60592af18cc4d7e0b3a95f2617c'; }\n$keys = $db->fetchAll(") - - # Kiosk: ausgegebenen Code zeigen, ohne echten Controller (?demo=code) - patch(os.path.join(target, 'kiosk.php'), - "$headline = trim((string)($kiosk['headline'] ?? ''))", - '''if (($_GET['demo'] ?? '') === 'code' && $kiosk) { - $voucher = ['code' => '4829-17364', 'site_name' => $kiosk['site_name'], 'max_uses' => 2, - 'expire_min' => 480, 'expiry_date' => '24.09.2026', 'expiry_time' => '08:00']; -} - -$headline = trim((string)($kiosk['headline'] ?? ''))''') - - # Kiosk-Verwaltung: Formular fuer den Screenshot geoeffnet zeigen - patch(os.path.join(target, 'admin', 'kiosks.php'), - '