diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7e20944 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Zeilenenden nicht anfassen – einige Dateien liegen bewusst mit CRLF vor. +* -text + +# Dateien, die nicht ins Release-ZIP gehoeren. +# `git archive` (siehe .github/workflows/release.yml) wertet export-ignore aus. +/.gitattributes export-ignore +/.gitignore export-ignore +/.dockerignore export-ignore +/.github export-ignore +/docs export-ignore +/tests export-ignore +/tools export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon export-ignore +/composer.lock export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aaaad9..e88d61a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,9 +29,40 @@ jobs: php -l "$f" done - - name: Validate JSON language/migration assets + - name: Validate language files run: | - php -r 'foreach (glob("lang/*.php") as $f) { $a = require $f; if (!is_array($a)) { fwrite(STDERR, "Bad lang file: $f\n"); exit(1);} } echo "lang OK\n";' + php -r ' + $de = require "lang/de.php"; $en = require "lang/en.php"; + if (!is_array($de) || !is_array($en)) { fwrite(STDERR, "Bad lang file\n"); exit(1); } + $missingEn = array_diff(array_keys($de), array_keys($en)); + $missingDe = array_diff(array_keys($en), array_keys($de)); + if ($missingEn || $missingDe) { + fwrite(STDERR, "Fehlend in en: " . implode(", ", $missingEn) . "\n"); + fwrite(STDERR, "Fehlend in de: " . implode(", ", $missingDe) . "\n"); + exit(1); + } + echo "lang OK (" . count($de) . " Schluessel)\n";' + + - name: Check that every used translation key exists + run: | + php -r ' + $de = require "lang/de.php"; + $missing = []; + $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(".", FilesystemIterator::SKIP_DOTS)); + foreach ($it as $file) { + $path = $file->getPathname(); + if (substr($path, -4) !== ".php") continue; + if (strpos($path, "/vendor/") !== false || strpos($path, "/tools/") !== false) continue; + preg_match_all("/__\(\s*\x27([a-z0-9_]+)\x27/", file_get_contents($path), $m); + foreach ($m[1] as $key) { + if (!isset($de[$key]) && substr($key, -1) !== "_") { $missing[$key] = $path; } + } + } + if ($missing) { + foreach ($missing as $key => $path) { fwrite(STDERR, "Unbekannter Schluessel $key in $path\n"); } + exit(1); + } + echo "Alle verwendeten Schluessel vorhanden\n";' test: name: Unit Tests & Static Analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3fc3001 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,152 @@ +name: Release-Paket + +# Bei jedem Merge nach main entsteht ein installierbares ZIP und landet als +# Vorab-Release "latest-main" im Repository. Wird ein Tag v* gepusht, wird +# daraus ein regulaeres Release mit derselben Mechanik. +on: + push: + branches: [ main ] + tags: [ 'v*' ] + workflow_dispatch: + +jobs: + package: + name: ZIP bauen und veröffentlichen + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Version und Dateinamen bestimmen + id: meta + run: | + set -eu + VERSION="$(tr -d ' \r\n' < VERSION)" + SHORT_SHA="$(git rev-parse --short HEAD)" + BUILD_DATE="$(date -u +%Y-%m-%d)" + + if [ "${GITHUB_REF_TYPE:-branch}" = "tag" ]; then + TAG="${GITHUB_REF_NAME}" + NAME="unifi-voucher-tool-${TAG}" + TITLE="Version ${TAG}" + PRERELEASE="false" + else + TAG="latest-main" + NAME="unifi-voucher-tool-${VERSION}+${BUILD_DATE}.${SHORT_SHA}" + TITLE="Aktueller Stand von main – ${VERSION} (${SHORT_SHA})" + PRERELEASE="true" + fi + + { + echo "version=${VERSION}" + echo "short_sha=${SHORT_SHA}" + echo "build_date=${BUILD_DATE}" + echo "tag=${TAG}" + echo "name=${NAME}" + echo "title=${TITLE}" + echo "prerelease=${PRERELEASE}" + } >> "$GITHUB_OUTPUT" + + - name: ZIP erzeugen + run: | + set -eu + mkdir -p dist + # git archive wertet die export-ignore-Regeln aus .gitattributes aus, + # docs/, tests/, tools/ und CI-Dateien bleiben also draußen. + git archive --format=zip -9 \ + --prefix="unifi-voucher-tool/" \ + -o "dist/${{ steps.meta.outputs.name }}.zip" HEAD + cd dist + sha256sum "${{ steps.meta.outputs.name }}.zip" > "${{ steps.meta.outputs.name }}.zip.sha256" + ls -lh + + - name: Inhalt kurz prüfen + run: | + set -eu + # Ein paar Dateien muessen enthalten sein, sonst ist das Paket kaputt. + for required in \ + unifi-voucher-tool/index.php \ + unifi-voucher-tool/install.php \ + unifi-voucher-tool/database.sql \ + unifi-voucher-tool/assets/global.css \ + unifi-voucher-tool/assets/vendor/inter/inter.css \ + unifi-voucher-tool/includes/Ui.php + do + if ! unzip -l "dist/${{ steps.meta.outputs.name }}.zip" | grep -q "$required"; then + echo "Fehlt im Paket: $required" >&2 + exit 1 + fi + done + echo "Paket vollständig." + + - name: Als Build-Artefakt sichern + uses: actions/upload-artifact@v3 + continue-on-error: true # Artefakt-Speicher ist optional + with: + name: ${{ steps.meta.outputs.name }} + path: dist/* + retention-days: 30 + + - name: Release anlegen bzw. auffrischen + env: + # Forgejo stellt den Token automatisch bereit; FORGEJO_TOKEN + # (persönlicher Token) dient als Ausweichweg. + TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.FORGEJO_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + NAME: ${{ steps.meta.outputs.name }} + TITLE: ${{ steps.meta.outputs.title }} + PRERELEASE: ${{ steps.meta.outputs.prerelease }} + VERSION: ${{ steps.meta.outputs.version }} + SHORT_SHA: ${{ steps.meta.outputs.short_sha }} + BUILD_DATE: ${{ steps.meta.outputs.build_date }} + run: | + set -eu + if [ -z "${TOKEN:-}" ]; then + echo "Kein Token vorhanden – Release wird übersprungen." >&2 + exit 0 + fi + AUTH="Authorization: token ${TOKEN}" + + # Rollendes Vorab-Release durch ein frisches ersetzen, damit der + # Download-Link stabil bleibt und auf den aktuellen Stand zeigt. + if [ "$TAG" = "latest-main" ]; then + OLD_ID="$(curl -sf -H "$AUTH" "${API}/releases/tags/${TAG}" \ + | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)" + if [ -n "${OLD_ID:-}" ]; then + curl -sf -X DELETE -H "$AUTH" "${API}/releases/${OLD_ID}" || true + curl -sf -X DELETE -H "$AUTH" "${API}/tags/${TAG}" || true + fi + fi + + # Release-Text bewusst ohne Anfuehrungszeichen und Backslashes, + # damit er ohne jq direkt in den JSON-Body passt (\n bleibt literal). + BODY="Automatisch gebaut aus Commit ${SHORT_SHA}.\n\n" + BODY="${BODY}| | |\n|---|---|\n" + BODY="${BODY}| Version | ${VERSION} |\n" + BODY="${BODY}| Commit | ${SHORT_SHA} |\n" + BODY="${BODY}| Gebaut am | ${BUILD_DATE} |\n\n" + BODY="${BODY}**Neuinstallation:** ZIP entpacken, Dateien auf den Webserver legen, install.php aufrufen.\n\n" + BODY="${BODY}**Update einer bestehenden Installation:** config.php, uploads/ und updater/storage/ nicht ueberschreiben " + BODY="${BODY}- oder gleich den eingebauten Updater unter Administration, System-Update verwenden.\n\n" + BODY="${BODY}Pruefsumme: siehe beigelegte .sha256-Datei." + + RELEASE_ID="$(curl -sf -X POST -H "$AUTH" -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"${TITLE}\",\"body\":\"${BODY}\",\"draft\":false,\"prerelease\":${PRERELEASE}}" \ + "${API}/releases" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)" + + if [ -z "${RELEASE_ID:-}" ]; then + echo "Release konnte nicht angelegt werden." >&2 + exit 1 + fi + + for file in "dist/${NAME}.zip" "dist/${NAME}.zip.sha256"; do + curl -sf -X POST -H "$AUTH" \ + -F "attachment=@${file}" \ + "${API}/releases/${RELEASE_ID}/assets?name=$(basename "$file")" > /dev/null + echo "Angehängt: $(basename "$file")" + done + + echo "Release ${TITLE} steht bereit: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}" diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..2654739 --- /dev/null +++ b/.htaccess @@ -0,0 +1,26 @@ +# --------------------------------------------------------------------------- +# Sicherheits-Header und Zugriffsschutz (Apache) +# Nginx-Entsprechung siehe Readme.md, Abschnitt "Sicherheit". +# --------------------------------------------------------------------------- + + + Header always set X-Content-Type-Options "nosniff" + Header always set X-Frame-Options "SAMEORIGIN" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()" + + # Alle Frontend-Assets liegen lokal; externe Quellen nur fuer hCaptcha, + # falls es in den Einstellungen aktiviert wurde. + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'unsafe-inline' https://*.hcaptcha.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.hcaptcha.com; frame-src https://*.hcaptcha.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" + + +# Kein Verzeichnislisting +Options -Indexes + +# Dateien, die nie direkt ausgeliefert werden sollen + + Require all denied + + +# Interne Ordner schuetzen sich ueber eigene .htaccess-Dateien +# (funktioniert auch bei Installation in einem Unterverzeichnis). diff --git a/Dockerfile b/Dockerfile index 3d0f28c..9696aa2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,11 +16,14 @@ RUN { \ echo 'post_max_size=8M'; \ } > /usr/local/etc/php/conf.d/zz-voucher.ini +# .htaccess auswerten (Sicherheits-Header, Schutz des uploads-Ordners) +RUN sed -ri 's!!\n\tAllowOverride All!g' /etc/apache2/apache2.conf + WORKDIR /var/www/html COPY . /var/www/html -# Laufzeit-Verzeichnis des Updaters beschreibbar machen -RUN mkdir -p /var/www/html/updater/storage \ +# Laufzeit-Verzeichnisse beschreibbar machen +RUN mkdir -p /var/www/html/updater/storage /var/www/html/uploads \ && chown -R www-data:www-data /var/www/html COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/Readme.md b/Readme.md index af6cb97..46a8a84 100644 --- a/Readme.md +++ b/Readme.md @@ -4,12 +4,15 @@ **Webbasiertes WLAN-Voucher-Management für UniFi OS** – mit Multi-Site-Support, Benutzerverwaltung, Microsoft-365-Login und integriertem Auto-Updater. +Entwickelt von **[Loheide.eu](https://loheide.eu)** + ![PHP](https://img.shields.io/badge/PHP-7.4%2B-777BB4?logo=php&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-5.7%2B%20%2F%20MariaDB-4479A1?logo=mysql&logoColor=white) ![UniFi OS](https://img.shields.io/badge/UniFi%20OS-7.0%2B-0559C9?logo=ubiquiti&logoColor=white) ![License](https://img.shields.io/badge/Lizenz-MIT-green) -![Version](https://img.shields.io/badge/Version-2.5.0-blueviolet) -![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg) +![Version](https://img.shields.io/badge/Version-2.8.0-blueviolet) +![Tests](https://img.shields.io/badge/Tests-PHPUnit%20%2B%20PHPStan-brightgreen) +[![Repository](https://img.shields.io/badge/Code-git.loheide.cloud-4b3ec4)](https://git.loheide.cloud/friloo/Unifi-Voucher-Tool) @@ -25,6 +28,8 @@ ## ✨ Features - 🎟️ **Voucher-Erstellung** mit sofortiger QR-Code-Anzeige, Druckvorlage und E-Mail-Versand +- 🖥️ **Display-Seiten (Kiosk)** – öffentliche Seite je Site, an der Gäste sich mit einem Klick selbst einen Zugang holen +- 🎨 **Jede Display-Seite eigenständig gestaltbar** – Logo, Hintergrundbild, Akzentfarbe, helle oder dunkle Karte - 📦 **Bulk-Erstellung** – bis zu 20 Vouchers auf einmal, inkl. Sammeldruck-Layout - 🧩 **Voucher-Profile/Templates** – vordefinierte Laufzeiten & Gerätelimits per Schnellauswahl - 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten @@ -47,6 +52,11 @@ - 🌍 **Ö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 @@ -75,6 +85,18 @@ Einstellungen der Login-Seite +### Display-Seite für Gäste + +
+ Display-Seite im Ruhezustand + Display-Seite mit eigenem Bild und Farben +
+ +
+ Ausgegebener Zugangscode auf dem Display + Display-Seite einrichten +
+
Voucher-Ergebnis mit QR-Code Bulk-Voucher-Erstellung @@ -92,6 +114,11 @@ Einstellungen
+
+ Markenfarben einstellen + Ansicht auf dem Smartphone +
+ ### REST-API, 2FA & Integrationen
@@ -130,8 +157,8 @@ ## 🚀 Installation ```bash -git clone https://github.com/friloo/unifi-voucher-tool.git -cd unifi-voucher-tool +git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git +cd Unifi-Voucher-Tool ``` 1. Dateien auf den Webserver hochladen @@ -201,6 +228,64 @@ Während eines Updates wird die Anwendung kurz in den **Wartungsmodus** versetzt --- +## 🖥️ Display-Seiten für Gäste + +Für Empfang, Lobby oder Tagungsraum lässt sich je Site eine **öffentliche Seite** +anlegen, die auf einem Bildschirm oder Tablet läuft. Gäste tippen auf einen +Knopf und bekommen sofort einen eigenen Zugangscode – ohne Anmeldung, ohne +Personal am Tresen. + +**Anlegen:** Administration → **Display-Seiten** → *Display-Seite anlegen* + +
+ Verwaltung der Display-Seiten +
+ +| Einstellung | Wirkung | +|---|---| +| Site | für welchen Standort die Codes erzeugt werden | +| Voucher-Profil | Laufzeit, Geräteanzahl und Bandbreite der Codes (leer = Standardwerte) | +| Überschrift / Text | was auf dem Bildschirm steht | +| Codes pro Tag | Obergrenze je Kalendertag (0 = unbegrenzt) | +| Wartezeit | Abstand zwischen zwei Codes an diesem Display | +| Anzeigedauer | danach springt der Bildschirm automatisch zurück | + +Jede Seite hat einen **eigenen, geheimen Link** (`kiosk.php?k=…`). Er lässt sich +kopieren, als QR-Code anzeigen (praktisch, um ihn am Tablet zu öffnen) und +jederzeit erneuern – der alte Link ist dann sofort ungültig. Den Link nicht +öffentlich verbreiten: wer ihn hat, kann im Rahmen der Limits Codes ziehen. + +Auf dem Startbildschirm steht zusätzlich ein QR-Code, der auf dieselbe Seite +zeigt. Gäste können sie damit **am eigenen Handy** öffnen – praktisch bei +Bildschirmen ohne Touch. + +### Jede Seite eigenständig gestalten + +Jede Display-Seite bringt ihr eigenes Erscheinungsbild mit – das Hotel am +Empfang sieht anders aus als der Tagungsraum nebenan: + +| Einstellung | Wirkung | +|---|---| +| Logo | eigenes Logo auf der Karte (leer = Logo aus den Einstellungen) | +| Hintergrundbild | formatfüllend hinter der Karte, z. B. ein Foto des Hauses | +| Abdunklung | 0–90 % dunkle Ebene über dem Bild, damit die Karte lesbar bleibt | +| Akzentfarbe | färbt den Knopf dieser Seite (leer = Farbe aus dem Design-Tab) | +| Karte | hell oder dunkel – auf Fotos wirkt die dunkle Karte meist ruhiger | + +Logo und Hintergrund lassen sich direkt hochladen (PNG, JPG, WEBP, GIF, SVG bis +3 MB) oder als URL hinterlegen; beim Löschen einer Display-Seite verschwinden +die hochgeladenen Dateien mit. + +Die ausgegebenen Codes erscheinen normal in *Live Vouchers*, im *Reporting* und +im *Audit-Log* (Aktion „Voucher am Display geholt"), sodass jederzeit +nachvollziehbar bleibt, woher ein Zugang stammt. + +> Display-Seiten funktionieren unabhängig vom globalen öffentlichen Modus – der +> geheime Link ist der Zugang. Webhook-Benachrichtigungen werden für diese Codes +> bewusst **nicht** ausgelöst, sonst wäre der Slack-Kanal voll. + +--- + ## ⚙️ Konfiguration ### `config.php` @@ -249,21 +334,44 @@ gemeinsames Stylesheet: **`assets/global.css`**. - **Dark Mode** ausschließlich über Tokens – keine `!important`-Overrides mehr - **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback -Eigenes Branding lässt sich meist mit wenigen Zeilen umsetzen – z. B. in einer -eigenen CSS-Datei oder direkt in `assets/global.css`: +### 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) */ - --accent-hover: #0d5f59; - --accent-soft: #e6f4f2; /* Flächen für aktive Zustände */ --brand-gradient: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%); --r-lg: 14px; /* Eckenradius für Cards */ } ``` -Logo und Favicon werden nicht über CSS, sondern unter -**Administration → Einstellungen → Allgemein** gesetzt. +### 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 @@ -303,13 +411,30 @@ Das Tool ist auf einen sicheren Standardbetrieb ausgelegt: | **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout | | **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) | -Empfohlene zusätzliche Härtung am Server: +Mitgeliefert wird eine `.htaccess` im Projektstamm mit Sicherheits-Headern +(`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, +`Permissions-Policy` und einer Content-Security-Policy). Da alle Assets lokal +liegen, erlaubt die CSP nur noch die eigene Herkunft – externe Verbindungen +bleiben lediglich für hCaptcha offen, falls es aktiviert wird. Ordner wie +`includes/`, `tools/`, `tests/` und `uploads/` schützen sich über eigene +`.htaccess`-Dateien. -```apache -# .htaccess – sensible Dateien sperren (wird vom Installer erzeugt) - - Require all denied - +> **Apache:** `AllowOverride All` muss für das Verzeichnis gesetzt sein, sonst +> werden die `.htaccess`-Dateien ignoriert. Das mitgelieferte Docker-Image +> erledigt das bereits. + +Für **Nginx** entspricht das: + +```nginx +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "SAMEORIGIN" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'" always; + +location ~ ^/(includes|tools|tests)/ { deny all; } +location ~ ^/updater/(storage|migrations)/ { deny all; } +location ~ ^/(config\.php|database\.sql)$ { deny all; } +location ^~ /uploads/ { location ~ \.php$ { deny all; } } ``` ```sql @@ -432,6 +557,108 @@ Das Schema wird beim ersten Start automatisch in MariaDB geladen; danach den Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV setzen (`DB_*`, `APP_KEY`). +Hochgeladene Logos und Hintergründe liegen im Volume `uploads` und überstehen +damit ein Image-Update. Bei eigener Apache-/Nginx-Installation muss `uploads/` +für den Webserver beschreibbar sein: + +```bash +chown -R www-data:www-data uploads updater/storage +``` + +--- + +## 🧪 Entwicklung + +Für Screenshots und einen schnellen Durchlauf aller Seiten gibt es eine +Demo-Instanz **ohne Datenbank** – `Database` und `Auth` werden durch Stubs mit +festen Beispieldaten ersetzt: + +```bash +python3 tools/demo/build.py /tmp/uvt-demo +php -S 127.0.0.1:8123 -t /tmp/uvt-demo & + +# Bilder in docs/screenshots neu erzeugen (benötigt headless Chromium) +CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py +``` + +Details und die verfügbaren Demo-Zustände: [`tools/README.md`](tools/README.md). + +Tests und statische Analyse: + +```bash +composer install +vendor/bin/phpunit # 29 Tests (Crypto, TOTP, API-Keys, Upload, Ui) +vendor/bin/phpstan analyse # Level 5 +``` + +Die Versionsnummer steht in der Datei **`VERSION`** im Projektstamm. Sie wird +im Admin-Bereich unten in der Seitenleiste angezeigt und benennt das +Release-Paket – für eine neue Version also dort (und im Badge oben) anheben. + +Die Pipeline (`.github/workflows/ci.yml`) führt zusätzlich einen +Syntax-Check über alle PHP-Dateien aus und prüft, ob `lang/de.php` und +`lang/en.php` dieselben Schlüssel enthalten und jeder im Code verwendete +Schlüssel existiert. Dieselben Schritte lassen sich lokal ausführen. + +--- + +## 📦 Repository & Mitwirken + +Der Quellcode liegt auf der eigenen Forgejo-Instanz – **nicht** auf GitHub: + +**** + +```bash +# HTTPS +git clone https://git.loheide.cloud/friloo/Unifi-Voucher-Tool.git + +# SSH (Port 2222) +git clone ssh://git@git.loheide.cloud:2222/friloo/Unifi-Voucher-Tool.git +``` + +### Fertige Pakete + +Jeder Merge nach `main` erzeugt automatisch ein installierbares ZIP +(`.github/workflows/release.yml`) und hängt es an das rollende Vorab-Release +**`latest-main`**: + +**** + +Das Paket enthält nur die Laufzeit-Dateien – `docs/`, `tests/`, `tools/` und die +CI-Konfiguration bleiben draußen (rund 1,4 MB). Wird ein Tag `v*` gepusht, +entsteht daraus ein reguläres Release mit derselben Mechanik. + +> Beim **Update einer bestehenden Installation** `config.php`, `uploads/` und +> `updater/storage/` nicht überschreiben – oder gleich den eingebauten Updater +> verwenden, der genau diese Pfade schützt. + +Voraussetzung ist ein registrierter **Forgejo-Actions-Runner**; ohne Runner +bleiben die Workflows in der Warteschlange stehen. Compose-Datei und Anleitung +dafür liegen in [`tools/runner/`](tools/runner/README.md). + +### Mitwirken + +Fehlerberichte und Änderungsvorschläge laufen über die **Issues** und **Pull +Requests** dort. Für die Kommandozeile eignet sich [`tea`](https://gitea.com/gitea/tea), +die Gitea-/Forgejo-CLI: + +```bash +tea pr create # Pull Request öffnen +tea issues ls # offene Tickets ansehen +``` + +> Die Workflows unter `.github/workflows/` werden von Forgejo Actions +> mitgelesen; das Badge oben ist bewusst statisch, solange kein Runner +> registriert ist. `docker-publish.yml` veröffentlicht nach `ghcr.io` und +> stammt noch aus der GitHub-Zeit – für den Forgejo-Betrieb entweder auf die +> eigene Registry umstellen oder entfernen. + +Der **Auto-Updater** ist davon unabhängig: er zieht seine Pakete über +`update.loheide.eu` (Channels `stable` und `development`) und nicht direkt aus +dem Git-Hoster. + +--- + ## 🗺️ Roadmap - [x] Voucher-Templates (vordefinierte Laufzeiten) @@ -447,11 +674,17 @@ setzen (`DB_*`, `APP_KEY`). - [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.5.0** · Autor: **Friederich Loheide** · Lizenz: **MIT** +**Version 2.8.0** · Autor: **Friederich Loheide** · Lizenz: **MIT** + +Entwickelt von **[Loheide.eu](https://loheide.eu)**
diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..834f262 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.8.0 diff --git a/admin/api_keys.php b/admin/api_keys.php index 4817b71..ccc0c24 100644 --- a/admin/api_keys.php +++ b/admin/api_keys.php @@ -37,7 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) { ); $auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt"); $newKey = $k['plain']; - $success = 'API-Schlüssel erstellt. Bitte JETZT kopieren – er wird nur einmal angezeigt!'; + $success = __('api_created_once'); } } } @@ -46,14 +46,14 @@ if (isset($_GET['toggle']) && isset($_GET['token']) && $auth->validateCsrfToken( $row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]); if ($row) { $db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]); - $success = 'Status aktualisiert.'; + $success = __('api_status_updated'); } } if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) { $db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]); $auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht'); - $success = 'API-Schlüssel gelöscht.'; + $success = __('api_deleted'); } $keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC"); @@ -66,12 +66,12 @@ $adminBase = ''; -API-Schlüssel – <?= htmlspecialchars($appTitle) ?> +<?= __('api_title') ?> – <?= htmlspecialchars($appTitle) ?> @@ -80,55 +80,55 @@ $adminBase = '';
-

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
+ - - - - - - - + + + + + + + @@ -138,8 +138,8 @@ $adminBase = '';
-

Verwendung

-

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

+

+

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

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

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

+

/api/openapi.php

diff --git a/admin/audit_log.php b/admin/audit_log.php index edde07b..8edff87 100644 --- a/admin/audit_log.php +++ b/admin/audit_log.php @@ -45,23 +45,15 @@ $users = $db->fetchAll("SELECT id, name FROM users WHERE is_active = 1 ORDER BY $currentPage = 'audit_log'; $adminBase = ''; -$actionLabels = [ - 'voucher_created' => 'Voucher erstellt', - 'voucher_bulk' => 'Bulk Voucher', - 'user_login' => 'Login', - 'user_logout' => 'Logout', - 'user_created' => 'Benutzer erstellt', - 'user_updated' => 'Benutzer geändert', - 'user_deleted' => 'Benutzer gelöscht', - 'site_added' => 'Site hinzugefügt', - 'site_updated' => 'Site geändert', - 'site_deleted' => 'Site gelöscht', - 'settings_saved' => 'Einstellungen gespeichert', - 'password_reset' => 'Passwort-Reset', - 'template_created' => 'Profil erstellt', - 'template_updated' => 'Profil geändert', - 'template_deleted' => 'Profil gelöscht', -]; +// 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); +} ?> @@ -80,7 +72,7 @@ $actionLabels = [
-
+
@@ -97,7 +89,7 @@ $actionLabels = [
- - Zurücksetzen + + Zurücksetzen
@@ -114,15 +106,15 @@ $actionLabels = [
- + Einträge
-

+

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

Export

-

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

- Konfiguration exportieren +

+

APP_KEY

+
-

Import / Restore

-

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

+

+


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

Mehrere Voucher erstellen

-

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

+

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

- + - + - + - +
-

Ergebnis

-
+
+ +
- System/Anonym +
+ : - +
+

+
NameCode / FehlerStatus
diff --git a/admin/index.php b/admin/index.php index a7fa9a0..4f3ee40 100644 --- a/admin/index.php +++ b/admin/index.php @@ -6,6 +6,7 @@ ini_set('log_errors', 1); require_once __DIR__ . '/../config.php'; require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/Ui.php'; require_once __DIR__ . '/../includes/UniFiController.php'; require_once __DIR__ . '/../includes/I18n.php'; @@ -90,7 +91,7 @@ $currentPage = 'dashboard'; <?= __('dashboard_title') ?> – <?= htmlspecialchars($appTitle) ?> - + @@ -113,21 +114,21 @@ $currentPage = 'dashboard';
-
+
-
+
-
+
@@ -135,21 +136,21 @@ $currentPage = 'dashboard';
-
+
-
+
-
+
@@ -182,9 +183,9 @@ $currentPage = 'dashboard';
-
+

- +
@@ -203,7 +204,7 @@ $currentPage = 'dashboard';

-

+

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

+

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

- - +

+

+ + - + - +
-
+
-

Reverse-Proxy

-

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

+

+

X-Forwarded-For

-

Webhook-Benachrichtigungen

-

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

- - +

+

+ +
-

SMS-Versand (Twilio)

-

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

- +

+

+
-
-
+
+
-

Single Sign-On (OpenID Connect)

-

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

- +

+

+
-
+
-
+
@@ -174,18 +174,18 @@ $adminBase = '';
-

Datenhaltung & Cleanup (DSGVO)

-

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

+

cron_cleanup.php +

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

+ + + +
+ +
+
+

+ +
+ +
+ + +
+
+
+
+
+
+ + + +
+ +
+
+ + +
+
+ + 0 ? ' / ' . (int)$k['daily_limit'] : '' ?> + +
+
+ + +
+
+ + + + +
+ + + + + +
+ + + + + + + +
+
+ +
+ + + + + + + + + + +
+ + + + diff --git a/admin/reports.php b/admin/reports.php index 53427dd..7b2aebf 100644 --- a/admin/reports.php +++ b/admin/reports.php @@ -6,6 +6,7 @@ ini_set('log_errors', 1); require_once __DIR__ . '/../config.php'; require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/Ui.php'; require_once __DIR__ . '/../includes/I18n.php'; $auth = new Auth(); @@ -95,46 +96,46 @@ $adminBase = ''; -Reporting – <?= htmlspecialchars($appTitle) ?> - +<?= __('rep_title') ?> – <?= htmlspecialchars($appTitle) ?> +
- + - CSV (täglich) - CSV (pro Site) - CSV (pro Nutzer) - + + + +
-
Vouchers gesamt
-
Gültig
-
Verwendet
-
In Tagen erstellt
+
+
+
+
-

Erstellte Voucher ( Tage)

+

-

Pro Site

-
+
+

+
SiteGesamtGültigVerwendetAbgelaufen
@@ -142,12 +143,12 @@ $adminBase = '';
-

Top-Nutzer

-
+

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

Zwei-Faktor-Authentifizierung

-

Konto:

+

+

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

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

+ +

@@ -129,34 +128,34 @@ $activeSessions = $dbSessions ? $auth->activeSessionCount() : 0; -
Nicht verfügbar
-

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

+
+

-
Aktiv
-

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

+
+


+ backupCodesRemaining($user) ?>

- +
-
+ - +
-
Inaktiv
+
    -
  1. Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator …)
  2. -
  3. QR-Code scannen oder Secret manuell eingeben
  4. -
  5. Den angezeigten 6-stelligen Code unten eingeben
  6. +
  7. +
  8. +
- + - +
- - - - - + + + @@ -248,48 +260,49 @@ $adminBase = '';
-
+
-
+
- - - - - - - - - + + + + + + + + + +
-
-

-
+
+

+
-
-
+ +

>
- +
-
-

-

Diese Werte werden als Vorgabe im Voucher-Formular verwendet.

+
+

+

@@ -311,22 +324,91 @@ $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

- + + +
+ + +
+

+

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

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

+
+

-
+ @@ -341,11 +423,7 @@ $adminBase = '';
-
- - -
-
+

@@ -370,11 +448,7 @@ $adminBase = '';
-
- - -
-
+
@@ -400,34 +474,34 @@ $adminBase = '';
- + - +
-
-

+
+

-

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
- +
@@ -448,7 +522,7 @@ $adminBase = '';
- +
@@ -457,9 +531,9 @@ $adminBase = '';
-

Microsoft 365

+

Microsoft 365

-

Azure AD App

+

Azure AD App

Redirect URI: /m365_callback.php

@@ -468,13 +542,13 @@ $adminBase = '';
- +
-
-

SMTP

+
+

SMTP

@@ -486,60 +560,60 @@ $adminBase = '';
-
+
-
+
- +

SMTP testen

- +
-
-

E-Mail Templates

+
+

E-Mail Templates

Auto:

-

Voucher E-Mail

-

Platzhalter:

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

+

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


-

Benutzer-Benachrichtigung

-

Platzhalter:

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

+

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

System & Erweitert

+
+

System & Erweitert

-

TinyMCE API Key

-

Kostenlosen API Key: tiny.cloud/signup

+

WYSIWYG-Editor

+

assets/vendor/

-
Für WYSIWYG-Editor in Anleitungen
+

Druck-Template

-

Platzhalter:

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

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

+
+

System-Information

@@ -551,14 +625,14 @@ $adminBase = '';
-
-

+
+

- +
@@ -579,17 +653,43 @@ document.querySelectorAll('.tab-button').forEach(btn => { }); }); +// Branding-Vorschau live faerben +function updateBrandPreview() { + const preview = document.getElementById('brandPreview'); + if (!preview) return; + const accent = (document.getElementById('brand_accent') || {}).value || ''; + const from = (document.getElementById('brand_gradient_from') || {}).value || ''; + const to = (document.getElementById('brand_gradient_to') || {}).value || ''; + const radius = (document.getElementById('brand_radius') || {}).value || '14'; + if (/^#[0-9a-fA-F]{6}$/.test(accent)) { + preview.style.setProperty('--accent', accent); + preview.style.setProperty('--accent-hover', `color-mix(in srgb, ${accent} 84%, #000)`); + preview.style.setProperty('--accent-soft', `color-mix(in srgb, ${accent} 12%, #fff)`); + preview.style.setProperty('--accent-border', `color-mix(in srgb, ${accent} 32%, #fff)`); + } + if (/^#[0-9a-fA-F]{6}$/.test(from) && /^#[0-9a-fA-F]{6}$/.test(to)) { + preview.style.setProperty('--brand-gradient', `linear-gradient(135deg, ${from} 0%, ${to} 100%)`); + } + preview.style.setProperty('--r-lg', radius + 'px'); +} +['brand_accent', 'brand_gradient_from', 'brand_gradient_to', 'brand_radius'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('input', updateBrandPreview); + if (el) el.addEventListener('change', updateBrandPreview); +}); +updateBrandPreview(); + // Farbwähler und Hex-Feld synchron halten document.querySelectorAll('.color-swatch').forEach(swatch => { const field = document.getElementById(swatch.dataset.target); if (!field) return; - swatch.addEventListener('input', () => { field.value = swatch.value; }); + swatch.addEventListener('input', () => { field.value = swatch.value; updateBrandPreview(); }); field.addEventListener('input', () => { if (/^#[0-9a-fA-F]{6}$/.test(field.value.trim())) swatch.value = field.value.trim(); }); }); -// Restore tab from hash +// Tab aus Anker uebernehmen (der Query-Parameter wird serverseitig gesetzt) window.addEventListener('DOMContentLoaded', function() { const hash = location.hash.substring(1); if (hash) { @@ -603,9 +703,9 @@ async function testSmtp() { const email = document.getElementById('smtpTestEmail').value.trim(); const btn = document.getElementById('smtpTestBtn'); const result = document.getElementById('smtpTestResult'); - if (!email) { result.textContent = 'Bitte E-Mail eingeben.'; return; } + if (!email) { result.textContent = ''; return; } btn.disabled = true; - btn.innerHTML = ''; + btn.innerHTML = ''; const fd = new FormData(); fd.append('ajax_smtp_test', '1'); fd.append('csrf_token', 'getCsrfToken() ?>'); @@ -615,26 +715,26 @@ async function testSmtp() { result.textContent = data.message; result.style.color = data.success ? 'var(--success)' : 'var(--danger)'; btn.disabled = false; - btn.innerHTML = ' Testen'; + btn.innerHTML = ' Testen'; } async function testCronJob() { const btn = document.getElementById('testCronBtn'); const result = document.getElementById('testCronResult'); btn.disabled = true; - btn.innerHTML = ' Läuft...'; + btn.innerHTML = ' '; try { const res = await fetch('../cron_sync.php?token='); const data = await res.json(); result.innerHTML = data.success - ? ` ${data.message}` - : ` ${data.message}`; + ? ` ${data.message}` + : ` ${data.message}`; if (data.success) showToast('success', 'Cron ausgeführt', data.message); } catch (e) { - result.innerHTML = `Fehler: ${e.message}`; + result.innerHTML = `: ${e.message}`; } btn.disabled = false; - btn.innerHTML = ' Jetzt ausführen'; + btn.innerHTML = ' '; } function initTinyMCE() { @@ -655,6 +755,10 @@ function initTinyMCE() { tinymce.baseURL = window.TINYMCE_BASE_URL; config.base_url = window.TINYMCE_BASE_URL; config.suffix = '.min'; + if (document.documentElement.lang === 'de') { + config.language = 'de'; + config.language_url = window.TINYMCE_BASE_URL + '/langs/de.js'; + } } tinymce.init(config); } diff --git a/admin/sites.php b/admin/sites.php index caf0d2c..23b0e14 100644 --- a/admin/sites.php +++ b/admin/sites.php @@ -105,20 +105,20 @@ $currentPage = 'sites'; -
+
-
+
- +

