Branding: Farben systemweit einstellbar + Bild-Upload statt nur URLs

Design-Tab (Administration → Einstellungen → Design):
- Akzentfarbe für Hell- und Dark-Mode, Markenverlauf und Eckenradius
- abgeleitete Töne (Hover, weiche Flächen, Fokusring) werden per
  color-mix aus der Grundfarbe berechnet – eine Farbe genügt
- Live-Vorschau mit Button, Badge, Chip, Logo-Kachel und Link
- Ausgabe als schlanker :root-Override über Ui::brandingStyle(), greift
  auf allen Seiten inklusive Login und Installer

Uploads (includes/Upload.php):
- Logo, Favicon, Login-Logo und Login-Hintergrund lassen sich jetzt
  hochladen; das URL-Feld bleibt als Alternative bestehen
- Whitelist nach Endung, 3-MB-Grenze, getimagesize-Prüfung für Raster,
  SVGs werden von Skripten, Event-Handlern und externen Verweisen befreit
- Zufällige Dateinamen in uploads/, dort sperrt eine .htaccess die
  Ausführung von PHP; beim Ersetzen wird die alte Datei gelöscht

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-23 06:27:30 +00:00
parent 6da46f040a
commit 30d0ce3a23
13 changed files with 437 additions and 32 deletions

View file

