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/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..af6cb97 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.5.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 @@ -52,11 +47,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** - 🌍 **Ö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 @@ -85,18 +75,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** 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 @@ -114,11 +92,6 @@ Entwickelt von **[Loheide.eu](https://loheide.eu)** Einstellungen
-
- Markenfarben einstellen - Ansicht auf dem Smartphone -
- ### REST-API, 2FA & Integrationen
@@ -157,8 +130,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 +201,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` @@ -334,44 +249,21 @@ gemeinsames Stylesheet: **`assets/global.css`**. - **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: +Eigenes Branding lässt sich meist mit wenigen Zeilen umsetzen – z. B. in einer +eigenen CSS-Datei oder direkt in `assets/global.css`: ```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 */ } ``` -### 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. +Logo und Favicon werden nicht über CSS, sondern unter +**Administration → Einstellungen → Allgemein** gesetzt. ### Login-Seite individualisieren @@ -411,30 +303,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 +432,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) @@ -674,17 +447,11 @@ dem Git-Hoster. - [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.5.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..4817b71 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,12 +66,12 @@ $adminBase = ''; -<?= __('api_title') ?> – <?= htmlspecialchars($appTitle) ?> +API-Schlüssel – <?= 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.

- - +
+ - - - - - - - + + + + + + + @@ -138,8 +138,8 @@ $adminBase = '';
-

-

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,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_…"
-

/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..edde07b 100644 --- a/admin/audit_log.php +++ b/admin/audit_log.php @@ -45,15 +45,23 @@ $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); -} +$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', +]; ?> @@ -72,7 +80,7 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
-
+
@@ -89,7 +97,7 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
- - Zurücksetzen + + Zurücksetzen
@@ -106,15 +114,15 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
- + Einträge
-

+

-
NamePräfixScopeLimitStatusZuletzt genutztErstellt von
uvt_uvt_ - - + + Löschen
+
@@ -128,34 +136,34 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_ - - - - - - + @@ -165,18 +173,18 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_ 1): ?> diff --git a/admin/backup.php b/admin/backup.php index bc4bfa6..b8afeee 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,12 +112,12 @@ $adminBase = ''; -<?= __('backup_title') ?> – <?= htmlspecialchars($appTitle) ?> +Backup & Restore – <?= htmlspecialchars($appTitle) ?> @@ -127,21 +125,21 @@ $adminBase = '';
-

-

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..d938ac5 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(); } @@ -95,8 +95,8 @@ $adminBase = ''; @@ -104,27 +104,27 @@ $adminBase = '';
-

-

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

- + - + - + - +
-

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

Ergebnis

+
diff --git a/admin/index.php b/admin/index.php index 4f3ee40..a7fa9a0 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'; @@ -91,7 +90,7 @@ $currentPage = 'dashboard'; <?= __('dashboard_title') ?> – <?= htmlspecialchars($appTitle) ?> - + @@ -114,21 +113,21 @@ $currentPage = 'dashboard';
-
+
-
+
-
+
@@ -136,21 +135,21 @@ $currentPage = 'dashboard';
-
+
-
+
-
+
@@ -183,9 +182,9 @@ $currentPage = 'dashboard';
-
+

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

-

+

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

+

-
NameCode / FehlerStatus
+
- - - - + + + @@ -255,7 +254,7 @@ $currentPage = 'dashboard'; -
+
diff --git a/admin/integrations.php b/admin/integrations.php index cfc7c83..683c5ad 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,12 +92,12 @@ $adminBase = ''; -<?= __('int_title') ?> – <?= htmlspecialchars($appTitle) ?> +Integration & Wartung – <?= 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.

+ + - + - +
-
+
-

-

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,18 +174,18 @@ $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..53427dd 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,46 @@ $adminBase = ''; -<?= __('rep_title') ?> – <?= htmlspecialchars($appTitle) ?> - +Reporting – <?= htmlspecialchars($appTitle) ?> +
- + - - - - + CSV (täglich) + CSV (pro Site) + CSV (pro Nutzer) +
-
-
-
-
+
Vouchers gesamt
+
Gültig
+
Verwendet
+
In Tagen erstellt
-

+

Erstellte Voucher ( Tage)

-

-
+
+

Pro Site

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

-
SiteGesamtGültigVerwendetAbgelaufen
+

Top-Nutzer

+
- +
BenutzerVoucher erstellt
Keine Daten
diff --git a/admin/security.php b/admin/security.php index 3de454c..b7b4ab6 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,41 @@ $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,34 +129,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.

-
-


- 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
- + - +
+ + + + + + + @@ -260,49 +248,48 @@ $adminBase = '';
-
+
-
+
- - - - - - - - - - + + + + + + + + +
-
-

-
+
+

+
- - +
+

>
- +
-
-

-

+
+

+

Diese Werte werden als Vorgabe im Voucher-Formular verwendet.

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

- - -
- - -
-

-

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

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

+
+

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

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

Microsoft 365

+

Microsoft 365

-

Azure AD App

+

Azure AD App

Redirect URI: /m365_callback.php

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

SMTP

+
+

SMTP

@@ -560,60 +486,60 @@ $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,14 +551,14 @@ $adminBase = '';
-
-

+
+

- +
@@ -653,43 +579,17 @@ 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; updateBrandPreview(); }); + swatch.addEventListener('input', () => { field.value = swatch.value; }); field.addEventListener('input', () => { if (/^#[0-9a-fA-F]{6}$/.test(field.value.trim())) swatch.value = field.value.trim(); }); }); -// Tab aus Anker uebernehmen (der Query-Parameter wird serverseitig gesetzt) +// Restore tab from hash window.addEventListener('DOMContentLoaded', function() { const hash = location.hash.substring(1); if (hash) { @@ -703,9 +603,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 = ''; return; } + if (!email) { result.textContent = 'Bitte E-Mail eingeben.'; return; } btn.disabled = true; - btn.innerHTML = ''; + btn.innerHTML = ''; const fd = new FormData(); fd.append('ajax_smtp_test', '1'); fd.append('csrf_token', 'getCsrfToken() ?>'); @@ -715,26 +615,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 = ' '; + btn.innerHTML = ' Läuft...'; 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 = `: ${e.message}`; + result.innerHTML = `Fehler: ${e.message}`; } btn.disabled = false; - btn.innerHTML = ' '; + btn.innerHTML = ' Jetzt ausführen'; } function initTinyMCE() { @@ -755,10 +655,6 @@ 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 23b0e14..caf0d2c 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';
-
+
- +

@@ -114,7 +118,7 @@ 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/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..866b2eb 100644 --- a/includes/Mailer.php +++ b/includes/Mailer.php @@ -1,339 +1,265 @@ -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->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); + } } \ 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/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..f952293 100644 --- a/includes/admin_nav.php +++ b/includes/admin_nav.php @@ -4,9 +4,8 @@ * 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 @@ -23,7 +22,6 @@ $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'], ], @@ -45,16 +43,28 @@ foreach ($navGroups as $items) { } } ?> - + + + + + + + + + - -