@@ -132,43 +132,43 @@ $currentPage = 'sites';
- + - + - +
- +
- +
- +
@@ -196,7 +196,7 @@ $currentPage = 'sites';
- Zu finden in der UniFi Controller URL +
@@ -219,7 +219,7 @@ $currentPage = 'sites';
@@ -259,7 +259,7 @@ $currentPage = 'sites';
- +
@@ -269,7 +269,7 @@ $currentPage = 'sites';
@@ -278,7 +278,7 @@ $currentPage = 'sites';
-
+
+
- +

@@ -118,7 +114,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { - +
diff --git a/includes/.htaccess b/includes/.htaccess new file mode 100644 index 0000000..2ac81c4 --- /dev/null +++ b/includes/.htaccess @@ -0,0 +1,2 @@ +# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert. +Require all denied diff --git a/includes/Kiosk.php b/includes/Kiosk.php new file mode 100644 index 0000000..ef25a56 --- /dev/null +++ b/includes/Kiosk.php @@ -0,0 +1,169 @@ +fetchOne( + "SELECT k.*, s.name AS site_name, s.is_active AS site_active, + t.name AS template_name, t.max_uses AS tpl_max_uses, t.expire_minutes AS tpl_expire_minutes, + t.qos_rate_max_down, t.qos_rate_max_up, t.qos_usage_quota + FROM kiosks k + INNER JOIN sites s ON s.id = k.site_id + LEFT JOIN voucher_templates t ON t.id = k.template_id + WHERE k.token = ? AND k.is_active = 1", + [$token] + ); + + if (!$row || (int)$row['site_active'] !== 1) { + return null; + } + + return $row; + } + + /** Wie viele Codes hat dieser Kiosk heute schon ausgegeben? */ + public static function usedToday($db, int $kioskId): int + { + $row = $db->fetchOne( + "SELECT COUNT(*) AS c FROM vouchers WHERE kiosk_id = ? AND DATE(created_at) = CURDATE()", + [$kioskId] + ); + + return (int)($row['c'] ?? 0); + } + + /** + * Darf gerade ein Code geholt werden? + * + * @return array{allowed:bool,reason:string,wait:int} + * reason: '' | 'cooldown' | 'daily_limit' + */ + public static function checkLimits($db, array $kiosk): array + { + $cooldown = max(0, (int)$kiosk['cooldown_seconds']); + if ($cooldown > 0 && !empty($kiosk['last_used_at'])) { + $elapsed = time() - strtotime((string)$kiosk['last_used_at']); + if ($elapsed >= 0 && $elapsed < $cooldown) { + return ['allowed' => false, 'reason' => 'cooldown', 'wait' => $cooldown - $elapsed]; + } + } + + $limit = max(0, (int)$kiosk['daily_limit']); + if ($limit > 0 && self::usedToday($db, (int)$kiosk['id']) >= $limit) { + return ['allowed' => false, 'reason' => 'daily_limit', 'wait' => 0]; + } + + return ['allowed' => true, 'reason' => '', 'wait' => 0]; + } + + /** Nach erfolgreicher Ausgabe den Zeitstempel fortschreiben. */ + public static function markUsed($db, int $kioskId): void + { + $db->execute("UPDATE kiosks SET last_used_at = NOW() WHERE id = ?", [$kioskId]); + } + + /** + * Voucher-Eckdaten eines Kiosks: entweder aus dem verknüpften Profil + * oder aus den globalen Standardwerten. + */ + public static function voucherSettings($db, array $kiosk): array + { + $maxUses = (int)($kiosk['tpl_max_uses'] ?? 0); + $expire = (int)($kiosk['tpl_expire_minutes'] ?? 0); + + if ($maxUses < 1) { + $maxUses = max(1, (int)$db->getSetting('default_max_uses', 1)); + } + if ($expire < 1) { + $expire = max(1, (int)$db->getSetting('default_expire_minutes', 480)); + } + + return [ + 'max_uses' => $maxUses, + 'expire_minutes' => $expire, + 'qos' => [ + 'down' => max(0, (int)($kiosk['qos_rate_max_down'] ?? 0)), + 'up' => max(0, (int)($kiosk['qos_rate_max_up'] ?? 0)), + 'quota_mb' => max(0, (int)($kiosk['qos_usage_quota'] ?? 0)), + ], + ]; + } + + /** + * Gestaltung einer Display-Seite: eigene Werte, sonst die des Systems. + * + * @return array{logo:string,background:string,overlay:float,accent:string,card:string} + */ + public static function appearance($db, array $kiosk): array + { + $accent = strtolower(trim((string)($kiosk['accent_color'] ?? ''))); + if (!preg_match('/^#[0-9a-f]{6}$/', $accent)) { + $accent = ''; + } + + $overlay = (int)($kiosk['bg_overlay'] ?? 45); + $overlay = max(0, min(90, $overlay)); + + $logo = trim((string)($kiosk['logo_url'] ?? '')); + if ($logo === '' && $db) { + $logo = (string)$db->getSetting('logo_url', ''); + } + + return [ + 'logo' => $logo, + 'background' => trim((string)($kiosk['background_url'] ?? '')), + 'overlay' => (float)$overlay / 100, // immer float, auch bei 0 + 'accent' => $accent, + 'card' => ($kiosk['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light', + ]; + } + + /** Öffentliche Adresse eines Kiosks. */ + public static function publicUrl(string $token, string $baseUrl = ''): string + { + if ($baseUrl === '') { + $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $path = dirname($_SERVER['SCRIPT_NAME'] ?? '/', 2); + $path = $path === '/' || $path === '\\' ? '' : $path; + $baseUrl = $protocol . '://' . $host . $path; + } + + return rtrim($baseUrl, '/') . '/kiosk.php?k=' . $token; + } +} diff --git a/includes/Mailer.php b/includes/Mailer.php index 866b2eb..7f5d1ad 100644 --- a/includes/Mailer.php +++ b/includes/Mailer.php @@ -1,265 +1,339 @@ -db = Database::getInstance(); - $this->loadSettings(); - } - - private function loadSettings() { - $this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1'; - $this->smtpHost = $this->db->getSetting('smtp_host', ''); - $this->smtpPort = (int)$this->db->getSetting('smtp_port', '587'); - $this->smtpUsername = $this->db->getSetting('smtp_username', ''); - $this->smtpPassword = $this->db->getSetting('smtp_password', ''); - $this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls'); - $this->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 - public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) { - $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); - $instructionHeader = $this->db->getSetting('instruction_header', ''); - $instructionText = $this->db->getSetting('instruction_text', ''); - - // System-URL aus Einstellungen oder automatisch erkennen - $systemUrl = $this->db->getSetting('system_url', ''); - if (empty($systemUrl)) { - $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; - $host = $_SERVER['HTTP_HOST']; - $scriptPath = dirname($_SERVER['SCRIPT_NAME']); - $scriptPath = $scriptPath === '/' ? '' : $scriptPath; - $systemUrl = $protocol . '://' . $host . $scriptPath; - } - - // Template aus Datenbank laden - $subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'); - $bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nIhr WLAN-Zugangscode lautet:\n\n{VOUCHER_CODE}\n\nGültigkeit: 8 Stunden ab Erstellung\nMaximale Geräte: {MAX_USES}\nStandort: {SITE_NAME}\n\n{INSTRUCTIONS}\n\nMit freundlichen Grüßen\n{APP_TITLE}"); - - // Anleitung formatieren - $instructions = ''; - if ($instructionText) { - $instructions = $instructionHeader . "\n" . $instructionText; - } - - // Platzhalter ersetzen - $placeholders = [ - '{VOUCHER_CODE}' => $voucherCode, - '{SITE_NAME}' => $siteName, - '{MAX_USES}' => $maxUses, - '{APP_TITLE}' => $appTitle, - '{INSTRUCTIONS}' => $instructions, - '{SYSTEM_URL}' => $systemUrl - ]; - - $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); - $body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate); - - // HTML oder Plain Text prüfen - $isHtml = strip_tags($body) !== $body; - - return $this->send($to, $subject, $body, $isHtml); - } - - public function sendTestEmail($to) { - $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); - $subject = '[Test] E-Mail-Konfiguration – ' . $appTitle; - $body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet."; - return $this->send($to, $subject, $body, false); - } - - public function sendUserNotification($to, $userName, $changes) { - $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); - - // System-URL aus Einstellungen oder automatisch erkennen - $systemUrl = $this->db->getSetting('system_url', ''); - if (empty($systemUrl)) { - $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; - $host = $_SERVER['HTTP_HOST']; - $scriptPath = dirname($_SERVER['SCRIPT_NAME']); - $scriptPath = $scriptPath === '/' ? '' : $scriptPath; - $systemUrl = $protocol . '://' . $host . $scriptPath; - } - - // Template aus Datenbank laden - $subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert'); - $bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}"); - - // Änderungen formatieren - $changesText = ''; - foreach ($changes as $change) { - $changesText .= "• $change\n"; - } - - // Platzhalter ersetzen - $placeholders = [ - '{USER_NAME}' => $userName, - '{CHANGES}' => $changesText, - '{APP_TITLE}' => $appTitle, - '{SYSTEM_URL}' => $systemUrl - ]; - - $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); - $body = str_replace(array_keys($placeholders), array_values($placeholders), $bodyTemplate); - - // HTML oder Plain Text prüfen - $isHtml = strip_tags($body) !== $body; - - return $this->send($to, $subject, $body, $isHtml); - } +db = Database::getInstance(); + $this->loadSettings(); + } + + private function loadSettings() { + $this->smtpEnabled = $this->db->getSetting('smtp_enabled', '0') === '1'; + $this->smtpHost = $this->db->getSetting('smtp_host', ''); + $this->smtpPort = (int)$this->db->getSetting('smtp_port', '587'); + $this->smtpUsername = $this->db->getSetting('smtp_username', ''); + $this->smtpPassword = $this->db->getSetting('smtp_password', ''); + $this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls'); + $this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']); + $this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System')); + } + + public function sendRaw($to, $subject, $plainBody) { + return $this->send($to, $subject, $plainBody, false); + } + + public function send($to, $subject, $body, $isHtml = false) { + // Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry). + $attempts = 2; + for ($i = 1; $i <= $attempts; $i++) { + if (!$this->smtpEnabled || empty($this->smtpHost)) { + $ok = $this->sendWithPhpMail($to, $subject, $body); + } else { + $ok = $this->sendWithSmtp($to, $subject, $body, $isHtml); + } + if ($ok) { + return true; + } + if ($i < $attempts) { + usleep(500000); // 0,5s vor erneutem Versuch + } + } + error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen."); + return false; + } + + private function sendWithPhpMail($to, $subject, $body) { + $headers = "From: {$this->fromName} <{$this->fromEmail}>\r\n"; + $headers .= "Reply-To: {$this->fromEmail}\r\n"; + $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; + + return mail($to, $subject, $body, $headers); + } + + private function sendWithSmtp($to, $subject, $body, $isHtml = false) { + try { + // Verbindung aufbauen + $socket = $this->connectToSmtp(); + + // EHLO + $this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); + + // STARTTLS wenn nötig + if ($this->smtpEncryption === 'tls') { + $this->smtpCommand($socket, "STARTTLS"); + stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + $this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']); + } + + // AUTH LOGIN + $this->smtpCommand($socket, "AUTH LOGIN"); + $this->smtpCommand($socket, base64_encode($this->smtpUsername)); + $this->smtpCommand($socket, base64_encode($this->smtpPassword)); + + // MAIL FROM + $this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>"); + + // RCPT TO + $this->smtpCommand($socket, "RCPT TO:<{$to}>"); + + // DATA + $this->smtpCommand($socket, "DATA"); + + // Headers + $message = "From: {$this->fromName} <{$this->fromEmail}>\r\n"; + $message .= "To: {$to}\r\n"; + $message .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n"; + $message .= "MIME-Version: 1.0\r\n"; + + if ($isHtml) { + $message .= "Content-Type: text/html; charset=UTF-8\r\n"; + } else { + $message .= "Content-Type: text/plain; charset=UTF-8\r\n"; + } + + $message .= "\r\n"; + + // Body - bei Plain Text Zeilenumbrüche konvertieren + if (!$isHtml) { + $body = nl2br($body, false); // Für Plain Text + $body = str_replace('
', "\r\n", $body); + } + + $message .= $body; + $message .= "\r\n.\r\n"; + + fwrite($socket, $message); + $response = fgets($socket); + + // QUIT + $this->smtpCommand($socket, "QUIT"); + fclose($socket); + + return strpos($response, '250') === 0; + + } catch (Exception $e) { + error_log("SMTP Error: " . $e->getMessage()); + return false; + } + } + + private function connectToSmtp() { + $context = stream_context_create([ + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true + ] + ]); + + if ($this->smtpEncryption === 'ssl') { + $host = 'ssl://' . $this->smtpHost; + } else { + $host = $this->smtpHost; + } + + $socket = stream_socket_client( + $host . ':' . $this->smtpPort, + $errno, + $errstr, + 30, + STREAM_CLIENT_CONNECT, + $context + ); + + if (!$socket) { + throw new Exception("SMTP Connection failed: $errstr ($errno)"); + } + + // Willkommensnachricht lesen + fgets($socket); + + return $socket; + } + + private function smtpCommand($socket, $command) { + fwrite($socket, $command . "\r\n"); + $response = fgets($socket); + + // Prüfen auf Fehler (4xx oder 5xx) + if (preg_match('/^[45]/', $response)) { + throw new Exception("SMTP Error: $response"); + } + + return $response; + } + + // Vordefinierte E-Mail-Templates + /** + * Legt den Nachrichtentext in ein schlichtes, markentreues HTML-Gerüst. + * Bewusst Tabellen + Inline-Styles: nur so rendern Outlook & Co. zuverlässig. + */ + private function brandedHtml(string $title, string $contentHtml, string $footerNote = ''): string + { + $accent = $this->db->getSetting('brand_gradient_from', '') ?: '#5b5bd6'; + $accent2 = $this->db->getSetting('brand_gradient_to', '') ?: '#8b5cf6'; + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent)) { $accent = '#5b5bd6'; } + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $accent2)) { $accent2 = '#8b5cf6'; } + + $safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); + $year = date('Y'); + $footer = $footerNote !== '' ? '
' . htmlspecialchars($footerNote, ENT_QUOTES, 'UTF-8') . '
' : ''; + + return '' + . '' + . '' . $safeTitle . '' + . '' + . '' + . '
' + . '' + . '' + . '' + . '' + . '
' + . '
' . $safeTitle . '
' + . '
' . $contentHtml . '
' + . '© ' . $year . ' ' . $safeTitle . $footer + . '
'; + } + + /** + * Voucher-Code als hervorgehobene Karte für die E-Mail. + */ + private function voucherCardHtml(string $code, string $siteName, $maxUses): string + { + return '' + . '
' + . '
' + . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '
' + . '
' + . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '
' + . '
' + . htmlspecialchars((string)$maxUses, ENT_QUOTES, 'UTF-8') . ' ' + . htmlspecialchars(function_exists('__') ? __('label_devices') : 'Geräte', ENT_QUOTES, 'UTF-8') . '
' + . '
'; + } + + public function sendVoucherEmail($to, $voucherCode, $siteName, $maxUses) { + $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); + $instructionHeader = $this->db->getSetting('instruction_header', ''); + $instructionText = $this->db->getSetting('instruction_text', ''); + + // System-URL aus Einstellungen oder automatisch erkennen + $systemUrl = $this->db->getSetting('system_url', ''); + if (empty($systemUrl)) { + $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST']; + $scriptPath = dirname($_SERVER['SCRIPT_NAME']); + $scriptPath = $scriptPath === '/' ? '' : $scriptPath; + $systemUrl = $protocol . '://' . $host . $scriptPath; + } + + // Template aus Datenbank laden + $subjectTemplate = $this->db->getSetting('email_voucher_subject', '{APP_TITLE} - Ihr WLAN-Zugang'); + $bodyTemplate = $this->db->getSetting('email_voucher_body', "Hallo,\n\nhier ist Ihr WLAN-Zugangscode:\n{VOUCHER_CARD}\nMaximale Geräte: {MAX_USES}
\nStandort: {SITE_NAME}\n\n{INSTRUCTIONS}\n\nViele Grüße\n{APP_TITLE}"); + + // Anleitung formatieren + $instructions = ''; + if ($instructionText) { + $instructions = $instructionHeader . "\n" . $instructionText; + } + + // Platzhalter ersetzen + $placeholders = [ + '{VOUCHER_CARD}' => $this->voucherCardHtml($voucherCode, (string)$siteName, $maxUses), + '{VOUCHER_CODE}' => $voucherCode, + '{SITE_NAME}' => $siteName, + '{MAX_USES}' => $maxUses, + '{APP_TITLE}' => $appTitle, + '{INSTRUCTIONS}' => $instructions, + '{SYSTEM_URL}' => $systemUrl + ]; + + $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); + + // Umbrueche der Vorlage vor dem Einsetzen der Platzhalter umwandeln, + // sonst wuerde das Markup der Voucher-Karte die Erkennung stoeren. + $isHtml = strip_tags($bodyTemplate) !== $bodyTemplate || strpos($bodyTemplate, '{VOUCHER_CARD}') !== false; + $template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate; + $body = str_replace(array_keys($placeholders), array_values($placeholders), $template); + + if ($isHtml) { + $body = $this->brandedHtml($appTitle, $body); + } + + return $this->send($to, $subject, $body, $isHtml); + } + + public function sendTestEmail($to) { + $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); + $subject = '[Test] E-Mail-Konfiguration – ' . $appTitle; + $body = "Dies ist eine Test-E-Mail von {$appTitle}.\n\nDie SMTP-Konfiguration ist korrekt eingerichtet."; + return $this->send($to, $subject, $body, false); + } + + public function sendUserNotification($to, $userName, $changes) { + $appTitle = $this->db->getSetting('app_title', 'UniFi Voucher System'); + + // System-URL aus Einstellungen oder automatisch erkennen + $systemUrl = $this->db->getSetting('system_url', ''); + if (empty($systemUrl)) { + $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST']; + $scriptPath = dirname($_SERVER['SCRIPT_NAME']); + $scriptPath = $scriptPath === '/' ? '' : $scriptPath; + $systemUrl = $protocol . '://' . $host . $scriptPath; + } + + // Template aus Datenbank laden + $subjectTemplate = $this->db->getSetting('email_user_notification_subject', '{APP_TITLE} - Ihre Berechtigungen wurden geändert'); + $bodyTemplate = $this->db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\nEin Administrator hat Ihre Berechtigungen im {APP_TITLE} geändert:\n\n{CHANGES}\n\nSie können sich unter folgender Adresse anmelden:\n{SYSTEM_URL}\n\nMit freundlichen Grüßen\n{APP_TITLE}"); + + // Änderungen formatieren + $changesText = ''; + foreach ($changes as $change) { + $changesText .= "• $change\n"; + } + + // Platzhalter ersetzen + $placeholders = [ + '{USER_NAME}' => $userName, + '{CHANGES}' => $changesText, + '{APP_TITLE}' => $appTitle, + '{SYSTEM_URL}' => $systemUrl + ]; + + $subject = str_replace(array_keys($placeholders), array_values($placeholders), $subjectTemplate); + + $isHtml = strip_tags($bodyTemplate) !== $bodyTemplate; + $template = $isHtml ? $this->textToHtml($bodyTemplate) : $bodyTemplate; + $body = str_replace(array_keys($placeholders), array_values($placeholders), $template); + + if ($isHtml) { + $body = $this->brandedHtml($appTitle, $body); + } + + return $this->send($to, $subject, $body, $isHtml); + } + + /** + * Zeilenumbrüche aus dem Vorlagentext in HTML übernehmen, ohne bereits + * vorhandenes Markup (z. B. aus dem WYSIWYG-Editor) zu zerstören. + */ + private function textToHtml(string $body): string + { + if (preg_match('#<(p|div|ul|ol|h[1-6])[\s>]#i', $body)) { + return $body; + } + + return nl2br($body, false); + } } \ No newline at end of file diff --git a/includes/Ui.php b/includes/Ui.php new file mode 100644 index 0000000..b7faa7a --- /dev/null +++ b/includes/Ui.php @@ -0,0 +1,259 @@ + mit Versionsstempel. */ + public static function script(string $path, string $base = '', bool $defer = false): string + { + return ''; + } + + /** + * Theme-Bootstrap: gespeicherte Auswahl, sonst Systemeinstellung. + * Muss im stehen, damit nichts hell aufblitzt. + */ + public static function themeScript(): string + { + return ''; + } + + /** Gueltige Hex-Farbe oder Fallback. */ + private static function color(?string $value, string $fallback): string + { + $value = trim((string)$value); + + return preg_match('/^#[0-9a-fA-F]{6}$/', $value) ? strtolower($value) : $fallback; + } + + /** + * CSS-Overrides fuer die Markenfarben. Gibt einen leeren String zurueck, + * wenn nichts vom Standard abweicht. + */ + public static function brandingStyle($db = null): string + { + if (!$db) { + return ''; + } + + $accent = self::color($db->getSetting('brand_accent', ''), self::DEFAULT_ACCENT); + $accentDark = self::color($db->getSetting('brand_accent_dark', ''), self::DEFAULT_ACCENT_DARK); + $from = self::color($db->getSetting('brand_gradient_from', ''), self::DEFAULT_GRADIENT_FROM); + $to = self::color($db->getSetting('brand_gradient_to', ''), self::DEFAULT_GRADIENT_TO); + $radius = (int)$db->getSetting('brand_radius', (string)self::DEFAULT_RADIUS); + $radius = max(0, min(28, $radius)); + + $isDefault = $accent === self::DEFAULT_ACCENT + && $accentDark === self::DEFAULT_ACCENT_DARK + && $from === self::DEFAULT_GRADIENT_FROM + && $to === self::DEFAULT_GRADIENT_TO + && $radius === self::DEFAULT_RADIUS; + + if ($isDefault) { + return ''; + } + + // Abgeleitete Töne über color-mix – so genügt eine einzige Grundfarbe. + return ''; + } + + /** + * Bildfeld mit Vorschau, Upload und URL-Eingabe. + * Wird von den Einstellungen und der Kiosk-Verwaltung genutzt. + */ + public static function imageField( + string $name, + string $label, + ?string $value, + string $hint = '', + string $accept = 'image/*', + string $base = '../' + ): string { + $value = (string)$value; + $preview = self::mediaUrl($value, $base); + $esc = static fn ($text) => htmlspecialchars((string)$text, ENT_QUOTES); + $t = static fn ($key, $fallback) => function_exists('__') ? __($key) : $fallback; + + $thumb = $preview !== '' + ? '' + : ''; + + $remove = $value !== '' + ? '' + : ''; + + return '
' + . '' + . '
' + . '
' . $thumb . '
' + . '
' + . '' + . '' + . $remove + . '
' + . ($hint !== '' ? '
' . $esc($hint) . '
' : '') + . '
'; + } + + /** + * Version aus der Datei VERSION im Projektstamm. + * Damit tragen Oberfläche und Release-Paket dieselbe Nummer. + */ + public static function version(): string + { + static $version = null; + if ($version === null) { + $file = self::root() . '/VERSION'; + $version = is_file($file) ? trim((string)file_get_contents($file)) : ''; + } + + return $version; + } + + /** Entwicklerhinweis – bewusst an einer Stelle gepflegt. */ + public const CREDIT_NAME = 'Loheide.eu'; + public const CREDIT_URL = 'https://loheide.eu'; + + /** + * Dezenter Hinweis auf den Entwickler, wie er im Seitenfuß erscheint. + */ + public static function credit(bool $withVersion = false): string + { + $label = function_exists('__') ? __('credit_by') : 'Entwickelt von'; + + $prefix = ''; + if ($withVersion && self::version() !== '') { + $prefix = 'v' . htmlspecialchars(self::version()) . ' · '; + } + + return '

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

