1
+
<?php
2
+
declare(strict_types=1);
3
+
4
+
/**
5
+
* index.php - Signia LebeLaut Uploadportal
6
+
* Zielversion: PHP 8.5 (kompatibel mit PHP 8.2+)
7
+
*
8
+
* Benötigte Serverfunktionen:
9
+
* - PHP-Erweiterung: fileinfo
10
+
* - XLSX-Unterstützung über ZipArchive oder den PHP-Kernbestandteil PharData
11
+
* - funktionsfähiger PHP-Mailversand (mail()/SMTP-Konfiguration des Servers)
12
+
*
13
+
* Dateien im gleichen Ordner:
14
+
* - max.jpg (Headerbild)
15
+
* - optional: lebelaut.png (Seitenhintergrund)
16
+
* - optional: CoU-Kundennummern.xlsx (bereits teilgenommene Kundennummern)
17
+
* - automatisch: kundendaten.xlsx (Formulardaten, ohne Logo/Portrait)
18
+
*
19
+
* Datenschutz:
20
+
* - Logo und Portrait werden ausschließlich aus dem temporären PHP-Upload
21
+
* als E-Mail-Anhänge versendet und nicht dauerhaft gespeichert.
22
+
*/
23
+
24
+
date_default_timezone_set('Europe/Copenhagen');
25
+
26
+
/* ----------------------------- Sicherheits-Basis ----------------------------- */
27
+
28
+
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
29
+
|| (isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443)
30
+
|| (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https')
31
+
|| (!empty($_SERVER['HTTP_X_FORWARDED_SSL']) && strtolower((string)$_SERVER['HTTP_X_FORWARDED_SSL']) === 'on');
32
+
33
+
session_set_cookie_params([
34
+
'httponly' => true,
35
+
'secure' => $isHttps,
36
+
'samesite' => 'Lax',
37
+
]);
38
+
ini_set('session.use_only_cookies', '1');
39
+
ini_set('session.use_strict_mode', '1');
40
+
session_name('signia_lebelaut_upload_session');
41
+
session_start();
42
+
43
+
@ini_set('upload_max_filesize', '60M');
44
+
@ini_set('post_max_size', '130M');
45
+
@ini_set('max_file_uploads', '4');
46
+
@ini_set('max_execution_time', '300');
47
+
@ini_set('memory_limit', '512M');
48
+
49
+
$cspNonce = base64_encode(random_bytes(18));
50
+
51
+
header('X-Content-Type-Options: nosniff');
52
+
header('X-Frame-Options: SAMEORIGIN');
53
+
header('Referrer-Policy: strict-origin-when-cross-origin');
54
+
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
55
+
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
56
+
header('Pragma: no-cache');
57
+
header(
58
+
"Content-Security-Policy: default-src 'self'; "
59
+
. "img-src 'self' data: blob:; "
60
+
. "style-src 'self' 'nonce-" . $cspNonce . "'; "
61
+
. "script-src 'self' 'nonce-" . $cspNonce . "'; "
62
+
. "connect-src 'self'; object-src 'none'; base-uri 'self'; "
63
+
. "form-action 'self'; frame-ancestors 'self'"
64
+
);
65
+
66
+
/* -------------------------------- Konfiguration ------------------------------- */
67
+
68
+
const MAX_FILE_SIZE_BYTES = 52_428_800; // 50 MB
69
+
const PORTRAIT_MIN_BYTES = 1_950_000; // akzeptiert ab ca. 1,95 MB
70
+
const CUSTOMER_NUMBER_PATTERN = '/^C300[A-Z0-9]{5}$/D';
71
+
const CUSTOMER_NUMBER_PREFIX = 'C300';
72
+
const MARKETING_EMAIL = 'marketing.de@signia-pro.com';
73
+
const CUSTOMER_DATA_FILE = 'kundendaten.xlsx';
74
+
const CUSTOMER_DATA_LOCK_FILE = '.kundendaten.lock';
75
+
76
+
$allowedLogoExtensions = ['ai', 'eps', 'png', 'tif', 'tiff', 'pdf', 'jpg', 'jpeg'];
77
+
$allowedPortraitExtensions = ['jpg', 'jpeg', 'tif', 'tiff', 'png'];
78
+
79
+
$allowedLogoMimes = [
80
+
'application/pdf',
81
+
'application/postscript',
82
+
'application/illustrator',
83
+
'application/octet-stream',
84
+
'text/plain',
85
+
'image/png',
86
+
'image/jpeg',
87
+
'image/tiff',
88
+
'image/x-tiff',
89
+
];
90
+
91
+
$allowedPortraitMimes = [
92
+
'image/jpeg',
93
+
'image/png',
94
+
'image/tiff',
95
+
'image/x-tiff',
96
+
'application/octet-stream',
97
+
];
98
+
99
+
/* --------------------------------- Hilfsfunktionen ---------------------------- */
100
+
101
+
function h(string $value): string
102
+
{
103
+
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
104
+
}
105
+
106
+
function utf8Length(string $value): int
107
+
{
108
+
return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
109
+
}
110
+
111
+
function utf8Substr(string $value, int $start, int $length): string
112
+
{
113
+
return function_exists('mb_substr')
114
+
? mb_substr($value, $start, $length, 'UTF-8')
115
+
: substr($value, $start, $length);
116
+
}
117
+
118
+
function csrfToken(): string
119
+
{
120
+
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
121
+
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
122
+
}
123
+
124
+
return $_SESSION['csrf_token'];
125
+
}
126
+
127
+
function verifyCsrfToken(?string $token): bool
128
+
{
129
+
return is_string($token)
130
+
&& isset($_SESSION['csrf_token'])
131
+
&& is_string($_SESSION['csrf_token'])
132
+
&& hash_equals($_SESSION['csrf_token'], $token);
133
+
}
134
+
135
+
function sanitizeText(string $value, int $maxLength = 180): string
136
+
{
137
+
$value = trim($value);
138
+
$value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value) ?? '';
139
+
$value = preg_replace('/\s+/u', ' ', $value) ?? '';
140
+
141
+
return utf8Length($value) > $maxLength
142
+
? utf8Substr($value, 0, $maxLength)
143
+
: $value;
144
+
}
145
+
146
+
function sanitizeTextarea(string $value, int $maxLength = 1000): string
147
+
{
148
+
$value = trim($value);
149
+
$value = str_replace(["\r\n", "\r"], "\n", $value);
150
+
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? '';
151
+
$value = preg_replace('/[ \t]+/u', ' ', $value) ?? '';
152
+
$value = preg_replace('/\n{3,}/u', "\n\n", $value) ?? '';
153
+
154
+
return utf8Length($value) > $maxLength
155
+
? utf8Substr($value, 0, $maxLength)
156
+
: $value;
157
+
}
158
+
159
+
function normalizeCustomerNumber(string $value): string
160
+
{
161
+
$value = strtoupper(trim($value));
162
+
$value = preg_replace('/\s+/', '', $value) ?? '';
163
+
164
+
return $value;
165
+
}
166
+
167
+
function isValidCustomerNumber(string $value): bool
168
+
{
169
+
return preg_match(CUSTOMER_NUMBER_PATTERN, $value) === 1;
170
+
}
171
+
172
+
function detectMimeType(string $tmpPath): string
173
+
{
174
+
if (!is_file($tmpPath) || !class_exists('finfo')) {
175
+
return 'application/octet-stream';
176
+
}
177
+
178
+
$finfo = new finfo(FILEINFO_MIME_TYPE);
179
+
$mime = $finfo->file($tmpPath);
180
+
181
+
return is_string($mime) && $mime !== '' ? $mime : 'application/octet-stream';
182
+
}
183
+
184
+
function fileExtension(string $fileName): string
185
+
{
186
+
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
187
+
188
+
return preg_replace('/[^a-z0-9]/', '', $extension) ?? '';
189
+
}
190
+
191
+
function anonymizeIp(string $ip): string
192
+
{
193
+
$ip = trim($ip);
194
+
195
+
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
196
+
return preg_replace('/\.\d+$/', '.0', $ip) ?? '';
197
+
}
198
+
199
+
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
200
+
$packed = @inet_pton($ip);
201
+
if ($packed !== false) {
202
+
$masked = substr($packed, 0, 8) . str_repeat("\0", 8);
203
+
$readable = @inet_ntop($masked);
204
+
205
+
return is_string($readable) ? $readable : 'ipv6';
206
+
}
207
+
208
+
return 'ipv6';
209
+
}
210
+
211
+
return '';
212
+
}
213
+
214
+
function uploadErrorMessage(int $errorCode): string
215
+
{
216
+
return match ($errorCode) {
217
+
UPLOAD_ERR_INI_SIZE,
218
+
UPLOAD_ERR_FORM_SIZE => 'Die Datei ist größer als erlaubt. Bitte laden Sie eine Datei mit maximal 50 MB hoch.',
219
+
UPLOAD_ERR_PARTIAL => 'Die Datei wurde nur teilweise hochgeladen. Bitte versuchen Sie es erneut.',
220
+
UPLOAD_ERR_NO_FILE => 'Bitte wählen Sie eine Datei aus oder ziehen Sie diese in das Upload-Feld.',
221
+
UPLOAD_ERR_NO_TMP_DIR => 'Serverfehler: Der temporäre Upload-Ordner fehlt.',
222
+
UPLOAD_ERR_CANT_WRITE => 'Serverfehler: Die Datei konnte temporär nicht verarbeitet werden.',
223
+
UPLOAD_ERR_EXTENSION => 'Der Upload wurde durch eine Server-Erweiterung gestoppt.',
224
+
default => 'Die Datei konnte nicht hochgeladen werden. Bitte versuchen Sie es erneut.',
225
+
};
226
+
}
227
+
228
+
function validateUploadedFile(
229
+
array $file,
230
+
string $label,
231
+
array $allowedExtensions,
232
+
array $allowedMimes,
233
+
bool $requiresPortraitMinimum = false
234
+
): array {
235
+
if (!isset($file['error'], $file['tmp_name'], $file['name'], $file['size'])) {
236
+
return ['ok' => false, 'message' => $label . ': Die Upload-Daten sind unvollständig.'];
237
+
}
238
+
239
+
if ((int)$file['error'] !== UPLOAD_ERR_OK) {
240
+
return ['ok' => false, 'message' => $label . ': ' . uploadErrorMessage((int)$file['error'])];
241
+
}
242
+
243
+
$tmpPath = (string)$file['tmp_name'];
244
+
$size = (int)$file['size'];
245
+
$originalName = sanitizeText((string)$file['name'], 220);
246
+
$extension = fileExtension($originalName);
247
+
$mime = detectMimeType($tmpPath);
248
+
249
+
if (!is_uploaded_file($tmpPath)) {
250
+
return ['ok' => false, 'message' => $label . ': Die Datei wurde nicht als gültiger Upload erkannt.'];
251
+
}
252
+
253
+
if ($size <= 0) {
254
+
return ['ok' => false, 'message' => $label . ': Die Datei scheint leer zu sein.'];
255
+
}
256
+
257
+
if ($size > MAX_FILE_SIZE_BYTES) {
258
+
return ['ok' => false, 'message' => $label . ': Die Datei ist größer als 50 MB.'];
259
+
}
260
+
261
+
if (!in_array($extension, $allowedExtensions, true)) {
262
+
return ['ok' => false, 'message' => $label . ': Dieses Dateiformat ist nicht vorgesehen.'];
263
+
}
264
+
265
+
if (!in_array($mime, $allowedMimes, true)) {
266
+
return ['ok' => false, 'message' => $label . ': Das Dateiformat konnte nicht sicher erkannt werden. Bitte laden Sie eine andere Originaldatei hoch.'];
267
+
}
268
+
269
+
$imageWidth = null;
270
+
$imageHeight = null;
271
+
$imageMime = '';
272
+
273
+
if (in_array($extension, ['jpg', 'jpeg', 'png'], true)) {
274
+
$imageInfo = @getimagesize($tmpPath);
275
+
276
+
if ($imageInfo === false || empty($imageInfo[0]) || empty($imageInfo[1])) {
277
+
return ['ok' => false, 'message' => $label . ': Die Datei konnte nicht als lesbares JPG- oder PNG-Bild geprüft werden.'];
278
+
}
279
+
280
+
$imageWidth = (int)$imageInfo[0];
281
+
$imageHeight = (int)$imageInfo[1];
282
+
$imageMime = isset($imageInfo['mime']) && is_string($imageInfo['mime']) ? $imageInfo['mime'] : '';
283
+
$expectedImageMimes = $extension === 'png' ? ['image/png'] : ['image/jpeg'];
284
+
285
+
if (!in_array($imageMime, $expectedImageMimes, true)) {
286
+
return ['ok' => false, 'message' => $label . ': Dateiendung und geprüftes Bildformat stimmen nicht überein.'];
287
+
}
288
+
}
289
+
290
+
if ($requiresPortraitMinimum && $size < PORTRAIT_MIN_BYTES) {
291
+
return [
292
+
'ok' => false,
293
+
'message' => 'Das Portrait ist zu klein oder zu stark komprimiert. Bitte laden Sie ein möglichst unbearbeitetes Originalbild ab ca. 2 MB hoch.',
294
+
];
295
+
}
296
+
297
+
return [
298
+
'ok' => true,
299
+
'message' => '',
300
+
'extension' => $extension,
301
+
'mime' => $mime,
302
+
'size' => $size,
303
+
'image_width' => $imageWidth,
304
+
'image_height' => $imageHeight,
305
+
'image_mime' => $imageMime,
306
+
'original_name' => $originalName,
307
+
'tmp_path' => $tmpPath,
308
+
];
309
+
}
310
+
311
+
function cleanupTemporaryUploads(array ...$validatedFiles): void
312
+
{
313
+
foreach ($validatedFiles as $validatedFile) {
314
+
$path = isset($validatedFile['tmp_path']) ? (string)$validatedFile['tmp_path'] : '';
315
+
if ($path !== '' && is_file($path) && is_uploaded_file($path)) {
316
+
@unlink($path);
317
+
}
318
+
}
319
+
}
320
+
321
+
function formatMegabytes(int $bytes): string
322
+
{
323
+
return number_format($bytes / 1_048_576, 2, ',', '.');
324
+
}
325
+
326
+
/* ------------------------- XLSX lesen: CoU-Kundennummern ---------------------- */
327
+
328
+
function findCouCustomerFile(): ?string
329
+
{
330
+
$files = glob(__DIR__ . DIRECTORY_SEPARATOR . 'CoU-Kundennummern.*') ?: [];
331
+
332
+
foreach ($files as $file) {
333
+
$base = pathinfo($file, PATHINFO_FILENAME);
334
+
$extension = strtolower((string)pathinfo($file, PATHINFO_EXTENSION));
335
+
if (strcasecmp($base, 'CoU-Kundennummern') === 0 && in_array($extension, ['xlsx', 'csv', 'xls'], true)) {
336
+
return $file;
337
+
}
338
+
}
339
+
340
+
return null;
341
+
}
342
+
343
+
function decodeXmlValue(string $value): string
344
+
{
345
+
return html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_XML1, 'UTF-8');
346
+
}
347
+
348
+
function extractXmlAttribute(string $attributes, string $name): string
349
+
{
350
+
$quotedName = preg_quote($name, '/');
…
… Diff-Ausgabe gekürzt …
351
+
customerNumber.addEventListener('input', scheduleCustomerCheck);
352
+
customerNumber.addEventListener('blur', checkExistingCustomer);
353
+
354
+
function readImageDimensions(file) {
355
+
return new Promise(function (resolve, reject) {
356
+
const objectUrl = URL.createObjectURL(file);
357
+
const img = new Image();
358
+
359
+
img.onload = function () {
360
+
const dimensions = { width: img.naturalWidth || img.width, height: img.naturalHeight || img.height };
361
+
URL.revokeObjectURL(objectUrl);
362
+
resolve(dimensions);
363
+
};
364
+
365
+
img.onerror = function () {
366
+
URL.revokeObjectURL(objectUrl);
367
+
reject(new Error('Bildmaße konnten nicht gelesen werden.'));
368
+
};
369
+
370
+
img.src = objectUrl;
371
+
});
372
+
}
373
+
374
+
function setQuality(qualityElement, result) {
375
+
qualityElement.className = 'quality-meter';
376
+
qualityElement.textContent = '';
377
+
if (!result) return;
378
+
379
+
qualityElement.classList.add('is-visible', 'is-' + result.level);
380
+
381
+
const head = document.createElement('div');
382
+
head.className = 'quality-head';
383
+
384
+
const title = document.createElement('div');
385
+
title.className = 'quality-title';
386
+
387
+
const dot = document.createElement('span');
388
+
dot.className = 'quality-dot';
389
+
dot.setAttribute('aria-hidden', 'true');
390
+
391
+
const titleText = document.createElement('span');
392
+
titleText.textContent = 'Datei-Check';
393
+
394
+
const badge = document.createElement('span');
395
+
badge.className = 'quality-badge';
396
+
badge.textContent = result.label;
397
+
398
+
title.appendChild(dot);
399
+
title.appendChild(titleText);
400
+
head.appendChild(title);
401
+
head.appendChild(badge);
402
+
403
+
const message = document.createElement('p');
404
+
message.className = 'quality-message';
405
+
message.textContent = result.message;
406
+
407
+
qualityElement.appendChild(head);
408
+
qualityElement.appendChild(message);
409
+
}
410
+
411
+
function unsupportedQuality(type) {
412
+
return {
413
+
level: 'red',
414
+
label: 'Nicht geeignet',
415
+
message: type === 'logo'
416
+
? 'Dieses Logoformat wird nicht akzeptiert. Bitte AI, EPS, PDF, PNG, TIFF oder JPG/JPEG verwenden.'
417
+
: 'Dieses Portraitformat wird nicht akzeptiert. Bitte JPG/JPEG, PNG oder TIFF verwenden.'
418
+
};
419
+
}
420
+
421
+
function evaluateLogoQuality(file, dimensions) {
422
+
const ext = extensionOf(file);
423
+
424
+
if (!ALLOWED_LOGO_EXTENSIONS.includes(ext)) return unsupportedQuality('logo');
425
+
if (file.size > MAX_FILE_SIZE) {
426
+
return { level: 'red', label: 'Nicht geeignet', message: 'Die Datei ist größer als 50 MB und wird nicht akzeptiert.' };
427
+
}
428
+
429
+
if (['ai', 'eps'].includes(ext)) {
430
+
if (file.size >= 100 * 1024) return { level: 'green', label: 'Sehr gut', message: 'Echte Vektordatei mit plausibler Dateigröße. Ideal für eine hochwertige Weiterverarbeitung.' };
431
+
if (file.size >= 25 * 1024) return { level: 'yellow', label: 'Bitte prüfen', message: 'Vektordatei vorhanden, aber ungewöhnlich klein. Wenn möglich, bitte die originale Druck- oder Agenturdatei hochladen.' };
432
+
return { level: 'red', label: 'Nicht geeignet', message: 'Die Vektordatei ist ungewöhnlich klein. Bitte prüfen, ob die vollständige Logo-Datei vorliegt.' };
433
+
}
434
+
435
+
if (ext === 'pdf') {
436
+
if (file.size >= 100 * 1024) return { level: 'green', label: 'Sehr gut', message: 'PDF mit plausibler Dateigröße. Besonders geeignet, wenn das Logo als Vektorgrafik enthalten ist.' };
437
+
if (file.size >= 25 * 1024) return { level: 'yellow', label: 'Bitte prüfen', message: 'Das PDF ist eher klein. Wenn möglich, bitte eine größere PDF- oder echte Vektordatei verwenden.' };
438
+
return { level: 'red', label: 'Nicht geeignet', message: 'Das PDF ist ungewöhnlich klein. Bitte die vollständige Logodatei prüfen.' };
439
+
}
440
+
441
+
if (['tif', 'tiff'].includes(ext)) {
442
+
if (file.size >= 1024 * 1024) return { level: 'green', label: 'Sehr gut', message: 'TIFF ab 1 MB. Für die Weiterverarbeitung sehr gut geeignet.' };
443
+
if (file.size >= 350 * 1024) return { level: 'yellow', label: 'Bitte prüfen', message: 'Das TIFF ist verwendbar, aber eher klein. Wenn möglich, bitte eine höher aufgelöste Datei verwenden.' };
444
+
return { level: 'red', label: 'Nicht geeignet', message: 'Das TIFF ist sehr klein. Bitte eine größere Datei oder eine Vektordatei verwenden.' };
445
+
}
446
+
447
+
if (['jpg', 'jpeg', 'png'].includes(ext) && dimensions) {
448
+
const minSide = Math.min(dimensions.width, dimensions.height);
449
+
const maxSide = Math.max(dimensions.width, dimensions.height);
450
+
const detail = formatPixels(dimensions.width, dimensions.height) + ' und ' + formatBytes(file.size);
451
+
452
+
if (maxSide >= 1600 && minSide >= 400 && file.size >= 300 * 1024) {
453
+
return { level: 'green', label: 'Sehr gut', message: 'Für eine Pixeldatei sehr gut geeignet: ' + detail + '.' };
454
+
}
455
+
if (maxSide >= 900 && minSide >= 250 && file.size >= 120 * 1024) {
456
+
return { level: 'yellow', label: 'Bitte prüfen', message: 'Verwendbar, aber knapp: ' + detail + '. Eine größere Datei oder Vektordatei wäre besser.' };
457
+
}
458
+
return { level: 'red', label: 'Nicht geeignet', message: 'Die Pixeldatei ist zu klein: ' + detail + '. Bitte eine größere Datei verwenden.' };
459
+
}
460
+
461
+
return { level: 'yellow', label: 'Bitte prüfen', message: 'Die Dateigröße beträgt ' + formatBytes(file.size) + '. Die Pixelmaße können für dieses Format im Browser nicht sicher geprüft werden.' };
462
+
}
463
+
464
+
function evaluatePortraitQuality(file, dimensions) {
465
+
const ext = extensionOf(file);
466
+
467
+
if (!ALLOWED_PORTRAIT_EXTENSIONS.includes(ext)) return unsupportedQuality('portrait');
468
+
if (file.size > MAX_FILE_SIZE) return { level: 'red', label: 'Nicht geeignet', message: 'Die Datei ist größer als 50 MB und wird nicht akzeptiert.' };
469
+
if (file.size < PORTRAIT_MIN_SIZE) return { level: 'red', label: 'Nicht geeignet', message: 'Das Portrait liegt unter ca. 1,95 MB. Bitte ein möglichst unbearbeitetes Originalbild ab ca. 2 MB hochladen.' };
470
+
471
+
if (['jpg', 'jpeg', 'png'].includes(ext) && dimensions) {
472
+
const minSide = Math.min(dimensions.width, dimensions.height);
473
+
const detail = formatPixels(dimensions.width, dimensions.height) + ' und ' + formatBytes(file.size);
474
+
475
+
if (dimensions.width >= 1600 && dimensions.height >= 1600) {
476
+
return { level: 'green', label: 'Sehr gut', message: 'Für eine hochwertige Darstellung sehr gut geeignet: ' + detail + '.' };
477
+
}
478
+
if (minSide >= 1200) {
479
+
return { level: 'yellow', label: 'Bitte prüfen', message: 'Verwendbar, aber bei größeren Anwendungen knapp: ' + detail + '.' };
480
+
}
481
+
return { level: 'red', label: 'Nicht geeignet', message: 'Trotz ausreichender Dateigröße sind die Bildmaße zu klein: ' + detail + '. Bitte ein größeres Originalbild verwenden.' };
482
+
}
483
+
484
+
if (['tif', 'tiff'].includes(ext)) {
485
+
if (file.size >= 5 * 1024 * 1024) return { level: 'green', label: 'Sehr gut', message: 'TIFF ab 5 MB. Für die professionelle Bearbeitung sehr gut geeignet.' };
486
+
return { level: 'yellow', label: 'Bitte prüfen', message: 'Das TIFF erfüllt die Mindestgröße, liegt aber unter 5 MB. Wenn möglich, bitte das größte verfügbare Original hochladen.' };
487
+
}
488
+
489
+
return { level: 'yellow', label: 'Bitte prüfen', message: 'Die Dateigröße beträgt ' + formatBytes(file.size) + '. Die Pixelmaße können für dieses Format nicht sicher geprüft werden.' };
490
+
}
491
+
492
+
function updateQuality(config, file) {
493
+
const ext = extensionOf(file);
494
+
const token = Date.now() + ':' + file.name + ':' + file.size;
495
+
config.qualityToken = token;
496
+
497
+
if (!['jpg', 'jpeg', 'png'].includes(ext)) {
498
+
setQuality(config.quality, config.type === 'logo' ? evaluateLogoQuality(file, null) : evaluatePortraitQuality(file, null));
499
+
return;
500
+
}
501
+
502
+
setQuality(config.quality, { level: 'yellow', label: 'Prüfung läuft', message: 'Die Bildmaße werden gerade geprüft.' });
503
+
readImageDimensions(file).then(function (dimensions) {
504
+
if (config.qualityToken !== token) return;
505
+
setQuality(config.quality, config.type === 'logo' ? evaluateLogoQuality(file, dimensions) : evaluatePortraitQuality(file, dimensions));
506
+
}).catch(function () {
507
+
if (config.qualityToken !== token) return;
508
+
setQuality(config.quality, { level: 'yellow', label: 'Bitte prüfen', message: 'Die Bildmaße konnten im Browser nicht sicher gelesen werden. Bitte möglichst eine hochauflösende Originaldatei verwenden.' });
509
+
});
510
+
}
511
+
512
+
function validateFile(file, type) {
513
+
const ext = extensionOf(file);
514
+
const allowed = type === 'logo' ? ALLOWED_LOGO_EXTENSIONS : ALLOWED_PORTRAIT_EXTENSIONS;
515
+
if (!allowed.includes(ext)) {
516
+
return type === 'logo'
517
+
? 'Bitte laden Sie ein Logo im Format AI, EPS, PNG, TIFF, PDF oder JPG/JPEG hoch.'
518
+
: 'Bitte laden Sie ein Portrait im Format JPG/JPEG, PNG oder TIFF hoch.';
519
+
}
520
+
if (file.size > MAX_FILE_SIZE) return 'Diese Datei ist größer als 50 MB.';
521
+
if (type === 'portrait' && file.size < PORTRAIT_MIN_SIZE) return 'Das Portrait muss mindestens ca. 1,95 MB groß sein.';
522
+
return '';
523
+
}
524
+
525
+
function clearPreview(preview) {
526
+
preview.innerHTML = '';
527
+
preview.classList.remove('is-visible');
528
+
}
529
+
530
+
function setTipsVisibility(tips, tools, hasFile) {
531
+
tips.classList.toggle('is-hidden', hasFile);
532
+
tools.classList.toggle('is-visible', hasFile);
533
+
}
534
+
535
+
function renderPreview(config) {
536
+
clearPreview(config.preview);
537
+
showInlineError(config.error, '');
538
+
539
+
const file = config.input.files && config.input.files[0] ? config.input.files[0] : null;
540
+
if (!file) {
541
+
config.status.textContent = 'Noch keine Datei ausgewählt.';
542
+
config.qualityToken = '';
543
+
setQuality(config.quality, null);
544
+
setTipsVisibility(config.tips, config.tools, false);
545
+
updateSubmitState();
546
+
return;
547
+
}
548
+
549
+
config.status.textContent = file.name + ' (' + formatBytes(file.size) + ')';
550
+
setTipsVisibility(config.tips, config.tools, true);
551
+
552
+
const validationMessage = validateFile(file, config.type);
553
+
showInlineError(config.error, validationMessage);
554
+
updateQuality(config, file);
555
+
556
+
const ext = extensionOf(file);
557
+
const canPreview = file.type.startsWith('image/') && ['jpg', 'jpeg', 'png'].includes(ext);
558
+
config.preview.classList.add('is-visible');
559
+
560
+
if (canPreview) {
561
+
const img = document.createElement('img');
562
+
img.alt = 'Vorschau ' + (config.type === 'logo' ? 'Logo' : 'Portrait');
563
+
img.src = URL.createObjectURL(file);
564
+
img.onload = function () { URL.revokeObjectURL(img.src); };
565
+
config.preview.appendChild(img);
566
+
} else {
567
+
const placeholder = document.createElement('div');
568
+
placeholder.className = 'preview-placeholder';
569
+
placeholder.innerHTML = '<strong>Datei ausgewählt.</strong><br>Für dieses Format ist im Browser keine sichere Vorschau verfügbar.';
570
+
config.preview.appendChild(placeholder);
571
+
}
572
+
573
+
updateSubmitState();
574
+
}
575
+
576
+
function removeFile(config) {
577
+
config.input.value = '';
578
+
renderPreview(config);
579
+
config.input.focus();
580
+
}
581
+
582
+
function updateSubmitState() {
583
+
if (!submitButton) return;
584
+
submitButton.disabled = isSubmitting || couMatched;
585
+
if (isSubmitting) submitButton.setAttribute('aria-busy', 'true');
586
+
else submitButton.removeAttribute('aria-busy');
587
+
}
588
+
589
+
function validateFileInputsForSubmit() {
590
+
let firstProblem = null;
591
+
[logoConfig, portraitConfig].forEach(function (config) {
592
+
const file = config.input.files && config.input.files[0] ? config.input.files[0] : null;
593
+
const message = file ? validateFile(file, config.type) : '';
594
+
showInlineError(config.error, message);
595
+
if (message && !firstProblem) firstProblem = config.error;
596
+
});
597
+
return firstProblem;
598
+
}
599
+
600
+
function setSubmitInProgress() {
601
+
isSubmitting = true;
602
+
submitButton.textContent = 'Übermittlung läuft...';
603
+
updateSubmitState();
604
+
}
605
+
606
+
function resetSubmitButton() {
607
+
isSubmitting = false;
608
+
submitButton.textContent = originalSubmitText;
609
+
updateSubmitState();
610
+
}
611
+
612
+
function setDroppedFiles(input, files) {
613
+
if (!files || !files.length) return;
614
+
try {
615
+
const transfer = new DataTransfer();
616
+
transfer.items.add(files[0]);
617
+
input.files = transfer.files;
618
+
} catch (error) {
619
+
input.files = files;
620
+
}
621
+
input.dispatchEvent(new Event('change', { bubbles: true }));
622
+
}
623
+
624
+
function initDragAndDrop(box) {
625
+
const input = box.querySelector('input[type="file"]');
626
+
if (!input) return;
627
+
628
+
['dragenter', 'dragover'].forEach(function (eventName) {
629
+
box.addEventListener(eventName, function (event) {
630
+
event.preventDefault();
631
+
box.classList.add('is-dragover');
632
+
});
633
+
});
634
+
635
+
['dragleave', 'drop'].forEach(function (eventName) {
636
+
box.addEventListener(eventName, function (event) {
637
+
event.preventDefault();
638
+
box.classList.remove('is-dragover');
639
+
});
640
+
});
641
+
642
+
box.addEventListener('drop', function (event) {
643
+
const files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null;
644
+
setDroppedFiles(input, files);
645
+
});
646
+
}
647
+
648
+
const logoConfig = { input: logoInput, status: logoStatus, preview: logoPreview, error: logoError, quality: logoQuality, tips: logoTips, tools: logoTools, type: 'logo' };
649
+
const portraitConfig = { input: portraitInput, status: portraitStatus, preview: portraitPreview, error: portraitError, quality: portraitQuality, tips: portraitTips, tools: portraitTools, type: 'portrait' };
650
+
651
+
logoInput.addEventListener('change', function () { renderPreview(logoConfig); });
652
+
portraitInput.addEventListener('change', function () { renderPreview(portraitConfig); });
653
+
logoRemove.addEventListener('click', function () { removeFile(logoConfig); });
654
+
portraitRemove.addEventListener('click', function () { removeFile(portraitConfig); });
655
+
656
+
[companyName, contactEmail].forEach(function (input) {
657
+
input.addEventListener('input', updateSubmitState);
658
+
input.addEventListener('blur', updateSubmitState);
659
+
});
660
+
consentUsage.addEventListener('change', updateSubmitState);
661
+
document.querySelectorAll('[data-upload-box]').forEach(initDragAndDrop);
662
+
663
+
form.addEventListener('submit', function (event) {
664
+
if (isSubmitting || couMatched) {
665
+
event.preventDefault();
666
+
return;
667
+
}
668
+
669
+
normalizeCustomerField();
670
+
const customerIsValid = CUSTOMER_PATTERN.test(customerNumber.value.trim().toUpperCase());
671
+
showInlineError(customerNumberError, customerIsValid ? '' : 'Die Kundennummer muss mit C300 beginnen und insgesamt genau 9 Stellen enthalten.');
672
+
673
+
if (!customerIsValid || !form.checkValidity()) {
674
+
event.preventDefault();
675
+
form.reportValidity();
676
+
return;
677
+
}
678
+
679
+
const firstFileProblem = validateFileInputsForSubmit();
680
+
if (firstFileProblem) {
681
+
event.preventDefault();
682
+
firstFileProblem.scrollIntoView({ behavior: 'smooth', block: 'center' });
683
+
return;
684
+
}
685
+
686
+
setSubmitInProgress();
687
+
});
688
+
689
+
window.addEventListener('pageshow', function () {
690
+
if (isSubmitting) resetSubmitButton();
691
+
});
692
+
693
+
normalizeCustomerField();
694
+
updateSubmitState();
695
+
if (CUSTOMER_PATTERN.test(customerNumber.value.trim().toUpperCase())) checkExistingCustomer();
696
+
})();
697
+
</script>
698
+
</body>
699
+
</html>
700
+
Links steht die alte, rechts die neue Zeilennummer. Rot wurde entfernt, grün wurde hinzugefügt.