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/.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 e558be6..af6cb97 100644 --- a/Readme.md +++ b/Readme.md @@ -8,7 +8,7 @@ ![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.6.0-blueviolet) +![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) @@ -47,11 +47,6 @@ - 🌍 **Ö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 @@ -97,11 +92,6 @@ Einstellungen -
- Markenfarben einstellen - Ansicht auf dem Smartphone -
- ### REST-API, 2FA & Integrationen
@@ -259,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 @@ -336,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 @@ -482,40 +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 -vendor/bin/phpstan analyse -``` - ## 🗺️ Roadmap - [x] Voucher-Templates (vordefinierte Laufzeiten) @@ -531,14 +447,11 @@ vendor/bin/phpstan analyse - [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 ---
-**Version 2.6.0** ¡ Autor: **Friederich Loheide** ¡ Lizenz: **MIT** +**Version 2.5.0** ¡ Autor: **Friederich Loheide** ¡ Lizenz: **MIT**
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 52d2937..edde07b 100644 --- a/admin/audit_log.php +++ b/admin/audit_log.php @@ -45,14 +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'] 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', +]; ?> @@ -71,7 +80,7 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
-
+
@@ -88,7 +97,7 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
- - ZurĂźcksetzen + + ZurĂźcksetzen
@@ -105,15 +114,15 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_
- + Einträge
-

+

-
NamePräfixScopeLimitStatusZuletzt genutztErstellt von
uvt_…uvt_… - - + + Löschen
+
@@ -127,34 +136,34 @@ foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_ - - - - - - + @@ -164,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/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
- + - +
+ + + + + + + @@ -322,49 +248,48 @@ $adminBase = '';
-
+
-
+
- - - - - - - - - - + + + + + + + + +
-
-

-
+
+

+
- - +
+

>
- +
-
-

-

+
+

+

Diese Werte werden als Vorgabe im Voucher-Formular verwendet.

@@ -386,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

- - -
- - -
-

-

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

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

+
+

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

@@ -510,7 +370,11 @@ $adminBase = '';
- +
+ + +
+
@@ -536,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
- +
@@ -584,7 +448,7 @@ $adminBase = '';
- +
@@ -593,9 +457,9 @@ $adminBase = '';
-

Microsoft 365

+

Microsoft 365

-

Azure AD App

+

Azure AD App

Redirect URI: /m365_callback.php

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

SMTP

+
+

SMTP

@@ -622,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

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

+
+

- +
@@ -715,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) { @@ -765,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() ?>'); @@ -777,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() { @@ -817,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/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 c2d3815..0000000 --- a/includes/Ui.php +++ /dev/null @@ -1,183 +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 ''; - } - - /** - * 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 39713bf..0000000 --- a/includes/Upload.php +++ /dev/null @@ -1,142 +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; - } - - /** - * 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/admin_nav.php b/includes/admin_nav.php index 4b5b099..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 @@ -44,16 +43,28 @@ foreach ($navGroups as $items) { } } ?> - + + + + + + + + + - -