'; + } + + /** + * Standard-Druckvorlage (wird nur verwendet, solange keine eigene + * Vorlage gespeichert ist). {QR_CODE} fuellt der Browser. + */ + public static function defaultPrintTemplate(): string + { + $validUntil = function_exists('__') ? __('print_valid_until') : 'Gültig bis'; + $devices = function_exists('__') ? __('print_devices') : 'Geräte'; + + return '
' + . '
{APP_TITLE}
' + . '
{SITE_NAME}
' + . '{QR_CODE}' + . '
{VOUCHER_CODE}
' + . '
' . $validUntil . ' {EXPIRY_DATE} {EXPIRY_TIME} · {MAX_USES} ' . $devices . '
' + . '
{INSTRUCTIONS}
' + . '
'; + } + + /** + * URL eines Bildes aus den Einstellungen. + * Hochgeladene Dateien liegen relativ zur Projektwurzel (uploads/…), + * externe Adressen bleiben unveraendert. + */ + public static function mediaUrl(string $value, string $base = ''): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + if (preg_match('#^(https?:)?//#i', $value) || strncmp($value, 'data:', 5) === 0 || $value[0] === '/') { + return $value; + } + + return $base . $value; + } + + /** + * Kompletter Standard-Kopf: Favicon, Schrift, Icons, Design-System, + * Theme-Bootstrap und Branding. + */ + public static function head($db = null, string $base = ''): string + { + $out = []; + + $favicon = $db ? self::mediaUrl((string)$db->getSetting('favicon_url', ''), $base) : ''; + if ($favicon !== '') { + $out[] = ''; + } + + $out[] = ''; + $out[] = ''; + $out[] = ''; + $out[] = ''; + $out[] = self::themeScript(); + + $branding = self::brandingStyle($db); + if ($branding !== '') { + $out[] = $branding; + } + + return implode("\n ", $out); + } +} diff --git a/includes/Upload.php b/includes/Upload.php new file mode 100644 index 0000000..2a97b90 --- /dev/null +++ b/includes/Upload.php @@ -0,0 +1,175 @@ + ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'], + 'favicon' => ['ico', 'png', 'svg'], + ]; + + /** + * Uebersetzte Meldung – faellt auf Deutsch zurueck, wenn die Klasse + * ausserhalb einer Seite mit geladener I18n verwendet wird. + */ + private static function msg(string $key, string $fallback): string + { + return function_exists('__') ? __($key) : $fallback; + } + + private static function dir(): string + { + return dirname(__DIR__) . '/uploads'; + } + + /** Legt das Upload-Verzeichnis inkl. Schutzdatei an. */ + public static function ensureDir(): bool + { + $dir = self::dir(); + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { + return false; + } + + $htaccess = $dir . '/.htaccess'; + if (!file_exists($htaccess)) { + @file_put_contents($htaccess, "php_flag engine off\nOptions -ExecCGI\n\n Require all denied\n\n"); + } + + return is_writable($dir); + } + + /** Ist der Pfad eine von uns gespeicherte Datei? */ + public static function isLocal(string $path): bool + { + return $path !== '' && strncmp($path, 'uploads/', 8) === 0 && strpos($path, '..') === false; + } + + /** Loescht eine zuvor hochgeladene Datei (externe URLs bleiben unberuehrt). */ + public static function delete(string $path): void + { + if (!self::isLocal($path)) { + return; + } + $file = dirname(__DIR__) . '/' . $path; + if (is_file($file)) { + @unlink($file); + } + } + + /** + * Nimmt einen Upload entgegen und gibt den relativen Pfad zurueck. + * + * @param array $file Eintrag aus $_FILES + * @param string $kind 'image' oder 'favicon' + * @throws RuntimeException bei ungueltigen Dateien + */ + public static function store(array $file, string $kind = 'image'): string + { + if (!isset($file['error']) || $file['error'] === UPLOAD_ERR_NO_FILE) { + return ''; + } + if ($file['error'] !== UPLOAD_ERR_OK) { + throw new RuntimeException(self::msg('upload_error_generic', 'Die Datei konnte nicht hochgeladen werden.')); + } + if (!is_uploaded_file($file['tmp_name'])) { + throw new RuntimeException(self::msg('upload_error_generic', 'Die Datei konnte nicht hochgeladen werden.')); + } + if ($file['size'] > self::MAX_BYTES) { + throw new RuntimeException(self::msg('upload_error_size', 'Die Datei ist zu groß (maximal 3 MB).')); + } + + $allowed = self::ALLOWED[$kind] ?? self::ALLOWED['image']; + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + if ($ext === 'jpeg') { + $ext = 'jpg'; + } + if (!in_array($ext, $allowed, true)) { + throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.')); + } + + $data = (string)file_get_contents($file['tmp_name']); + + if ($ext === 'svg') { + $data = self::sanitizeSvg($data); + } elseif ($ext !== 'ico') { + // Raster: muss als Bild lesbar sein + if (@getimagesize($file['tmp_name']) === false) { + throw new RuntimeException(self::msg('upload_error_type', 'Dieser Dateityp wird nicht unterstützt.')); + } + } + + if (!self::ensureDir()) { + throw new RuntimeException(self::msg('upload_error_dir', 'Der Ordner uploads/ ist nicht beschreibbar.')); + } + + $name = bin2hex(random_bytes(8)) . '.' . $ext; + $dest = self::dir() . '/' . $name; + if (file_put_contents($dest, $data) === false) { + throw new RuntimeException(self::msg('upload_error_dir', 'Der Ordner uploads/ ist nicht beschreibbar.')); + } + @chmod($dest, 0644); + + return 'uploads/' . $name; + } + + /** + * Neuer Wert eines Bildfeldes aus dem Formular. + * + * Reihenfolge: hochgeladene Datei schlaegt alles, danach der + * Entfernen-Schalter, sonst gilt das URL-Feld. Wird eine zuvor + * hochgeladene Datei ersetzt oder entfernt, verschwindet sie auch + * von der Platte. + * + * @param string $name Feldname (erwartet , _file, _remove) + * @param string $current bisher gespeicherter Wert + * @param string $kind 'image' oder 'favicon' + */ + public static function resolveField(string $name, string $current, string $kind = 'image'): string + { + $uploaded = self::store($_FILES[$name . '_file'] ?? [], $kind); + if ($uploaded !== '') { + self::delete($current); + return $uploaded; + } + + if (!empty($_POST[$name . '_remove'])) { + self::delete($current); + return ''; + } + + $value = trim((string)($_POST[$name] ?? '')); + if ($value !== $current && self::isLocal($current) && !self::isLocal($value)) { + self::delete($current); + } + + return $value; + } + + /** + * Entfernt aktive Inhalte aus SVG-Dateien (Skripte, Event-Handler, + * externe Verweise). Lieber eine Grafik verlieren als eine XSS-Luecke. + */ + private static function sanitizeSvg(string $svg): string + { + if (stripos($svg, ']*>.*?<\s*/\s*\1\s*>#is', '', $svg); + $svg = preg_replace('#<\s*(script|foreignObject|iframe|embed|object|animate|set)\b[^>]*/?>#i', '', $svg); + $svg = preg_replace('#\son[a-z]+\s*=\s*"[^"]*"#i', '', $svg); + $svg = preg_replace("#\son[a-z]+\s*=\s*'[^']*'#i", '', $svg); + $svg = preg_replace('#(href|xlink:href)\s*=\s*([\'"])\s*(javascript|data):[^\'"]*\2#i', '', $svg); + $svg = preg_replace('#]*>#i', '', $svg); + + return (string)$svg; + } +} diff --git a/includes/VoucherService.php b/includes/VoucherService.php new file mode 100644 index 0000000..9ddcaa7 --- /dev/null +++ b/includes/VoucherService.php @@ -0,0 +1,63 @@ + kbit, 'up' => kbit, 'quota_mb' => MB] + * @param int|null $userId angemeldeter Benutzer, sonst null + * @param int|null $kioskId Herkunft, falls ueber eine Display-Seite geholt + * + * @return array{code:string,site_name:string,max_uses:int,expire_min:int,expiry_date:string,expiry_time:string} + * @throws Exception wenn der Controller keinen gueltigen Voucher liefert + */ + public static function create( + $db, + array $site, + string $voucherName, + int $maxUses, + int $expireMinutes, + ?int $userId = null, + array $qos = [], + ?int $kioskId = null + ): array { + $fullName = date('Y-m-d') . '_' . $voucherName; + + $controller = new UniFiController( + $site['unifi_controller_url'], + $site['unifi_username'], + Crypto::decrypt($site['unifi_password']), + $site['site_id'] + ); + + $voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos); + if (!is_array($voucher) || empty($voucher['formatted_code'])) { + throw new Exception(function_exists('__') ? __('error_voucher_invalid') : 'Ungueltige Antwort des Controllers'); + } + + $db->execute( + "INSERT INTO vouchers (site_id, user_id, kiosk_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [$site['id'], $userId, $kioskId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null] + ); + + $expiryTs = time() + ($expireMinutes * 60); + + return [ + 'code' => $voucher['formatted_code'], + 'site_name' => $site['name'], + 'max_uses' => $maxUses, + 'expire_min' => $expireMinutes, + 'expiry_date' => date('d.m.Y', $expiryTs), + 'expiry_time' => date('H:i', $expiryTs), + ]; + } +} diff --git a/includes/admin_nav.php b/includes/admin_nav.php index f952293..167c9ff 100644 --- a/includes/admin_nav.php +++ b/includes/admin_nav.php @@ -4,8 +4,9 @@ * 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 @@ -22,6 +23,7 @@ $navGroups = [ ['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'], ], @@ -43,28 +45,16 @@ foreach ($navGroups as $items) { } } ?> - - - - - - - - - + + -