@ -9,6 +9,7 @@ require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Mailer.php';
require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Ui.php';
require_once __DIR__ . '/../includes/Upload.php';
$auth = new Auth();
$auth->requireAdmin();
@ -41,6 +42,33 @@ if (isset($_POST['ajax_smtp_test'])) {
$error = '';
$success = '';
/**
* Liefert den neuen Wert eines Bildfeldes: Upload schlaegt URL, und ein
* gesetzter Entfernen-Schalter loescht die bisherige Datei.
*/
function resolveImageField(string $name, Database $db, string $kind): string
{
$current = (string)$db->getSetting($name, '');
$uploaded = Upload::store($_FILES[$name . '_file'] ?? [], $kind);
if ($uploaded !== '') {
Upload::delete($current);
return $uploaded;
}
if (!empty($_POST[$name . '_remove'])) {
Upload::delete($current);
return '';
}
$value = trim($_POST[$name] ?? '');
if ($value !== $current && Upload::isLocal($current) && !Upload::isLocal($value)) {
Upload::delete($current);
}
return $value;
}
// Einstellungen speichern
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
@ -52,22 +80,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if ($formType === 'general') {
$settings['app_title'] = trim($_POST['app_title'] ?? '');
$settings['logo_url'] = trim($_POST['logo_url'] ?? '');
$settings['favicon_url'] = trim($_POST['favicon_url'] ?? '');
$settings['logo_url'] = resolveImageField('logo_url', $db, 'image');
$settings['favicon_url'] = resolveImageField('favicon_url', $db, 'favicon');
$settings['instruction_header'] = trim($_POST['instruction_header'] ?? '');
$settings['instruction_text'] = $_POST['instruction_text'] ?? '';
$settings['public_access'] = isset($_POST['public_access']) ? '1' : '0';
}
if ($formType === 'branding') {
foreach (['brand_accent', 'brand_accent_dark', 'brand_gradient_from', 'brand_gradient_to'] as $key) {
$value = strtolower(trim($_POST[$key] ?? ''));
$settings[$key] = preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : '';
}
$radius = (int)($_POST['brand_radius'] ?? Ui::DEFAULT_RADIUS);
$settings['brand_radius'] = (string)max(0, min(28, $radius));
}
if ($formType === 'login') {
$settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0';
$settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? '');
$settings['login_logo_url'] = trim($_POST['login_logo_url'] ?? '');
$settings['login_logo_url'] = resolveImageField('login_logo_url', $db, 'image');
$settings['login_claim_title'] = trim($_POST['login_claim_title'] ?? '');
$settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? '');
$settings['login_features'] = trim($_POST['login_features'] ?? '');
$settings['login_footer'] = trim($_POST['login_footer'] ?? '');
$settings['login_bg_image'] = trim($_POST['login_bg_image'] ?? '');
$settings['login_bg_image'] = resolveImageField('login_bg_image', $db, 'image');
$settings['login_bg_from'] = trim($_POST['login_bg_from'] ?? '');
$settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? '');
$overlay = (int)($_POST['login_bg_overlay'] ?? 40);
@ -122,6 +159,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
}
$success = __('settings_saved');
} catch (RuntimeException $e) {
$error = $e->getMessage();
} catch (Exception $e) {
$error = 'Fehler: ' . $e->getMessage();
}
@ -204,6 +243,11 @@ $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', '<div style="text-align:center;padding:40px"><h1>{APP_TITLE}</h1><h2>WLAN Code</h2><div style="font-size:48px;font-weight:bold;margin:30px 0;font-family:monospace">{VOUCHER_CODE}</div><p><strong>Gültig bis:</strong> {EXPIRY_DATE} {EXPIRY_TIME}</p><p><strong>Site:</strong> {SITE_NAME}</p><p><strong>Geräte:</strong> {MAX_USES}</p><hr style="margin:30px 0"><div>{INSTRUCTIONS}</div></div>'),
'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', ''),
@ -219,6 +263,37 @@ $cs = [
'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
];
/**
* Bildfeld: Vorschau, Upload, alternativ URL plus Entfernen-Schalter.
*/
function imageField(string $name, string $label, string $value, string $hint = '', string $accept = 'image/*'): void
{
$preview = Ui::mediaUrl($value, '../');
?>
<div class="form-group">
<label><?= htmlspecialchars($label) ?></label>
<div class="image-field">
<div class="image-preview">
<?php if ($preview !== ''): ?>
<img src="<?= htmlspecialchars($preview) ?>" alt="">
<?php else: ?>
<i class="fas fa-image"></i>
<?php endif; ?>
</div>
<div class="image-field-controls">
<input type="file" name="<?= $name ?>_file" accept="<?= htmlspecialchars($accept) ?>">
<input type="text" name="<?= $name ?>" value="<?= htmlspecialchars($value) ?>"
placeholder="<?= htmlspecialchars(__('settings_image_url_placeholder')) ?>">
<?php if ($value !== ''): ?>
<label class="chk"><input type="checkbox" name="<?= $name ?>_remove" value="1"> <?= __('settings_image_remove') ?></label>
<?php endif; ?>
</div>
</div>
<?php if ($hint !== ''): ?><div class="help-text"><?= htmlspecialchars($hint) ?></div><?php endif; ?>
</div>
<?php
}
$currentPage = 'settings';
$adminBase = '';
?>
@ -253,6 +328,7 @@ $adminBase = '';
<div class="tab-navigation" id="tabNav">
<button class="tab-button active" data-tab="general"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></button>
<button class="tab-button" data-tab="defaults"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_defaults') ?></button>
<button class="tab-button" data-tab="branding"><i class="fas fa-palette"></i> <?= __('settings_tab_branding') ?></button>
<button class="tab-button" data-tab="login"><i class="fas fa-right-to-bracket"></i> <?= __('settings_tab_login') ?></button>
<button class="tab-button" data-tab="cron"><i class="fas fa-clock"></i> <?= __('settings_tab_cron') ?></button>
<button class="tab-button" data-tab="m365"><i class="fab fa-microsoft"></i> <?= __('settings_tab_m365') ?></button>
@ -265,13 +341,13 @@ $adminBase = '';
<!-- Allgemein -->
<div id="tab-general" class="tab-content active">
<h2 style="margin-bottom: 20px; color: var(--text-primary);"><i class="fas fa-sliders-h"></i> <?= __('settings_tab_general') ?></h2>
<form method="post">
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="general">
<div class="form-group"><label><?= __('settings_app_title') ?></label><input type="text" name="app_title" value="<?= htmlspecialchars($cs['app_title']) ?>" required></div>
<div class="form-grid">
<div class="form-group"><label><?= __('settings_logo_url') ?></label><input type="url" name="logo_url" value="<?= htmlspecialchars($cs['logo_url']) ?>" placeholder="https://example.com/logo.png"></div>
<div class="form-group"><label><?= __('settings_favicon_url') ?></label><input type="url" name="favicon_url" value="<?= htmlspecialchars($cs['favicon_url']) ?>" placeholder="https://example.com/favicon.ico"><div class="help-text"><?= __('settings_favicon_hint') ?></div></div>
<?php imageField('logo_url', __('settings_logo_url'), $cs['logo_url'], __('settings_upload_hint')); ?>
<?php imageField('favicon_url', __('settings_favicon_url'), $cs['favicon_url'], __('settings_favicon_hint'), 'image/x-icon,image/png,image/svg+xml'); ?>
</div>
<hr class="section-divider">
<div class="form-group"><label><?= __('settings_instr_header') ?></label><input type="text" name="instruction_header" value="<?= htmlspecialchars($cs['instruction_header']) ?>"></div>
@ -317,11 +393,80 @@ $adminBase = '';
</form>
</div>
<!-- Design & Branding -->
<div id="tab-branding" class="tab-content">
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-palette"></i> <?= __('settings_tab_branding') ?></h2>
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_branding_intro') ?></p>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="branding">
<div class="form-grid">
<div class="form-group">
<label><?= __('settings_brand_accent') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="brand_accent" value="<?= htmlspecialchars($cs['brand_accent']) ?>">
<input type="text" id="brand_accent" name="brand_accent" value="<?= htmlspecialchars($cs['brand_accent']) ?>" placeholder="<?= Ui::DEFAULT_ACCENT ?>">
</div>
<div class="help-text"><?= __('settings_brand_accent_hint') ?></div>
</div>
<div class="form-group">
<label><?= __('settings_brand_accent_dark') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="brand_accent_dark" value="<?= htmlspecialchars($cs['brand_accent_dark']) ?>">
<input type="text" id="brand_accent_dark" name="brand_accent_dark" value="<?= htmlspecialchars($cs['brand_accent_dark']) ?>" placeholder="<?= Ui::DEFAULT_ACCENT_DARK ?>">
</div>
<div class="help-text"><?= __('settings_brand_accent_dark_hint') ?></div>
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label><?= __('settings_brand_gradient_from') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="brand_gradient_from" value="<?= htmlspecialchars($cs['brand_gradient_from']) ?>">
<input type="text" id="brand_gradient_from" name="brand_gradient_from" value="<?= htmlspecialchars($cs['brand_gradient_from']) ?>">
</div>
</div>
<div class="form-group">
<label><?= __('settings_brand_gradient_to') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="brand_gradient_to" value="<?= htmlspecialchars($cs['brand_gradient_to']) ?>">
<input type="text" id="brand_gradient_to" name="brand_gradient_to" value="<?= htmlspecialchars($cs['brand_gradient_to']) ?>">
</div>
<div class="help-text"><?= __('settings_brand_gradient_hint') ?></div>
</div>
<div class="form-group">
<label><?= __('settings_brand_radius') ?></label>
<select id="brand_radius" name="brand_radius">
<?php foreach ([6 => __('settings_brand_radius_sharp'), 14 => __('settings_brand_radius_default'), 20 => __('settings_brand_radius_round')] as $value => $label): ?>
<option value="<?= $value ?>" <?= (int)$cs['brand_radius'] === $value ? 'selected' : '' ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="card" id="brandPreview" style="margin-top: 8px;">
<h3><?= __('settings_brand_preview') ?></h3>
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-top:14px;">
<span class="brand-mark" style="width:38px;height:38px;"><i class="fas fa-wifi"></i></span>
<button type="button" class="btn btn-primary"><i class="fas fa-ticket"></i> <?= __('voucher_create_btn') ?></button>
<button type="button" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
<span class="badge badge-success"><?= __('status_valid') ?></span>
<span class="chip"><?= __('nav_vouchers') ?></span>
<a href="#" onclick="return false;"><?= __('settings_brand_link') ?></a>
</div>
</div>
<button type="submit" name="save_settings" class="btn btn-primary" style="margin-top:16px;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
</form>
</div>
<!-- Login-Seite -->
<div id="tab-login" class="tab-content">
<h2 style="margin-bottom: 8px; color: var(--text-primary);"><i class="fas fa-right-to-bracket"></i> <?= __('settings_tab_login') ?></h2>
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;"><?= __('settings_login_intro') ?></p>
<form method="post">
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<input type="hidden" name="form_type" value="login">
@ -336,11 +481,7 @@ $adminBase = '';
<input type="text" name="login_brand_name" value="<?= htmlspecialchars($cs['login_brand_name']) ?>" placeholder="<?= htmlspecialchars($cs['app_title']) ?>">
<div class="help-text"><?= __('settings_login_brand_hint') ?></div>
</div>
<div class="form-group">
<label><?= __('settings_login_logo') ?></label>
<input type="url" name="login_logo_url" value="<?= htmlspecialchars($cs['login_logo_url']) ?>" placeholder="https://example.com/logo.svg">
<div class="help-text"><?= __('settings_login_logo_hint') ?></div>
</div>
<?php imageField('login_logo_url', __('settings_login_logo'), $cs['login_logo_url'], __('settings_login_logo_hint')); ?>
</div>
<hr class="section-divider">
@ -365,11 +506,7 @@ $adminBase = '';
<hr class="section-divider">
<div class="form-group">
<label><?= __('settings_login_bg_image') ?></label>
<input type="url" name="login_bg_image" value="<?= htmlspecialchars($cs['login_bg_image']) ?>" placeholder="https://example.com/empfang.jpg">
<div class="help-text"><?= __('settings_login_bg_image_hint') ?></div>
</div>
<?php imageField('login_bg_image', __('settings_login_bg_image'), $cs['login_bg_image'], __('settings_login_bg_image_hint')); ?>
<div class="form-grid">
<div class="form-group">
<label><?= __('settings_login_bg_from') ?></label>
@ -574,11 +711,37 @@ document.querySelectorAll('.tab-button').forEach(btn => {
});
});
// Branding-Vorschau live faerben
function updateBrandPreview() {
const preview = document.getElementById('brandPreview');
if (!preview) return;
const accent = (document.getElementById('brand_accent') || {}).value || '';
const from = (document.getElementById('brand_gradient_from') || {}).value || '';
const to = (document.getElementById('brand_gradient_to') || {}).value || '';
const radius = (document.getElementById('brand_radius') || {}).value || '14';
if (/^#[0-9a-fA-F]{6}$/.test(accent)) {
preview.style.setProperty('--accent', accent);
preview.style.setProperty('--accent-hover', `color-mix(in srgb, ${accent} 84%, #000)`);
preview.style.setProperty('--accent-soft', `color-mix(in srgb, ${accent} 12%, #fff)`);
preview.style.setProperty('--accent-border', `color-mix(in srgb, ${accent} 32%, #fff)`);
}
if (/^#[0-9a-fA-F]{6}$/.test(from) && /^#[0-9a-fA-F]{6}$/.test(to)) {
preview.style.setProperty('--brand-gradient', `linear-gradient(135deg, ${from} 0%, ${to} 100%)`);
}
preview.style.setProperty('--r-lg', radius + 'px');
}
['brand_accent', 'brand_gradient_from', 'brand_gradient_to', 'brand_radius'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', updateBrandPreview);
if (el) el.addEventListener('change', updateBrandPreview);
});
updateBrandPreview();
// Farbwähler und Hex-Feld synchron halten
document.querySelectorAll('.color-swatch').forEach(swatch => {
const field = document.getElementById(swatch.dataset.target);
if (!field) return;
swatch.addEventListener('input', () => { field.value = swatch.value; });
swatch.addEventListener('input', () => { field.value = swatch.value; updateBrandPreview(); });
field.addEventListener('input', () => {
if (/^#[0-9a-fA-F]{6}$/.test(field.value.trim())) swatch.value = field.value.trim();
});

View file

@ -562,7 +562,28 @@ input:focus, select:focus, textarea:focus, .input:focus {
}
input:disabled, select:disabled, textarea:disabled { background: var(--bg-hover); color: var(--text-muted); cursor: not-allowed; }
input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; }
input[type="file"] { font-size: 13px; color: var(--text-secondary); }
input[type="file"] {
width: 100%;
font-size: 13px;
color: var(--text-secondary);
}
input[type="file"]::file-selector-button {
margin-right: 10px;
padding: 7px 13px;
border: 1px solid var(--border-color);
border-radius: var(--r-sm);
background: var(--bg-card);
color: var(--text-primary);
font: inherit;
font-size: 12.5px;
font-weight: 550;
cursor: pointer;
transition: background-color .15s, border-color .15s;
}
input[type="file"]::file-selector-button:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
}
.chk, label.chk {
display: flex; align-items: center; gap: 10px;
@ -1060,6 +1081,22 @@ input[type="file"] { font-size: 13px; color: var(--text-secondary); }
border-radius: var(--r-xl);
box-shadow: var(--shadow-xl);
}
/* Bildfeld: Vorschau, Upload und URL nebeneinander */
.image-field { display: flex; gap: 14px; align-items: flex-start; }
.image-preview {
width: 84px; height: 64px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
padding: 6px;
background: var(--bg-subtle);
border: 1px solid var(--border-color);
border-radius: var(--r-md);
color: var(--text-muted);
overflow: hidden;
}
.image-preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-field-controls { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.image-field-controls .chk { margin: 0; font-size: 12.5px; color: var(--text-secondary); }
/* Farbwähler mit Hex-Eingabe */
.color-field { display: flex; align-items: center; gap: 8px; }
.color-field input[type="color"] {

View file

@ -89,7 +89,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<body class="app-body focus-page">
<div class="focus-card card">
<?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
<?php else: ?>
<h1><?= htmlspecialchars($appTitle) ?></h1>
<?php endif; ?>

View file

@ -116,6 +116,24 @@ class Ui
. '</style>';
}
/**
* URL eines Bildes aus den Einstellungen.
* Hochgeladene Dateien liegen relativ zur Projektwurzel (uploads/),
* externe Adressen bleiben unveraendert.
*/
public static function mediaUrl(string $value, string $base = ''): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if (preg_match('#^(https?:)?//#i', $value) || strncmp($value, 'data:', 5) === 0 || $value[0] === '/') {
return $value;
}
return $base . $value;
}
/**
* Kompletter Standard-Kopf: Favicon, Schrift, Icons, Design-System,
* Theme-Bootstrap und Branding.
@ -124,7 +142,7 @@ class Ui
{
$out = [];
$favicon = $db ? trim((string)$db->getSetting('favicon_url', '')) : '';
$favicon = $db ? self::mediaUrl((string)$db->getSetting('favicon_url', ''), $base) : '';
if ($favicon !== '') {
$out[] = '<link rel="icon" href="' . htmlspecialchars($favicon) . '">';
}

133
includes/Upload.php Normal file
View file

@ -0,0 +1,133 @@
<?php
/**
* Datei-Uploads fuer Branding-Bilder (Logo, Favicon, Hintergrund).
*
* Bewusst eng gefasst: nur Bilder, kleine Groesse, zufaelliger Dateiname,
* Ablage in uploads/ (dort ist die PHP-Ausfuehrung per .htaccess gesperrt).
* SVG-Dateien werden vor dem Speichern von aktiven Inhalten befreit.
*/
class Upload
{
public const MAX_BYTES = 3145728; // 3 MB
/** Erlaubte Endungen je Einsatzzweck. */
private const ALLOWED = [
'image' => ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'],
'favicon' => ['ico', 'png', 'svg'],
];
private static function dir(): string
{
return dirname(__DIR__) . '/uploads';
}
/** Legt das Upload-Verzeichnis inkl. Schutzdatei an. */
public static function ensureDir(): bool
{
$dir = self::dir();
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return false;
}
$htaccess = $dir . '/.htaccess';
if (!file_exists($htaccess)) {
@file_put_contents($htaccess, "php_flag engine off\nOptions -ExecCGI\n<FilesMatch \"\\.(php|phtml|phar)$\">\n Require all denied\n</FilesMatch>\n");
}
return is_writable($dir);
}
/** Ist der Pfad eine von uns gespeicherte Datei? */
public static function isLocal(string $path): bool
{
return $path !== '' && strncmp($path, 'uploads/', 8) === 0 && strpos($path, '..') === false;
}
/** Loescht eine zuvor hochgeladene Datei (externe URLs bleiben unberuehrt). */
public static function delete(string $path): void
{
if (!self::isLocal($path)) {
return;
}
$file = dirname(__DIR__) . '/' . $path;
if (is_file($file)) {
@unlink($file);
}
}
/**
* Nimmt einen Upload entgegen und gibt den relativen Pfad zurueck.
*
* @param array $file Eintrag aus $_FILES
* @param string $kind 'image' oder 'favicon'
* @throws RuntimeException bei ungueltigen Dateien
*/
public static function store(array $file, string $kind = 'image'): string
{
if (!isset($file['error']) || $file['error'] === UPLOAD_ERR_NO_FILE) {
return '';
}
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException(__('upload_error_generic'));
}
if (!is_uploaded_file($file['tmp_name'])) {
throw new RuntimeException(__('upload_error_generic'));
}
if ($file['size'] > self::MAX_BYTES) {
throw new RuntimeException(__('upload_error_size'));
}
$allowed = self::ALLOWED[$kind] ?? self::ALLOWED['image'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext === 'jpeg') {
$ext = 'jpg';
}
if (!in_array($ext, $allowed, true)) {
throw new RuntimeException(__('upload_error_type'));
}
$data = (string)file_get_contents($file['tmp_name']);
if ($ext === 'svg') {
$data = self::sanitizeSvg($data);
} elseif ($ext !== 'ico') {
// Raster: muss als Bild lesbar sein
if (@getimagesize($file['tmp_name']) === false) {
throw new RuntimeException(__('upload_error_type'));
}
}
if (!self::ensureDir()) {
throw new RuntimeException(__('upload_error_dir'));
}
$name = bin2hex(random_bytes(8)) . '.' . $ext;
$dest = self::dir() . '/' . $name;
if (file_put_contents($dest, $data) === false) {
throw new RuntimeException(__('upload_error_dir'));
}
@chmod($dest, 0644);
return 'uploads/' . $name;
}
/**
* Entfernt aktive Inhalte aus SVG-Dateien (Skripte, Event-Handler,
* externe Verweise). Lieber eine Grafik verlieren als eine XSS-Luecke.
*/
private static function sanitizeSvg(string $svg): string
{
if (stripos($svg, '<svg') === false) {
throw new RuntimeException(__('upload_error_type'));
}
$svg = preg_replace('#<\s*(script|foreignObject|iframe|embed|object|animate|set)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $svg);
$svg = preg_replace('#<\s*(script|foreignObject|iframe|embed|object|animate|set)\b[^>]*/?>#i', '', $svg);
$svg = preg_replace('#\son[a-z]+\s*=\s*"[^"]*"#i', '', $svg);
$svg = preg_replace("#\son[a-z]+\s*=\s*'[^']*'#i", '', $svg);
$svg = preg_replace('#(href|xlink:href)\s*=\s*([\'"])\s*(javascript|data):[^\'"]*\2#i', '', $svg);
$svg = preg_replace('#<!ENTITY[^>]*>#i', '', $svg);
return (string)$svg;
}
}

