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:
parent
6da46f040a
commit
30d0ce3a23
13 changed files with 437 additions and 32 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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; ?>
|
||||
|
|
|
|||
|
|
@ -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
133
includes/Upload.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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): ?>
|
||||
|
|
|
|||
26
lang/de.php
26
lang/de.php
|
|
@ -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',
|
||||
|
|
|
|||
26
lang/en.php
26
lang/en.php
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; ?>
|
||||
|
|
|
|||
|
|
@ -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
4
uploads/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Hochgeladene Dateien gehoeren nicht ins Repository.
|
||||
*
|
||||
!.gitignore
|
||||
!.htaccess
|
||||
6
uploads/.htaccess
Normal file
6
uploads/.htaccess
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue