Self-Hosting, Branding, i18n, Barrierefreiheit und Werkzeuge #2
26
.htaccess
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sicherheits-Header und Zugriffsschutz (Apache)
|
||||||
|
# Nginx-Entsprechung siehe Readme.md, Abschnitt "Sicherheit".
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
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'"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Kein Verzeichnislisting
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
# Dateien, die nie direkt ausgeliefert werden sollen
|
||||||
|
<FilesMatch "^(config\.php|composer\.(json|lock)|phpunit\.xml\.dist|phpstan\.neon|database\.sql)$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Interne Ordner schuetzen sich ueber eigene .htaccess-Dateien
|
||||||
|
# (funktioniert auch bei Installation in einem Unterverzeichnis).
|
||||||
|
|
@ -16,11 +16,14 @@ RUN { \
|
||||||
echo 'post_max_size=8M'; \
|
echo 'post_max_size=8M'; \
|
||||||
} > /usr/local/etc/php/conf.d/zz-voucher.ini
|
} > /usr/local/etc/php/conf.d/zz-voucher.ini
|
||||||
|
|
||||||
|
# .htaccess auswerten (Sicherheits-Header, Schutz des uploads-Ordners)
|
||||||
|
RUN sed -ri 's!<Directory /var/www/>!<Directory /var/www/>\n\tAllowOverride All!g' /etc/apache2/apache2.conf
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
COPY . /var/www/html
|
COPY . /var/www/html
|
||||||
|
|
||||||
# Laufzeit-Verzeichnis des Updaters beschreibbar machen
|
# Laufzeit-Verzeichnisse beschreibbar machen
|
||||||
RUN mkdir -p /var/www/html/updater/storage \
|
RUN mkdir -p /var/www/html/updater/storage /var/www/html/uploads \
|
||||||
&& chown -R www-data:www-data /var/www/html
|
&& chown -R www-data:www-data /var/www/html
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
|
||||||
115
Readme.md
|
|
@ -8,7 +8,7 @@
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -47,6 +47,11 @@
|
||||||
- 🌍 **Öffentlicher Modus** – optional ohne Login nutzbar (mit CSRF-Schutz & Throttle)
|
- 🌍 **Ö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)
|
- 🎨 **Einheitliches Design-System** – ein Stylesheet für Frontend, Login und Backend (Tokens, Komponenten, Light/Dark)
|
||||||
- 🏷️ **Login-Seite individualisierbar** – Firmenname, Logo, Texte, Hintergrundbild bzw. Farbverlauf
|
- 🏷️ **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
|
- 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert
|
||||||
- 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`)
|
- 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`)
|
||||||
- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay
|
- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay
|
||||||
|
|
@ -92,6 +97,11 @@
|
||||||
<img src="docs/screenshots/settings.png" alt="Einstellungen" width="48%">
|
<img src="docs/screenshots/settings.png" alt="Einstellungen" width="48%">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/settings-branding.png" alt="Markenfarben einstellen" width="48%">
|
||||||
|
<img src="docs/screenshots/mobile-vouchers.png" alt="Ansicht auf dem Smartphone" width="22%">
|
||||||
|
</div>
|
||||||
|
|
||||||
### REST-API, 2FA & Integrationen
|
### REST-API, 2FA & Integrationen
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
@ -249,21 +259,44 @@ gemeinsames Stylesheet: **`assets/global.css`**.
|
||||||
- **Dark Mode** ausschließlich über Tokens – keine `!important`-Overrides mehr
|
- **Dark Mode** ausschließlich über Tokens – keine `!important`-Overrides mehr
|
||||||
- **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback
|
- **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback
|
||||||
|
|
||||||
Eigenes Branding lässt sich meist mit wenigen Zeilen umsetzen – z. B. in einer
|
### Markenfarben ohne Code
|
||||||
eigenen CSS-Datei oder direkt in `assets/global.css`:
|
|
||||||
|
Unter **Administration → Einstellungen → Design** lassen sich Akzentfarbe
|
||||||
|
(hell und dunkel), Markenverlauf und Eckenradius setzen. Abgeleitete Töne –
|
||||||
|
Hover, weiche Flächen, Rahmen, Fokusring – berechnet das System per `color-mix`
|
||||||
|
aus der Grundfarbe; eine Farbe genügt also. Eine Live-Vorschau zeigt Button,
|
||||||
|
Badge, Chip und Logo-Kachel sofort im neuen Ton.
|
||||||
|
|
||||||
|
Die Werte landen als schlanker `:root`-Override im Seitenkopf und gelten überall,
|
||||||
|
auch auf Login-Seite, Installer und Updater. Wer lieber in CSS arbeitet, kann
|
||||||
|
dieselben Variablen weiterhin in `assets/global.css` überschreiben:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
:root {
|
:root {
|
||||||
--accent: #0f766e; /* Primärfarbe (Buttons, aktive Navigation) */
|
--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%);
|
--brand-gradient: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%);
|
||||||
--r-lg: 14px; /* Eckenradius für Cards */
|
--r-lg: 14px; /* Eckenradius für Cards */
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Logo und Favicon werden nicht über CSS, sondern unter
|
### Bilder hochladen
|
||||||
**Administration → Einstellungen → Allgemein** gesetzt.
|
|
||||||
|
Logo, Favicon, Login-Logo und Login-Hintergrund lassen sich direkt hochladen –
|
||||||
|
alternativ bleibt das URL-Feld bestehen. Die Dateien landen unter `uploads/`
|
||||||
|
(Docker: eigenes Volume, siehe unten). Erlaubt sind PNG, JPG, WEBP, GIF und SVG
|
||||||
|
bis 3 MB; SVGs werden vor dem Speichern von Skripten und externen Verweisen
|
||||||
|
befreit, und im Upload-Ordner sperrt eine `.htaccess` die PHP-Ausführung.
|
||||||
|
|
||||||
|
### Assets ohne Drittanbieter
|
||||||
|
|
||||||
|
Schrift (Inter), Icons (Font Awesome), Diagramme (Chart.js), QR-Codes und der
|
||||||
|
WYSIWYG-Editor (TinyMCE) liegen unter `assets/vendor/` und kommen vom eigenen
|
||||||
|
Server. Das hält Besucher-IPs bei Ihnen – und die Oberfläche funktioniert auch
|
||||||
|
dort, wo das Netz keinen Weg nach außen hat. Details und Aktualisierungs-Hinweise:
|
||||||
|
[`assets/vendor/README.md`](assets/vendor/README.md).
|
||||||
|
|
||||||
|
Alle Asset-URLs tragen einen Versionsstempel (`?v=…`), damit Browser nach einem
|
||||||
|
Update nicht die alten Dateien aus dem Cache verwenden.
|
||||||
|
|
||||||
### Login-Seite individualisieren
|
### Login-Seite individualisieren
|
||||||
|
|
||||||
|
|
@ -303,13 +336,30 @@ Das Tool ist auf einen sicheren Standardbetrieb ausgelegt:
|
||||||
| **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout |
|
| **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout |
|
||||||
| **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) |
|
| **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) |
|
||||||
|
|
||||||
Empfohlene zusätzliche Härtung am Server:
|
Mitgeliefert wird eine `.htaccess` im Projektstamm mit Sicherheits-Headern
|
||||||
|
(`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
|
||||||
|
`Permissions-Policy` und einer Content-Security-Policy). Da alle Assets lokal
|
||||||
|
liegen, erlaubt die CSP nur noch die eigene Herkunft – externe Verbindungen
|
||||||
|
bleiben lediglich für hCaptcha offen, falls es aktiviert wird. Ordner wie
|
||||||
|
`includes/`, `tools/`, `tests/` und `uploads/` schützen sich über eigene
|
||||||
|
`.htaccess`-Dateien.
|
||||||
|
|
||||||
```apache
|
> **Apache:** `AllowOverride All` muss für das Verzeichnis gesetzt sein, sonst
|
||||||
# .htaccess – sensible Dateien sperren (wird vom Installer erzeugt)
|
> werden die `.htaccess`-Dateien ignoriert. Das mitgelieferte Docker-Image
|
||||||
<FilesMatch "^(config\.php|database\.sql|install\.php|test\.php|m365_debug\.php|.*\.md)$">
|
> erledigt das bereits.
|
||||||
Require all denied
|
|
||||||
</FilesMatch>
|
Für **Nginx** entspricht das:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'" always;
|
||||||
|
|
||||||
|
location ~ ^/(includes|tools|tests)/ { deny all; }
|
||||||
|
location ~ ^/updater/(storage|migrations)/ { deny all; }
|
||||||
|
location ~ ^/(config\.php|database\.sql)$ { deny all; }
|
||||||
|
location ^~ /uploads/ { location ~ \.php$ { deny all; } }
|
||||||
```
|
```
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
|
@ -432,6 +482,40 @@ 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
|
Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV
|
||||||
setzen (`DB_*`, `APP_KEY`).
|
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
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
- [x] Voucher-Templates (vordefinierte Laufzeiten)
|
- [x] Voucher-Templates (vordefinierte Laufzeiten)
|
||||||
|
|
@ -447,11 +531,14 @@ setzen (`DB_*`, `APP_KEY`).
|
||||||
- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
|
- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
|
||||||
- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan)
|
- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan)
|
||||||
- [x] Gemeinsames Design-System für Frontend, Login und Backend
|
- [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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**Version 2.5.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
|
**Version 2.6.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,22 @@ $cs = [
|
||||||
'email_user_notification_subject' => $db->getSetting('email_user_notification_subject', '{APP_TITLE} - Berechtigungen geändert'),
|
'email_user_notification_subject' => $db->getSetting('email_user_notification_subject', '{APP_TITLE} - Berechtigungen geändert'),
|
||||||
'email_user_notification_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"),
|
'email_user_notification_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"),
|
||||||
'print_template' => $db->getSetting('print_template', Ui::defaultPrintTemplate()),
|
'print_template' => $db->getSetting('print_template', Ui::defaultPrintTemplate()),
|
||||||
|
'brand_accent' => $db->getSetting('brand_accent', '') ?: Ui::DEFAULT_ACCENT,
|
||||||
|
'brand_accent_dark' => $db->getSetting('brand_accent_dark', '') ?: Ui::DEFAULT_ACCENT_DARK,
|
||||||
|
'brand_gradient_from' => $db->getSetting('brand_gradient_from', '') ?: Ui::DEFAULT_GRADIENT_FROM,
|
||||||
|
'brand_gradient_to' => $db->getSetting('brand_gradient_to', '') ?: Ui::DEFAULT_GRADIENT_TO,
|
||||||
|
'brand_radius' => $db->getSetting('brand_radius', (string)Ui::DEFAULT_RADIUS),
|
||||||
|
'login_panel_enabled' => $db->getSetting('login_panel_enabled', '1'),
|
||||||
|
'login_brand_name' => $db->getSetting('login_brand_name', ''),
|
||||||
|
'login_logo_url' => $db->getSetting('login_logo_url', ''),
|
||||||
|
'login_claim_title' => $db->getSetting('login_claim_title', ''),
|
||||||
|
'login_claim_text' => $db->getSetting('login_claim_text', ''),
|
||||||
|
'login_features' => $db->getSetting('login_features', ''),
|
||||||
|
'login_footer' => $db->getSetting('login_footer', ''),
|
||||||
|
'login_bg_image' => $db->getSetting('login_bg_image', ''),
|
||||||
|
'login_bg_from' => $db->getSetting('login_bg_from', '#3b2f8f'),
|
||||||
|
'login_bg_to' => $db->getSetting('login_bg_to', '#6d5ce7'),
|
||||||
|
'login_bg_overlay' => $db->getSetting('login_bg_overlay', '40'),
|
||||||
'cron_token' => $db->getSetting('cron_token', ''),
|
'cron_token' => $db->getSetting('cron_token', ''),
|
||||||
'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
|
'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
|
||||||
];
|
];
|
||||||
|
|
@ -250,8 +266,9 @@ $cs = [
|
||||||
/**
|
/**
|
||||||
* Bildfeld: Vorschau, Upload, alternativ URL – plus Entfernen-Schalter.
|
* Bildfeld: Vorschau, Upload, alternativ URL – plus Entfernen-Schalter.
|
||||||
*/
|
*/
|
||||||
function imageField(string $name, string $label, string $value, string $hint = '', string $accept = 'image/*'): void
|
function imageField(string $name, string $label, ?string $value, string $hint = '', string $accept = 'image/*'): void
|
||||||
{
|
{
|
||||||
|
$value = (string)$value;
|
||||||
$preview = Ui::mediaUrl($value, '../');
|
$preview = Ui::mediaUrl($value, '../');
|
||||||
?>
|
?>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -278,6 +295,9 @@ function imageField(string $name, string $label, string $value, string $hint = '
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aktiver Tab per ?tab=… (Deep-Link, funktioniert auch ohne JavaScript)
|
||||||
|
$activeTab = preg_replace('/[^a-z_]/', '', (string)($_GET['tab'] ?? 'general')) ?: 'general';
|
||||||
|
|
||||||
$currentPage = 'settings';
|
$currentPage = 'settings';
|
||||||
$adminBase = '';
|
$adminBase = '';
|
||||||
?>
|
?>
|
||||||
|
|
@ -310,20 +330,20 @@ $adminBase = '';
|
||||||
|
|
||||||
<div class="tab-container">
|
<div class="tab-container">
|
||||||
<div class="tab-navigation" id="tabNav">
|
<div class="tab-navigation" id="tabNav">
|
||||||
<button class="tab-button active" data-tab="general"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></button>
|
<button class="tab-button<?= $activeTab === 'general' ? ' active' : '' ?>" data-tab="general"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></button>
|
||||||
<button class="tab-button" data-tab="defaults"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></button>
|
<button class="tab-button<?= $activeTab === 'defaults' ? ' active' : '' ?>" data-tab="defaults"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></button>
|
||||||
<button class="tab-button" data-tab="branding"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></button>
|
<button class="tab-button<?= $activeTab === 'branding' ? ' active' : '' ?>" data-tab="branding"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></button>
|
||||||
<button class="tab-button" data-tab="login"><i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('settings_tab_login') ?></button>
|
<button class="tab-button<?= $activeTab === 'login' ? ' active' : '' ?>" data-tab="login"><i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('settings_tab_login') ?></button>
|
||||||
<button class="tab-button" data-tab="cron"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></button>
|
<button class="tab-button<?= $activeTab === 'cron' ? ' active' : '' ?>" data-tab="cron"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></button>
|
||||||
<button class="tab-button" data-tab="m365"><i class="fab fa-microsoft" aria-hidden="true"></i> <?= __('settings_tab_m365') ?></button>
|
<button class="tab-button" data-tab="m365"><i class="fab fa-microsoft" aria-hidden="true"></i> <?= __('settings_tab_m365') ?></button>
|
||||||
<button class="tab-button" data-tab="smtp"><i class="fas fa-envelope" aria-hidden="true"></i> <?= __('settings_tab_smtp') ?></button>
|
<button class="tab-button<?= $activeTab === 'smtp' ? ' active' : '' ?>" data-tab="smtp"><i class="fas fa-envelope" aria-hidden="true"></i> <?= __('settings_tab_smtp') ?></button>
|
||||||
<button class="tab-button" data-tab="templates_email"><i class="fas fa-file-alt" aria-hidden="true"></i> <?= __('settings_tab_templates_email') ?></button>
|
<button class="tab-button<?= $activeTab === 'templates_email' ? ' active' : '' ?>" data-tab="templates_email"><i class="fas fa-file-alt" aria-hidden="true"></i> <?= __('settings_tab_templates_email') ?></button>
|
||||||
<button class="tab-button" data-tab="system"><i class="fas fa-cogs" aria-hidden="true"></i> <?= __('settings_tab_system') ?></button>
|
<button class="tab-button<?= $activeTab === 'system' ? ' active' : '' ?>" data-tab="system"><i class="fas fa-cogs" aria-hidden="true"></i> <?= __('settings_tab_system') ?></button>
|
||||||
<button class="tab-button" data-tab="password"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></button>
|
<button class="tab-button<?= $activeTab === 'password' ? ' active' : '' ?>" data-tab="password"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Allgemein -->
|
<!-- Allgemein -->
|
||||||
<div id="tab-general" class="tab-content active">
|
<div id="tab-general" class="tab-content<?= $activeTab === 'general' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></h2>
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
|
|
@ -342,7 +362,7 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Voucher-Standards -->
|
<!-- Voucher-Standards -->
|
||||||
<div id="tab-defaults" class="tab-content">
|
<div id="tab-defaults" class="tab-content<?= $activeTab === 'defaults' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></h2>
|
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_defaults') ?></h2>
|
||||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_defaults_hint') ?></p>
|
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_defaults_hint') ?></p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
|
@ -378,7 +398,7 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Design & Branding -->
|
<!-- Design & Branding -->
|
||||||
<div id="tab-branding" class="tab-content">
|
<div id="tab-branding" class="tab-content<?= $activeTab === 'branding' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></h2>
|
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></h2>
|
||||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_branding_intro') ?></p>
|
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_branding_intro') ?></p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
|
@ -447,7 +467,7 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login-Seite -->
|
<!-- Login-Seite -->
|
||||||
<div id="tab-login" class="tab-content">
|
<div id="tab-login" class="tab-content<?= $activeTab === 'login' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('settings_tab_login') ?></h2>
|
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('settings_tab_login') ?></h2>
|
||||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_login_intro') ?></p>
|
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_login_intro') ?></p>
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
|
@ -525,7 +545,7 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cron-Sync -->
|
<!-- Cron-Sync -->
|
||||||
<div id="tab-cron" class="tab-content">
|
<div id="tab-cron" class="tab-content<?= $activeTab === 'cron' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-clock" aria-hidden="true"></i> <?= __('settings_tab_cron') ?></h2>
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> <?= __('settings_cron_what') ?></h4>
|
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> <?= __('settings_cron_what') ?></h4>
|
||||||
|
|
@ -589,7 +609,7 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SMTP -->
|
<!-- SMTP -->
|
||||||
<div id="tab-smtp" class="tab-content">
|
<div id="tab-smtp" class="tab-content<?= $activeTab === 'smtp' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope" aria-hidden="true"></i> SMTP</h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-envelope" aria-hidden="true"></i> SMTP</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
|
|
@ -620,7 +640,7 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- E-Mail Templates -->
|
<!-- E-Mail Templates -->
|
||||||
<div id="tab-templates_email" class="tab-content">
|
<div id="tab-templates_email" class="tab-content<?= $activeTab === 'templates_email' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt" aria-hidden="true"></i> E-Mail Templates</h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-file-alt" aria-hidden="true"></i> E-Mail Templates</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
|
|
@ -641,7 +661,7 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System -->
|
<!-- System -->
|
||||||
<div id="tab-system" class="tab-content">
|
<div id="tab-system" class="tab-content<?= $activeTab === 'system' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs" aria-hidden="true"></i> System & Erweitert</h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-cogs" aria-hidden="true"></i> System & Erweitert</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
|
|
@ -667,7 +687,7 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Passwort -->
|
<!-- Passwort -->
|
||||||
<div id="tab-password" class="tab-content">
|
<div id="tab-password" class="tab-content<?= $activeTab === 'password' ? ' active' : '' ?>">
|
||||||
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></h2>
|
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-key" aria-hidden="true"></i> <?= __('settings_tab_password') ?></h2>
|
||||||
<form method="post" style="max-width:500px;">
|
<form method="post" style="max-width:500px;">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
|
|
@ -731,7 +751,7 @@ document.querySelectorAll('.color-swatch').forEach(swatch => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore tab from hash
|
// Tab aus Anker uebernehmen (der Query-Parameter wird serverseitig gesetzt)
|
||||||
window.addEventListener('DOMContentLoaded', function() {
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
const hash = location.hash.substring(1);
|
const hash = location.hash.substring(1);
|
||||||
if (hash) {
|
if (hash) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ services:
|
||||||
# nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));"
|
# nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));"
|
||||||
APP_KEY: ""
|
APP_KEY: ""
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
|
volumes:
|
||||||
|
# Hochgeladene Logos/Hintergruende ueberleben so ein Image-Update
|
||||||
|
- uploads:/var/www/html/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -36,3 +39,4 @@ services:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
uploads:
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 377 KiB After Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 377 KiB After Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 464 KiB After Width: | Height: | Size: 465 KiB |
|
Before Width: | Height: | Size: 1 MiB After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 244 KiB |
BIN
docs/screenshots/mobile-vouchers.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/screenshots/settings-branding.png
Normal file
|
After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 750 KiB After Width: | Height: | Size: 751 KiB |
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 412 KiB |
2
includes/.htaccess
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
|
||||||
|
Require all denied
|
||||||
2
tests/.htaccess
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
|
||||||
|
Require all denied
|
||||||
2
tools/.htaccess
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
|
||||||
|
Require all denied
|
||||||
42
tools/README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Werkzeuge
|
||||||
|
|
||||||
|
## Demo-Instanz (`tools/demo/build.py`)
|
||||||
|
|
||||||
|
Baut aus dem Projekt eine lauffähige Kopie **ohne Datenbank**: `Database` und
|
||||||
|
`Auth` werden durch Stubs mit festen Beispieldaten ersetzt (`tools/demo/overlay/`).
|
||||||
|
Das Original bleibt unverändert.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/demo/build.py /tmp/uvt-demo
|
||||||
|
php -S 127.0.0.1:8123 -t /tmp/uvt-demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Nützlich für:
|
||||||
|
|
||||||
|
* **Screenshots** für die Dokumentation – immer mit denselben Daten
|
||||||
|
* **Smoke-Test** – jede Seite einmal rendern, ohne MySQL aufzusetzen
|
||||||
|
|
||||||
|
Demo-spezifische Zustände werden über Query-Parameter erreicht:
|
||||||
|
|
||||||
|
| Parameter | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `?demo=result` / `?demo=bulk` | Voucher-Ergebnis bzw. Bulk-Liste auf `index.php` |
|
||||||
|
| `?demo=new` | frisch erzeugter Schlüssel auf `admin/api_keys.php` |
|
||||||
|
| `?anon=1` | nicht angemeldet (für die Login-Seite) |
|
||||||
|
| `?theme=dark` | erzwingt den Dark Mode |
|
||||||
|
| `?brand=custom` / `?brand=nopanel` | Beispiel-Branding der Login-Seite |
|
||||||
|
|
||||||
|
## Screenshots (`tools/screenshots.py`)
|
||||||
|
|
||||||
|
Rendert die Bilder aus `docs/screenshots/` neu. Voraussetzung ist ein
|
||||||
|
headless Chromium; der Pfad kommt aus `$CHROME_BIN` oder wird in den
|
||||||
|
üblichen Verzeichnissen gesucht.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/demo/build.py /tmp/uvt-demo
|
||||||
|
php -S 127.0.0.1:8123 -t /tmp/uvt-demo &
|
||||||
|
CHROME_BIN=/usr/bin/chromium python3 tools/screenshots.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Skripte sind Hilfsmittel für die Entwicklung – im Betrieb werden sie nicht
|
||||||
|
benötigt und sind per `.htaccess` nicht über HTTP erreichbar.
|
||||||
78
tools/screenshots.py
Executable file
|
|
@ -0,0 +1,78 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Erzeugt die Screenshots in docs/screenshots aus der laufenden Demo-Instanz.
|
||||||
|
|
||||||
|
Voraussetzung:
|
||||||
|
python3 tools/demo/build.py /tmp/uvt-demo
|
||||||
|
php -S 127.0.0.1:8123 -t /tmp/uvt-demo &
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 tools/screenshots.py [basis-url]
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
OUT = os.path.join(ROOT, 'docs', 'screenshots')
|
||||||
|
SCALE = 2
|
||||||
|
|
||||||
|
# (Datei, Pfad, Breite, Hoehe) – Breite/Hoehe in CSS-Pixeln, Ausgabe in 2x
|
||||||
|
SHOTS = [
|
||||||
|
('login.png', 'login.php?anon=1', 1200, 850),
|
||||||
|
('login-branding.png', 'login.php?anon=1&brand=custom', 1200, 850),
|
||||||
|
('voucher-form.png', 'index.php', 1200, 880),
|
||||||
|
('voucher-result.png', 'index.php?demo=result', 1200, 900),
|
||||||
|
('bulk-vouchers.png', 'index.php?demo=bulk', 1200, 800),
|
||||||
|
('admin-dashboard.png', 'admin/index.php', 1200, 1400),
|
||||||
|
('admin-dashboard-dark.png', 'admin/index.php?theme=dark', 1200, 1400),
|
||||||
|
('vouchers.png', 'admin/vouchers.php', 1200, 1120),
|
||||||
|
('settings.png', 'admin/settings.php', 1200, 950),
|
||||||
|
('settings-login.png', 'admin/settings.php?tab=login', 1200, 1000),
|
||||||
|
('settings-branding.png', 'admin/settings.php?tab=branding', 1200, 900),
|
||||||
|
('api-keys.png', 'admin/api_keys.php?demo=new', 1200, 830),
|
||||||
|
('integrations.png', 'admin/integrations.php', 1200, 900),
|
||||||
|
('two-factor.png', 'admin/security.php', 1200, 900),
|
||||||
|
('updater.png', 'admin/update.php', 1200, 780),
|
||||||
|
('updater-available.png', 'admin/update.php?demo=available', 1200, 780),
|
||||||
|
('mobile-vouchers.png', 'admin/users.php', 430, 860),
|
||||||
|
('maintenance.png', 'updater/templates/maintenance.html', 1200, 700),
|
||||||
|
]
|
||||||
|
|
||||||
|
CANDIDATES = [
|
||||||
|
os.environ.get('CHROME_BIN', ''),
|
||||||
|
'/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome',
|
||||||
|
'/opt/pw-browsers/chromium', shutil.which('chromium') or '',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def find_browser() -> str:
|
||||||
|
for path in CANDIDATES:
|
||||||
|
if path and os.path.isfile(path) and os.access(path, os.X_OK):
|
||||||
|
return path
|
||||||
|
raise SystemExit('Kein Chromium gefunden – Pfad ueber CHROME_BIN setzen.')
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
base = (sys.argv[1] if len(sys.argv) > 1 else 'http://127.0.0.1:8123').rstrip('/')
|
||||||
|
browser = find_browser()
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
for name, path, width, height in SHOTS:
|
||||||
|
target = os.path.join(OUT, name)
|
||||||
|
subprocess.run([
|
||||||
|
browser, '--headless', '--no-sandbox', '--disable-gpu', '--hide-scrollbars',
|
||||||
|
'--force-color-profile=srgb', '--disable-lcd-text', '--font-render-hinting=none',
|
||||||
|
'--virtual-time-budget=4000', '--force-prefers-reduced-motion',
|
||||||
|
'--force-device-scale-factor=%d' % SCALE,
|
||||||
|
'--window-size=%d,%d' % (width, height),
|
||||||
|
'--screenshot=%s' % target,
|
||||||
|
'%s/%s' % (base, path),
|
||||||
|
], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
print('%-28s %s' % (name, 'ok' if os.path.exists(target) else 'FEHLER'))
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
2
updater/migrations/.htaccess
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
|
||||||
|
Require all denied
|
||||||
|
|
@ -66,6 +66,9 @@
|
||||||
100% { transform: translateX(300%); }
|
100% { transform: translateX(300%); }
|
||||||
}
|
}
|
||||||
.hint { font-size: 12.5px; color: #8a93a3; }
|
.hint { font-size: 12.5px; color: #8a93a3; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bar::after { animation: none; width: 45%; }
|
||||||
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body { color: #e9ecf3; background: radial-gradient(760px 420px at 12% -8%, #1a1c30, transparent 62%), #0a0c11; }
|
body { color: #e9ecf3; background: radial-gradient(760px 420px at 12% -8%, #1a1c30, transparent 62%), #0a0c11; }
|
||||||
.card { background: #12151d; border-color: #232838; }
|
.card { background: #12151d; border-color: #232838; }
|
||||||
|
|
|
||||||