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>
This commit is contained in:
Friederich Loheide 2026-09-23 06:49:14 +00:00
parent e28527ed91
commit 36e06ac817
30 changed files with 307 additions and 36 deletions

26
.htaccess Normal file
View 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).

View file

@ -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
View file

@ -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.5.0-blueviolet)
![Version](https://img.shields.io/badge/Version-2.6.0-blueviolet)
![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg)
</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>

View file

@ -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) {

View file

@ -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:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

After

Width:  |  Height:  |  Size: 379 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

After

Width:  |  Height:  |  Size: 379 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

After

Width:  |  Height:  |  Size: 465 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 304 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 KiB

After

Width:  |  Height:  |  Size: 751 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

After

Width:  |  Height:  |  Size: 412 KiB

Before After
Before After

2
includes/.htaccess Normal file
View file

@ -0,0 +1,2 @@
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
Require all denied

2
tests/.htaccess Normal file
View file

@ -0,0 +1,2 @@
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
Require all denied

2
tools/.htaccess Normal file
View file

@ -0,0 +1,2 @@
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
Require all denied

42
tools/README.md Normal file
View 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
View 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())

View file

@ -0,0 +1,2 @@
# Diese Dateien werden nur serverseitig eingebunden und nie direkt ausgeliefert.
Require all denied

View file

@ -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; }