View file

@ -346,7 +346,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<div class="container">
<?php if ($logoUrl && !$voucherCreated && !$bulkCreated): ?>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
<?php endif; ?>
<?php if (!$voucherCreated && !$bulkCreated): ?>

View file

@ -220,6 +220,28 @@ return [
// Settings
'settings_title' => 'Einstellungen',
'settings_subtitle' => 'System-Konfiguration und Personalisierung',
'settings_tab_branding' => 'Design',
'settings_branding_intro' => 'Farben und Formen der gesamten Oberfläche Frontend wie Administration.',
'settings_brand_accent' => 'Akzentfarbe (hell)',
'settings_brand_accent_hint' => 'Buttons, aktive Navigation, Links. Abgeleitete Töne werden automatisch berechnet.',
'settings_brand_accent_dark' => 'Akzentfarbe (Dark Mode)',
'settings_brand_accent_dark_hint' => 'Im Dark Mode meist eine hellere Variante der Grundfarbe.',
'settings_brand_gradient_from'=> 'Markenverlauf: Start',
'settings_brand_gradient_to' => 'Markenverlauf: Ende',
'settings_brand_gradient_hint'=> 'Für Logo-Kachel, Avatare und die Voucher-Karte.',
'settings_brand_radius' => 'Eckenradius',
'settings_brand_radius_sharp' => 'Kantig',
'settings_brand_radius_default'=> 'Standard',
'settings_brand_radius_round' => 'Rund',
'settings_brand_preview' => 'Vorschau',
'settings_brand_link' => 'Beispiel-Link',
'settings_image_url_placeholder' => 'https://… oder Datei hochladen',
'settings_image_remove' => 'Bild entfernen',
'settings_upload_hint' => 'PNG, JPG, WEBP, GIF oder SVG maximal 3 MB.',
'upload_error_generic' => 'Die Datei konnte nicht hochgeladen werden.',
'upload_error_size' => 'Die Datei ist zu groß (maximal 3 MB).',
'upload_error_type' => 'Dieser Dateityp wird nicht unterstützt.',
'upload_error_dir' => 'Der Ordner uploads/ ist nicht beschreibbar.',
'settings_tab_login' => 'Login-Seite',
'settings_login_intro' => 'Aussehen und Texte der Anmeldeseite. Leere Felder verwenden die Standardwerte.',
'settings_login_panel' => 'Linke Bildspalte (Split-Screen) anzeigen',
@ -249,8 +271,8 @@ return [
'settings_tab_password' => 'Passwort',
'settings_saved' => 'Einstellungen erfolgreich gespeichert!',
'settings_app_title' => 'Anwendungs-Titel *',
'settings_logo_url' => 'Logo-URL',
'settings_favicon_url' => 'Favicon-URL',
'settings_logo_url' => 'Logo',
'settings_favicon_url' => 'Favicon',
'settings_favicon_hint' => 'Icon im Browser-Tab (.ico, .png, .svg)',
'settings_instr_header' => 'Anleitung - Überschrift',
'settings_instr_text' => 'Anleitung - Text',

View file

@ -220,6 +220,28 @@ return [
// Settings
'settings_title' => 'Settings',
'settings_subtitle' => 'System configuration and customization',
'settings_tab_branding' => 'Design',
'settings_branding_intro' => 'Colours and shapes for the whole interface front end and administration.',
'settings_brand_accent' => 'Accent colour (light)',
'settings_brand_accent_hint' => 'Buttons, active navigation, links. Derived shades are calculated automatically.',
'settings_brand_accent_dark' => 'Accent colour (dark mode)',
'settings_brand_accent_dark_hint' => 'Usually a lighter variant of the base colour for dark mode.',
'settings_brand_gradient_from'=> 'Brand gradient: start',
'settings_brand_gradient_to' => 'Brand gradient: end',
'settings_brand_gradient_hint'=> 'Used for the logo tile, avatars and the voucher card.',
'settings_brand_radius' => 'Corner radius',
'settings_brand_radius_sharp' => 'Sharp',
'settings_brand_radius_default'=> 'Default',
'settings_brand_radius_round' => 'Round',
'settings_brand_preview' => 'Preview',
'settings_brand_link' => 'Example link',
'settings_image_url_placeholder' => 'https://… or upload a file',
'settings_image_remove' => 'Remove image',
'settings_upload_hint' => 'PNG, JPG, WEBP, GIF or SVG 3 MB maximum.',
'upload_error_generic' => 'The file could not be uploaded.',
'upload_error_size' => 'The file is too large (3 MB maximum).',
'upload_error_type' => 'This file type is not supported.',
'upload_error_dir' => 'The uploads/ directory is not writable.',
'settings_tab_login' => 'Login page',
'settings_login_intro' => 'Appearance and wording of the sign-in page. Empty fields fall back to the defaults.',
'settings_login_panel' => 'Show left image column (split screen)',
@ -249,8 +271,8 @@ return [
'settings_tab_password' => 'Password',
'settings_saved' => 'Settings saved successfully!',
'settings_app_title' => 'Application Title *',
'settings_logo_url' => 'Logo URL',
'settings_favicon_url' => 'Favicon URL',
'settings_logo_url' => 'Logo',
'settings_favicon_url' => 'Favicon',
'settings_favicon_hint' => 'Browser tab icon (.ico, .png, .svg)',
'settings_instr_header' => 'Instructions - Headline',
'settings_instr_text' => 'Instructions - Text',

View file

@ -146,7 +146,7 @@ try {
. ';--login-to:' . htmlspecialchars($loginBgTo, ENT_QUOTES)
. ';--login-overlay:' . ($loginOverlay / 100);
if ($loginBgImage !== '') {
$visualStyle .= ";--login-image:url('" . htmlspecialchars($loginBgImage, ENT_QUOTES) . "')";
$visualStyle .= ";--login-image:url('" . htmlspecialchars(Ui::mediaUrl($loginBgImage), ENT_QUOTES) . "')";
}
} catch (Exception $e) {
@ -167,7 +167,7 @@ try {
<section class="auth-visual<?= $loginBgImage !== '' ? ' has-image' : '' ?>" style="<?= $visualStyle ?>">
<div class="auth-brand">
<?php if ($loginLogo): ?>
<img src="<?= htmlspecialchars($loginLogo) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="auth-brand-logo">
<img src="<?= htmlspecialchars(Ui::mediaUrl($loginLogo)) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="auth-brand-logo">
<?php else: ?>
<span class="brand-mark"><i class="fas fa-wifi"></i></span>
<span><?= htmlspecialchars($loginBrand) ?></span>
@ -204,7 +204,7 @@ try {
<div class="login-container">
<?php if (!$showPanel): ?>
<?php if ($loginLogo): ?>
<img src="<?= htmlspecialchars($loginLogo) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="logo">
<img src="<?= htmlspecialchars(Ui::mediaUrl($loginLogo)) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="logo">
<?php else: ?>
<div class="auth-mark">
<span class="brand-mark"><i class="fas fa-wifi"></i></span>

View file

@ -134,7 +134,7 @@ try {
<body class="app-body focus-page">
<div class="focus-card card login-container">
<?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
<?php else: ?>
<h1><?= htmlspecialchars($appTitle) ?></h1>
<?php endif; ?>

View file

@ -81,7 +81,7 @@ if ($valid && $_SERVER['REQUEST_METHOD'] === 'POST') {
<body class="app-body focus-page">
<div class="focus-card card">
<?php if ($logoUrl): ?>
<img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo" class="logo">
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
<?php else: ?>
<div class="focus-icon" style="margin-bottom:14px;"><i class="fas fa-key"></i></div>
<?php endif; ?>

4
uploads/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
# Hochgeladene Dateien gehoeren nicht ins Repository.
*
!.gitignore
!.htaccess

6
uploads/.htaccess Normal file
View file

@ -0,0 +1,6 @@
# Hochgeladene Dateien niemals als Programm ausfuehren.
php_flag engine off
Options -ExecCGI
<FilesMatch "\.(php|phtml|phar|cgi|pl|py)$">
Require all denied
</FilesMatch>