Sicherheits-Header, Werkzeuge und Dokumentation
Sicherheit: - .htaccess im Projektstamm mit X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy und einer Content-Security-Policy; da alle Assets lokal liegen, erlaubt sie nur noch die eigene Herkunft (Ausnahme: hCaptcha, falls aktiviert) - includes/, tools/, tests/, updater/storage und uploads/ schützen sich über eigene .htaccess-Dateien – auch bei Installation im Unterordner - Docker: AllowOverride All, damit diese Regeln überhaupt greifen, und ein Volume für uploads/, damit Logos ein Image-Update überstehen Werkzeuge: - tools/screenshots.py erzeugt alle Bilder in docs/screenshots aus der Demo-Instanz; tools/README.md beschreibt beides - Einstellungs-Tabs sind per ?tab=… direkt verlinkbar (serverseitig, also auch ohne JavaScript) Dokumentation: Readme um Markenfarben, Bild-Upload, lokale Assets, Sicherheits-Header (inkl. Nginx-Entsprechung) und einen Abschnitt "Entwicklung" ergänzt; Screenshots neu erzeugt, Version 2.6.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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'; \
|
||||
} > /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
|
||||
COPY . /var/www/html
|
||||
|
||||
# Laufzeit-Verzeichnis des Updaters beschreibbar machen
|
||||
RUN mkdir -p /var/www/html/updater/storage \
|
||||
# Laufzeit-Verzeichnisse beschreibbar machen
|
||||
RUN mkdir -p /var/www/html/updater/storage /var/www/html/uploads \
|
||||
&& chown -R www-data:www-data /var/www/html
|
||||
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
|
|
|||
115
Readme.md
|
|
@ -8,7 +8,7 @@
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
|
@ -47,6 +47,11 @@
|
|||
- 🌍 **Öffentlicher Modus** – optional ohne Login nutzbar (mit CSRF-Schutz & Throttle)
|
||||
- 🎨 **Einheitliches Design-System** – ein Stylesheet für Frontend, Login und Backend (Tokens, Komponenten, Light/Dark)
|
||||
- 🏷️ **Login-Seite individualisierbar** – Firmenname, Logo, Texte, Hintergrundbild bzw. Farbverlauf
|
||||
- 🖌️ **Eigene Markenfarben** – Akzentfarbe, Verlauf und Eckenradius wirken auf die gesamte Oberfläche
|
||||
- ⬆️ **Bild-Upload** für Logo, Favicon und Login-Hintergrund (kein externes Hosting nötig)
|
||||
- 🔒 **Keine externen CDNs** – Schrift, Icons, Diagramme und Editor werden lokal ausgeliefert (DSGVO, Offline-Netze)
|
||||
- ♿ **Barrierearm** – Kontraste nach WCAG AA, Sprungmarke, aria-Beschriftungen, `prefers-reduced-motion`
|
||||
- 📱 **Mobil nutzbar** – Tabellen werden auf schmalen Geräten zu Karten
|
||||
- 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert
|
||||
- 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`)
|
||||
- 📱 **Responsive Admin-Layout** mit Hamburger-Menü & Sidebar-Overlay
|
||||
|
|
@ -92,6 +97,11 @@
|
|||
<img src="docs/screenshots/settings.png" alt="Einstellungen" width="48%">
|
||||
</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
|
||||
|
||||
<div align="center">
|
||||
|
|
@ -249,21 +259,44 @@ gemeinsames Stylesheet: **`assets/global.css`**.
|
|||
- **Dark Mode** ausschließlich über Tokens – keine `!important`-Overrides mehr
|
||||
- **Schriftart** Inter (via Google Fonts) mit System-Font-Fallback
|
||||
|
||||
Eigenes Branding lässt sich meist mit wenigen Zeilen umsetzen – z. B. in einer
|
||||
eigenen CSS-Datei oder direkt in `assets/global.css`:
|
||||
### Markenfarben ohne Code
|
||||
|
||||
Unter **Administration → Einstellungen → Design** lassen sich Akzentfarbe
|
||||
(hell und dunkel), Markenverlauf und Eckenradius setzen. Abgeleitete Töne –
|
||||
Hover, weiche Flächen, Rahmen, Fokusring – berechnet das System per `color-mix`
|
||||
aus der Grundfarbe; eine Farbe genügt also. Eine Live-Vorschau zeigt Button,
|
||||
Badge, Chip und Logo-Kachel sofort im neuen Ton.
|
||||
|
||||
Die Werte landen als schlanker `:root`-Override im Seitenkopf und gelten überall,
|
||||
auch auf Login-Seite, Installer und Updater. Wer lieber in CSS arbeitet, kann
|
||||
dieselben Variablen weiterhin in `assets/global.css` überschreiben:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--accent: #0f766e; /* Primärfarbe (Buttons, aktive Navigation) */
|
||||
--accent-hover: #0d5f59;
|
||||
--accent-soft: #e6f4f2; /* Flächen für aktive Zustände */
|
||||
--brand-gradient: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%);
|
||||
--r-lg: 14px; /* Eckenradius für Cards */
|
||||
}
|
||||
```
|
||||
|
||||
Logo und Favicon werden nicht über CSS, sondern unter
|
||||
**Administration → Einstellungen → Allgemein** gesetzt.
|
||||
### Bilder hochladen
|
||||
|
||||
Logo, Favicon, Login-Logo und Login-Hintergrund lassen sich direkt hochladen –
|
||||
alternativ bleibt das URL-Feld bestehen. Die Dateien landen unter `uploads/`
|
||||
(Docker: eigenes Volume, siehe unten). Erlaubt sind PNG, JPG, WEBP, GIF und SVG
|
||||
bis 3 MB; SVGs werden vor dem Speichern von Skripten und externen Verweisen
|
||||
befreit, und im Upload-Ordner sperrt eine `.htaccess` die PHP-Ausführung.
|
||||
|
||||
### Assets ohne Drittanbieter
|
||||
|
||||
Schrift (Inter), Icons (Font Awesome), Diagramme (Chart.js), QR-Codes und der
|
||||
WYSIWYG-Editor (TinyMCE) liegen unter `assets/vendor/` und kommen vom eigenen
|
||||
Server. Das hält Besucher-IPs bei Ihnen – und die Oberfläche funktioniert auch
|
||||
dort, wo das Netz keinen Weg nach außen hat. Details und Aktualisierungs-Hinweise:
|
||||
[`assets/vendor/README.md`](assets/vendor/README.md).
|
||||
|
||||
Alle Asset-URLs tragen einen Versionsstempel (`?v=…`), damit Browser nach einem
|
||||
Update nicht die alten Dateien aus dem Cache verwenden.
|
||||
|
||||
### Login-Seite individualisieren
|
||||
|
||||
|
|
@ -303,13 +336,30 @@ Das Tool ist auf einen sicheren Standardbetrieb ausgelegt:
|
|||
| **Sessions** | HttpOnly, SameSite, strict mode + absolutes Timeout |
|
||||
| **Fehler** | `display_errors` aus, `log_errors` an (kein Info-Leak) |
|
||||
|
||||
Empfohlene zusätzliche Härtung am Server:
|
||||
Mitgeliefert wird eine `.htaccess` im Projektstamm mit Sicherheits-Headern
|
||||
(`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
|
||||
`Permissions-Policy` und einer Content-Security-Policy). Da alle Assets lokal
|
||||
liegen, erlaubt die CSP nur noch die eigene Herkunft – externe Verbindungen
|
||||
bleiben lediglich für hCaptcha offen, falls es aktiviert wird. Ordner wie
|
||||
`includes/`, `tools/`, `tests/` und `uploads/` schützen sich über eigene
|
||||
`.htaccess`-Dateien.
|
||||
|
||||
```apache
|
||||
# .htaccess – sensible Dateien sperren (wird vom Installer erzeugt)
|
||||
<FilesMatch "^(config\.php|database\.sql|install\.php|test\.php|m365_debug\.php|.*\.md)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
> **Apache:** `AllowOverride All` muss für das Verzeichnis gesetzt sein, sonst
|
||||
> werden die `.htaccess`-Dateien ignoriert. Das mitgelieferte Docker-Image
|
||||
> erledigt das bereits.
|
||||
|
||||
Für **Nginx** entspricht das:
|
||||
|
||||
```nginx
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'" always;
|
||||
|
||||
location ~ ^/(includes|tools|tests)/ { deny all; }
|
||||
location ~ ^/updater/(storage|migrations)/ { deny all; }
|
||||
location ~ ^/(config\.php|database\.sql)$ { deny all; }
|
||||
location ^~ /uploads/ { location ~ \.php$ { deny all; } }
|
||||
```
|
||||
|
||||
```sql
|
||||
|
|
@ -432,6 +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
|
||||
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)
|
||||
|
|
@ -447,11 +531,14 @@ setzen (`DB_*`, `APP_KEY`).
|
|||
- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint
|
||||
- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan)
|
||||
- [x] Gemeinsames Design-System für Frontend, Login und Backend
|
||||
- [x] Branding über die Oberfläche (Farben, Logo, Login-Seite)
|
||||
- [x] Assets lokal ausliefern (keine Drittanbieter-CDNs)
|
||||
- [x] Vollständige englische Übersetzung des Admin-Bereichs
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Version 2.5.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
|
||||
**Version 2.6.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -243,6 +243,22 @@ $cs = [
|
|||
'email_user_notification_subject' => $db->getSetting('email_user_notification_subject', '{APP_TITLE} - Berechtigungen geändert'),
|
||||
'email_user_notification_body' => $db->getSetting('email_user_notification_body', "Hallo {USER_NAME},\n\n{CHANGES}"),
|
||||
'print_template' => $db->getSetting('print_template', Ui::defaultPrintTemplate()),
|
||||
'brand_accent' => $db->getSetting('brand_accent', '') ?: Ui::DEFAULT_ACCENT,
|
||||
'brand_accent_dark' => $db->getSetting('brand_accent_dark', '') ?: Ui::DEFAULT_ACCENT_DARK,
|
||||
'brand_gradient_from' => $db->getSetting('brand_gradient_from', '') ?: Ui::DEFAULT_GRADIENT_FROM,
|
||||
'brand_gradient_to' => $db->getSetting('brand_gradient_to', '') ?: Ui::DEFAULT_GRADIENT_TO,
|
||||
'brand_radius' => $db->getSetting('brand_radius', (string)Ui::DEFAULT_RADIUS),
|
||||
'login_panel_enabled' => $db->getSetting('login_panel_enabled', '1'),
|
||||
'login_brand_name' => $db->getSetting('login_brand_name', ''),
|
||||
'login_logo_url' => $db->getSetting('login_logo_url', ''),
|
||||
'login_claim_title' => $db->getSetting('login_claim_title', ''),
|
||||
'login_claim_text' => $db->getSetting('login_claim_text', ''),
|
||||
'login_features' => $db->getSetting('login_features', ''),
|
||||
'login_footer' => $db->getSetting('login_footer', ''),
|
||||
'login_bg_image' => $db->getSetting('login_bg_image', ''),
|
||||
'login_bg_from' => $db->getSetting('login_bg_from', '#3b2f8f'),
|
||||
'login_bg_to' => $db->getSetting('login_bg_to', '#6d5ce7'),
|
||||
'login_bg_overlay' => $db->getSetting('login_bg_overlay', '40'),
|
||||
'cron_token' => $db->getSetting('cron_token', ''),
|
||||
'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
|
||||
];
|
||||
|
|
@ -250,8 +266,9 @@ $cs = [
|
|||
/**
|
||||
* 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, '../');
|
||||
?>
|
||||
<div class="form-group">
|
||||
|
|
@ -278,6 +295,9 @@ function imageField(string $name, string $label, string $value, string $hint = '
|
|||
<?php
|
||||
}
|
||||
|
||||
// Aktiver Tab per ?tab=… (Deep-Link, funktioniert auch ohne JavaScript)
|
||||
$activeTab = preg_replace('/[^a-z_]/', '', (string)($_GET['tab'] ?? 'general')) ?: 'general';
|
||||
|
||||
$currentPage = 'settings';
|
||||
$adminBase = '';
|
||||
?>
|
||||
|
|
@ -310,20 +330,20 @@ $adminBase = '';
|
|||
|
||||
<div class="tab-container">
|
||||
<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" 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" 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 === 'general' ? ' active' : '' ?>" data-tab="general"><i class="fas fa-sliders-h" aria-hidden="true"></i> <?= __('settings_tab_general') ?></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<?= $activeTab === 'branding' ? ' active' : '' ?>" data-tab="branding"><i class="fas fa-palette" aria-hidden="true"></i> <?= __('settings_tab_branding') ?></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<?= $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="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" 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 === 'smtp' ? ' active' : '' ?>" data-tab="smtp"><i class="fas fa-envelope" aria-hidden="true"></i> <?= __('settings_tab_smtp') ?></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<?= $activeTab === 'system' ? ' active' : '' ?>" data-tab="system"><i class="fas fa-cogs" aria-hidden="true"></i> <?= __('settings_tab_system') ?></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>
|
||||
|
||||
<!-- 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>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
|
|
@ -342,7 +362,7 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_defaults_hint') ?></p>
|
||||
<form method="post">
|
||||
|
|
@ -378,7 +398,7 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_branding_intro') ?></p>
|
||||
<form method="post">
|
||||
|
|
@ -447,7 +467,7 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_login_intro') ?></p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
|
|
@ -525,7 +545,7 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div class="info-box">
|
||||
<h4><i class="fas fa-info-circle" aria-hidden="true"></i> <?= __('settings_cron_what') ?></h4>
|
||||
|
|
@ -589,7 +609,7 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
|
|
@ -620,7 +640,7 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
|
|
@ -641,7 +661,7 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||
|
|
@ -667,7 +687,7 @@ $adminBase = '';
|
|||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<form method="post" style="max-width:500px;">
|
||||
<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() {
|
||||
const hash = location.hash.substring(1);
|
||||
if (hash) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ services:
|
|||
# nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));"
|
||||
APP_KEY: ""
|
||||
TZ: Europe/Berlin
|
||||
volumes:
|
||||
# Hochgeladene Logos/Hintergruende ueberleben so ein Image-Update
|
||||
- uploads:/var/www/html/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
@ -36,3 +39,4 @@ services:
|
|||
|
||||
volumes:
|
||||
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%); }
|
||||
}
|
||||
.hint { font-size: 12.5px; color: #8a93a3; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bar::after { animation: none; width: 45%; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #e9ecf3; background: radial-gradient(760px 420px at 12% -8%, #1a1c30, transparent 62%), #0a0c11; }
|
||||
.card { background: #12151d; border-color: #232838; }
|
||||
|
|
|
|||