From 6684062400f7d2288992785ef56ec3c6bd5d3d72 Mon Sep 17 00:00:00 2001 From: RaineSchroeder Date: Sat, 19 Sep 2026 23:27:40 +0000 Subject: [PATCH] Initial CAT application and deployment update --- .htaccess | 9 + README.md | 21 + admin.php | 948 ++++++++++++++++++++++++++++++++++++++ booking.php | 138 ++++++ cat_update_2026-09-19.sql | 12 + cat_update_2026-09-19.zip | Bin 0 -> 33623 bytes css/admin.css | 119 +++++ css/booking.css | 271 +++++++++++ css/index.css | 107 +++++ css/index.html | 2 + edit_registration.php | 731 +++++++++++++++++++++++++++++ export.php | 167 +++++++ inc/.htaccess | 1 + inc/index.html | 2 + inc/room_planner.php | 138 ++++++ index.php | 36 ++ process_registration.php | 662 ++++++++++++++++++++++++++ roommate_suggestions.php | 65 +++ scripts/booking.js | 103 +++++ scripts/functions.js | 126 +++++ scripts/index.html | 2 + scripts/security.js | 30 ++ 22 files changed, 3690 insertions(+) create mode 100644 .htaccess create mode 100644 README.md create mode 100644 admin.php create mode 100644 booking.php create mode 100644 cat_update_2026-09-19.sql create mode 100644 cat_update_2026-09-19.zip create mode 100644 css/admin.css create mode 100644 css/booking.css create mode 100644 css/index.css create mode 100644 css/index.html create mode 100644 edit_registration.php create mode 100644 export.php create mode 100644 inc/.htaccess create mode 100644 inc/index.html create mode 100644 inc/room_planner.php create mode 100644 index.php create mode 100644 process_registration.php create mode 100644 roommate_suggestions.php create mode 100644 scripts/booking.js create mode 100644 scripts/functions.js create mode 100644 scripts/index.html create mode 100644 scripts/security.js diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..1fcd2b1 --- /dev/null +++ b/.htaccess @@ -0,0 +1,9 @@ +Options -Indexes + + + Require all denied + + + + Require all denied + diff --git a/README.md b/README.md new file mode 100644 index 0000000..33db73f --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# CAT + +CAT – Chorwochenende-Planungs- und Buchungsanwendung. + +## Inhalt + +- PHP-Anwendung aus `/var/www/html/cat` +- `cat_update_2026-09-19.sql`: idempotentes Datenbank-Update für die Zimmerplanung +- `cat_update_2026-09-19.zip`: separates Update-Paket der geänderten Webdateien + +## Konfiguration + +Die produktiven Dateien `inc/db.inc.php` und `inc/mail.inc.php` sind aus Sicherheitsgründen nicht versioniert. Sie müssen auf dem Zielsystem separat konfiguriert werden. + +## Datenbank-Update + +```text +mariadb cat < cat_update_2026-09-19.sql +``` + +Das Update legt die drei Zimmeranzahl-Einstellungen an, ohne vorhandene Daten zu überschreiben. diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..d11469b --- /dev/null +++ b/admin.php @@ -0,0 +1,948 @@ + true, + 'samesite' => 'Strict', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'use_strict_mode' => true, +]); +session_start(); +header('Cache-Control: no-store, no-cache, must-revalidate'); +header('Pragma: no-cache'); +header('X-Content-Type-Options: nosniff'); +header('X-Frame-Options: DENY'); +header('Referrer-Policy: no-referrer'); +header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); +header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"); + +require_once __DIR__ . '/inc/db.inc.php'; +require_once __DIR__ . '/inc/mail.inc.php'; +require_once __DIR__ . '/inc/room_planner.php'; + +function postString(string $key): string +{ + $value = $_POST[$key] ?? ''; + + return is_string($value) ? trim($value) : ''; +} + +function escape(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function accommodationLabel(?string $value): string +{ + return match ($value) { + 'single' => 'Einzelzimmer', + 'double' => 'Doppelzimmer', + 'multi' => 'Mehrbettzimmer', + null, '' => '–', + default => $value, + }; +} + +function dayLabel(mixed $value): string +{ + return match ((string) $value) { + '1' => 'Fr.', + '2' => 'Sa.', + '3' => 'So.', + default => (string) $value, + }; +} + +function mealLabel(mixed $value): string +{ + return match ((string) $value) { + 'breakfast' => 'Frühstück', + 'lunch' => 'Mittagessen', + 'coffee' => 'Kaffee', + 'drinks' => 'Getränkepauschale', + 'dinner' => 'Abendessen', + default => (string) $value, + }; +} + +function earlyDepartureLabel(mixed $value): string +{ + return match ((string) $value) { + 'sat_breakfast' => 'Samstag nach dem Frühstück', + 'sat_lunch' => 'Samstag nach dem Mittagessen', + 'sat_coffee' => 'Samstag nach dem Kaffee', + 'sat_dinner' => 'Samstag nach dem Abendessen', + 'sun_breakfast' => 'Sonntag nach dem Frühstück', + default => (string) $value, + }; +} + +function mealSummary(array $days, array $mealsByDay): string +{ + $parts = []; + foreach ($days as $day) { + $meals = array_map('mealLabel', $mealsByDay[(string) $day] ?? []); + $meals[] = 'Getränkepauschale'; + $meals[] = 'Proberaumpauschale'; + $parts[] = dayLabel($day) . ': ' . implode(', ', $meals); + } + + return implode('; ', $parts); +} + +function calculateBookingTotal(PDO $pdo, bool $under27, string $guestType, ?string $roomType, array $mealsByDay): float +{ + $ageSuffix = $under27 ? 'under_27' : 'over_27'; + $keys = []; + + if ($guestType === 'regular_guest' && $roomType !== null) { + $keys[] = $roomType . '_' . $ageSuffix; + } + if ($guestType === 'day_guest') { + foreach ($mealsByDay as $meals) { + foreach (array_unique(array_merge($meals, ['drinks'])) as $meal) { + if (in_array($meal, ['breakfast', 'lunch', 'coffee', 'dinner'], true)) { + $keys[] = $meal . '_' . $ageSuffix; + } elseif ($meal === 'drinks') { + $keys[] = 'drinks'; + } + } + $keys[] = 'rehearsal_room'; + } + } + if ($keys === []) { + return 0.0; + } + + $uniqueKeys = array_values(array_unique($keys)); + $placeholders = implode(',', array_fill(0, count($uniqueKeys), '?')); + $statement = $pdo->prepare("SELECT setting_key, price FROM pricing_settings WHERE setting_key IN ($placeholders)"); + $statement->execute($uniqueKeys); + $prices = []; + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + $prices[(string) $row['setting_key']] = (float) $row['price']; + } + + return round(array_sum(array_map( + static fn (string $key): float => $prices[$key] ?? 0.0, + $keys + )), 2); +} + +function fetchUnpaidRecipients(PDO $pdo): array +{ + return $pdo->query( + "SELECT p.name, p.email, COALESCE(b.manual_total_price, b.total_price) AS effective_price + FROM bookings b + INNER JOIN participants p ON p.id = b.participant_id + WHERE b.is_paid = 0 AND p.email IS NOT NULL AND p.email <> '' + ORDER BY p.name" + )->fetchAll(PDO::FETCH_ASSOC); +} + +function personalizeBulkEmail(string $template, array $recipient): string +{ + return str_replace( + ['{{preis}}', '{{name}}'], + [ + number_format((float) $recipient['effective_price'], 2, ',', '.') . ' €', + (string) $recipient['name'], + ], + $template + ); +} + +function redirectToAdmin(): never +{ + header('Location: admin.php'); + exit; +} + +function csrfToken(): string +{ + if (!isset($_SESSION['admin_csrf'])) { + $_SESSION['admin_csrf'] = bin2hex(random_bytes(32)); + } + + return $_SESSION['admin_csrf']; +} + +function requireCsrf(): void +{ + $submitted = postString('csrf_token'); + $stored = $_SESSION['admin_csrf'] ?? ''; + + if ($stored === '' || !hash_equals($stored, $submitted)) { + http_response_code(403); + exit('Ungültige Anfrage.'); + } +} + +function createPdo(array $dbConfig): PDO +{ + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $dbConfig['host'], + $dbConfig['port'], + $dbConfig['database'], + $dbConfig['charset'] + ); + + return new PDO($dsn, $dbConfig['username'], $dbConfig['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => false, + ]); +} + +$pdo = createPdo($dbConfig); + +if (isset($_SESSION['admin_id'])) { + $lastActivity = (int) ($_SESSION['admin_last_activity'] ?? 0); + if ($lastActivity > 0 && $lastActivity < time() - 1800) { + $_SESSION = []; + session_regenerate_id(true); + } else { + $_SESSION['admin_last_activity'] = time(); + } +} + +if (isset($_SESSION['admin_id'])) { + $activeStatement = $pdo->prepare('SELECT id FROM admin_users WHERE id = :id AND is_active = 1 LIMIT 1'); + $activeStatement->execute([':id' => (int) $_SESSION['admin_id']]); + if ($activeStatement->fetchColumn() === false) { + $_SESSION = []; + session_regenerate_id(true); + } +} + +$message = $_SESSION['admin_message'] ?? ''; +unset($_SESSION['admin_message']); + +if (postString('action') === 'login') { + $username = postString('username'); + $password = postString('password'); + $lockedUntil = (int) ($_SESSION['admin_locked_until'] ?? 0); + + if ($lockedUntil > time()) { + $message = 'Zu viele Fehlversuche. Bitte später erneut versuchen.'; + } else { + $statement = $pdo->prepare( + 'SELECT id, username, password_hash + FROM admin_users + WHERE username = :username AND is_active = 1 + LIMIT 1' + ); + $statement->execute([':username' => $username]); + $admin = $statement->fetch(PDO::FETCH_ASSOC); + + if ($admin !== false && password_verify($password, $admin['password_hash'])) { + if (password_needs_rehash($admin['password_hash'], PASSWORD_DEFAULT)) { + $rehash = $pdo->prepare('UPDATE admin_users SET password_hash = :password_hash WHERE id = :id'); + $rehash->execute([ + ':password_hash' => password_hash($password, PASSWORD_DEFAULT), + ':id' => (int) $admin['id'], + ]); + } + unset($_SESSION['admin_login_failures'], $_SESSION['admin_locked_until']); + session_regenerate_id(true); + $_SESSION['admin_id'] = (int) $admin['id']; + $_SESSION['admin_username'] = $admin['username']; + $_SESSION['admin_last_activity'] = time(); + $_SESSION['admin_csrf'] = bin2hex(random_bytes(32)); + redirectToAdmin(); + } + + $failures = (int) ($_SESSION['admin_login_failures'] ?? 0) + 1; + $_SESSION['admin_login_failures'] = $failures; + if ($failures >= 5) { + $_SESSION['admin_locked_until'] = time() + 300; + } + usleep(500000); + $message = 'Benutzername oder Passwort ist ungültig.'; + } +} + +if (!isset($_SESSION['admin_id'])) { + ?> + + + + + Admin-Login + + + +
+

Chorwochenende

+

Admin-Login

+ +

+ +
+ + + + + + +
+
+ + + time() - 42000, + 'path' => '/', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'httponly' => true, + 'samesite' => 'Strict', + ]); + session_destroy(); + redirectToAdmin(); + } + + if ($action === 'delete_booking') { + $bookingId = filter_var($_POST['booking_id'] ?? null, FILTER_VALIDATE_INT); + if ($bookingId === false || $bookingId === null) { + throw new InvalidArgumentException('Ungültige Registrierung.'); + } + + $pdo->beginTransaction(); + $participantStatement = $pdo->prepare( + 'SELECT participant_id FROM bookings WHERE id = :id FOR UPDATE' + ); + $participantStatement->execute([':id' => $bookingId]); + $participantId = $participantStatement->fetchColumn(); + + if ($participantId === false) { + throw new InvalidArgumentException('Registrierung nicht gefunden.'); + } + + $deleteBookings = $pdo->prepare('DELETE FROM bookings WHERE participant_id = :participant_id'); + $deleteBookings->execute([':participant_id' => $participantId]); + $deleteParticipant = $pdo->prepare('DELETE FROM participants WHERE id = :id'); + $deleteParticipant->execute([':id' => $participantId]); + $pdo->commit(); + $_SESSION['admin_message'] = 'Registrierung wurde gelöscht.'; + } + + if ($action === 'toggle_paid') { + $bookingId = filter_var($_POST['booking_id'] ?? null, FILTER_VALIDATE_INT); + $paid = filter_var($_POST['is_paid'] ?? null, FILTER_VALIDATE_INT); + if ($bookingId === false || $bookingId === null || !in_array($paid, [0, 1], true)) { + throw new InvalidArgumentException('Ungültiger Zahlungsstatus.'); + } + + $statement = $pdo->prepare('UPDATE bookings SET is_paid = :is_paid WHERE id = :id'); + $statement->execute([':is_paid' => $paid, ':id' => $bookingId]); + $_SESSION['admin_message'] = $paid === 1 + ? 'Registrierung als bezahlt markiert.' + : 'Zahlungsmarkierung entfernt.'; + } + + if ($action === 'create_user') { + $username = postString('new_username'); + $email = postString('new_email'); + $password = postString('new_password'); + + if (!preg_match('/^[A-Za-z0-9_.-]{3,80}$/', $username)) { + throw new InvalidArgumentException('Der Benutzername enthält ungültige Zeichen.'); + } + if (strlen($password) < 12) { + throw new InvalidArgumentException('Passwörter müssen mindestens 12 Zeichen lang sein.'); + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 255) { + throw new InvalidArgumentException('Bitte eine gültige Admin-E-Mail-Adresse eingeben.'); + } + + $statement = $pdo->prepare( + 'INSERT INTO admin_users (username, email, password_hash) VALUES (:username, :email, :password_hash)' + ); + $statement->execute([ + ':username' => $username, + ':email' => $email, + ':password_hash' => password_hash($password, PASSWORD_DEFAULT), + ]); + $_SESSION['admin_message'] = 'Admin-Benutzer wurde angelegt.'; + } + + if ($action === 'update_room_settings') { + $roomSettings = [ + 'single' => postString('room_count_single'), + 'double' => postString('room_count_double'), + 'multi' => postString('room_count_multi'), + ]; + $upsertRoomSetting = $pdo->prepare( + 'INSERT INTO pricing_settings (setting_key, price) VALUES (:key, :price) + ON DUPLICATE KEY UPDATE price = VALUES(price)' + ); + foreach ($roomSettings as $roomType => $rawCount) { + if ($rawCount === '' || !ctype_digit($rawCount) || (int) $rawCount > 9999) { + throw new InvalidArgumentException('Bitte gültige Zimmeranzahlen zwischen 0 und 9.999 eingeben.'); + } + $upsertRoomSetting->execute([ + ':key' => 'room_count_' . $roomType, + ':price' => $rawCount, + ]); + } + $_SESSION['admin_message'] = 'Zimmeranzahlen wurden gespeichert.'; + } + + if ($action === 'prepare_unpaid_email') { + $template = postString('bulk_email_text'); + if ($template === '' || mb_strlen($template) > 10000) { + throw new InvalidArgumentException('Bitte einen Nachrichtentext mit höchstens 10.000 Zeichen eingeben.'); + } + $recipients = fetchUnpaidRecipients($pdo); + if ($recipients === []) { + throw new InvalidArgumentException('Es wurden keine unbezahlten Registrierungen mit E-Mail-Adresse gefunden.'); + } + $_SESSION['bulk_email_draft'] = [ + 'text' => $template, + 'created_at' => time(), + ]; + $_SESSION['admin_message'] = 'Vorschau vorbereitet. Bitte Empfängerzahl und Nachricht prüfen.'; + } + + if ($action === 'send_unpaid_email') { + $draft = $_SESSION['bulk_email_draft'] ?? null; + if (!is_array($draft) || !is_string($draft['text'] ?? null) || (int) ($draft['created_at'] ?? 0) < time() - 1800) { + unset($_SESSION['bulk_email_draft']); + throw new InvalidArgumentException('Die Vorschau ist abgelaufen. Bitte die Nachricht erneut vorbereiten.'); + } + $recipients = fetchUnpaidRecipients($pdo); + $sent = 0; + $failed = 0; + foreach ($recipients as $recipient) { + try { + sendConfiguredTextEmail( + $mailConfig, + (string) $recipient['email'], + 'One Voice - Chorwochenende - Deine Teilnahmegebühr', + personalizeBulkEmail($draft['text'], $recipient) + ); + $sent++; + } catch (Throwable $exception) { + $failed++; + error_log('Massenmail konnte nicht an ' . (string) $recipient['email'] . ' versendet werden: ' . $exception->getMessage()); + } + } + unset($_SESSION['bulk_email_draft']); + $_SESSION['admin_message'] = $failed === 0 + ? $sent . ' Zahlungserinnerung(en) wurden versendet.' + : $sent . ' Zahlungserinnerung(en) versendet, ' . $failed . ' Versand/Versände fehlgeschlagen.'; + } + + if ($action === 'update_booking_price') { + $bookingId = filter_var($_POST['booking_id'] ?? null, FILTER_VALIDATE_INT); + if ($bookingId === false || $bookingId === null) { + throw new InvalidArgumentException('Ungültige Registrierung.'); + } + $correctPrice = isset($_POST['correct_price']) && $_POST['correct_price'] === '1'; + $rawManualPrice = $correctPrice ? str_replace(',', '.', postString('manual_total_price')) : ''; + $bookingStatement = $pdo->prepare( + 'SELECT b.id, b.type, b.accommodation_type, b.meal_preferences, p.under_27 + FROM bookings b + INNER JOIN participants p ON p.id = b.participant_id + WHERE b.id = :id' + ); + $bookingStatement->execute([':id' => $bookingId]); + $bookingRow = $bookingStatement->fetch(PDO::FETCH_ASSOC); + if ($bookingRow === false) { + throw new InvalidArgumentException('Registrierung nicht gefunden.'); + } + if ($rawManualPrice !== '' && (!is_numeric($rawManualPrice) || (float) $rawManualPrice < 0 || (float) $rawManualPrice > 999999.99)) { + throw new InvalidArgumentException('Bitte einen gültigen manuellen Gesamtpreis zwischen 0,00 und 999.999,99 eingeben.'); + } + $manualPrice = $rawManualPrice === '' ? null : number_format((float) $rawManualPrice, 2, '.', ''); + $mealData = $bookingRow['meal_preferences'] !== null + ? json_decode($bookingRow['meal_preferences'], true) + : []; + $mealData = is_array($mealData) ? $mealData : []; + $mealsByDay = is_array($mealData['meals_by_day'] ?? null) ? $mealData['meals_by_day'] : []; + if ($mealsByDay === [] && is_array($mealData['meals'] ?? null)) { + foreach ($mealData['days'] ?? [] as $day) { + $mealsByDay[(string) $day] = $mealData['meals']; + } + } + foreach ($mealData['days'] ?? [] as $day) { + $mealsByDay[(string) $day] ??= []; + } + $automaticPrice = calculateBookingTotal( + $pdo, + (int) $bookingRow['under_27'] === 1, + $bookingRow['type'] === 'Day' ? 'day_guest' : 'regular_guest', + $bookingRow['accommodation_type'], + $mealsByDay + ); + $statement = $pdo->prepare( + 'UPDATE bookings SET manual_total_price = :manual_total_price, total_price = :total_price WHERE id = :id' + ); + $statement->execute([ + ':manual_total_price' => $manualPrice, + ':total_price' => $manualPrice ?? number_format($automaticPrice, 2, '.', ''), + ':id' => $bookingId, + ]); + $_SESSION['admin_message'] = $manualPrice === null + ? 'Manueller Preis entfernt; automatische Berechnung wurde wiederhergestellt.' + : 'Preis wurde korrigiert.'; + } + + if ($action === 'update_prices') { + $priceKeys = []; + foreach (['single', 'double', 'multi', 'breakfast', 'lunch', 'coffee', 'dinner'] as $baseKey) { + $priceKeys[] = $baseKey . '_under_27'; + $priceKeys[] = $baseKey . '_over_27'; + } + $priceKeys[] = 'drinks'; + $priceKeys[] = 'rehearsal_room'; + $upsert = $pdo->prepare( + 'INSERT INTO pricing_settings (setting_key, price) VALUES (:key, :price) + ON DUPLICATE KEY UPDATE price = VALUES(price)' + ); + foreach ($priceKeys as $priceKey) { + $rawPrice = str_replace(',', '.', postString('price_' . $priceKey)); + if ($rawPrice === '' || !is_numeric($rawPrice) || (float) $rawPrice < 0 || (float) $rawPrice > 999999.99) { + throw new InvalidArgumentException('Bitte gültige Preise zwischen 0,00 und 999.999,99 eingeben.'); + } + $upsert->execute([':key' => $priceKey, ':price' => number_format((float) $rawPrice, 2, '.', '')]); + } + + $bookingRows = $pdo->query( + 'SELECT b.id, b.type, b.accommodation_type, b.meal_preferences, b.manual_total_price, p.under_27 + FROM bookings b + INNER JOIN participants p ON p.id = b.participant_id' + )->fetchAll(PDO::FETCH_ASSOC); + $updateTotal = $pdo->prepare('UPDATE bookings SET total_price = :total_price WHERE id = :id'); + foreach ($bookingRows as $bookingRow) { + if ($bookingRow['manual_total_price'] !== null) { + continue; + } + $mealData = $bookingRow['meal_preferences'] !== null + ? json_decode($bookingRow['meal_preferences'], true) + : []; + $mealData = is_array($mealData) ? $mealData : []; + $guestType = $bookingRow['type'] === 'Day' ? 'day_guest' : 'regular_guest'; + $mealsByDay = is_array($mealData['meals_by_day'] ?? null) ? $mealData['meals_by_day'] : []; + if ($mealsByDay === [] && is_array($mealData['meals'] ?? null)) { + foreach ($mealData['days'] ?? [] as $day) { + $mealsByDay[(string) $day] = $mealData['meals']; + } + } + foreach ($mealData['days'] ?? [] as $day) { + $mealsByDay[(string) $day] ??= []; + } + $totalPrice = calculateBookingTotal( + $pdo, + (int) $bookingRow['under_27'] === 1, + $guestType, + $bookingRow['accommodation_type'], + $mealsByDay + ); + $updateTotal->execute([ + ':total_price' => number_format($totalPrice, 2, '.', ''), + ':id' => (int) $bookingRow['id'], + ]); + } + $_SESSION['admin_message'] = 'Preise wurden gespeichert.'; + } + + if ($action === 'change_password') { + $adminId = filter_var($_POST['admin_id'] ?? null, FILTER_VALIDATE_INT); + $password = postString('changed_password'); + + if ($adminId === false || $adminId === null || strlen($password) < 12) { + throw new InvalidArgumentException('Passwort muss mindestens 12 Zeichen lang sein.'); + } + + $statement = $pdo->prepare( + 'UPDATE admin_users SET password_hash = :password_hash, updated_at = CURRENT_TIMESTAMP + WHERE id = :id AND is_active = 1' + ); + $statement->execute([ + ':password_hash' => password_hash($password, PASSWORD_DEFAULT), + ':id' => $adminId, + ]); + $_SESSION['admin_message'] = 'Passwort wurde geändert.'; + } + + if ($action === 'delete_user') { + $adminId = filter_var($_POST['admin_id'] ?? null, FILTER_VALIDATE_INT); + if ($adminId === false || $adminId === null || $adminId === (int) $_SESSION['admin_id']) { + throw new InvalidArgumentException('Der eigene Benutzer kann nicht gelöscht werden.'); + } + + $activeCount = (int) $pdo->query('SELECT COUNT(*) FROM admin_users WHERE is_active = 1')->fetchColumn(); + if ($activeCount <= 1) { + throw new InvalidArgumentException('Der letzte Admin-Benutzer kann nicht gelöscht werden.'); + } + + $statement = $pdo->prepare('DELETE FROM admin_users WHERE id = :id AND is_active = 1'); + $statement->execute([':id' => $adminId]); + $_SESSION['admin_message'] = 'Admin-Benutzer wurde gelöscht.'; + } + } catch (InvalidArgumentException $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $_SESSION['admin_message'] = $exception->getMessage(); + } catch (PDOException $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log($exception->getMessage()); + $_SESSION['admin_message'] = 'Die Änderung konnte nicht gespeichert werden.'; + } + + redirectToAdmin(); +} + +$bookings = $pdo->query( + 'SELECT b.id AS booking_id, p.name, p.email, p.role, p.under_27, p.allergies, + b.type, b.accommodation_type, b.meal_preferences, b.is_paid, b.total_price, + b.manual_total_price, b.roommate_requests, b.early_departure, b.other_notes + FROM bookings b + INNER JOIN participants p ON p.id = b.participant_id + ORDER BY b.id DESC' +)->fetchAll(PDO::FETCH_ASSOC); + +$roomPlanningSettings = loadRoomPlanningSettings($pdo); +$roomPlanningParticipants = $pdo->query( + "SELECT p.id, p.name, p.email, b.accommodation_type AS room_type, + b.roommate_requests + FROM bookings b + INNER JOIN participants p ON p.id = b.participant_id + WHERE b.type = 'Stay' + AND b.accommodation_type IN ('single', 'double', 'multi') + ORDER BY p.name" +)->fetchAll(PDO::FETCH_ASSOC); +$roomPlan = buildRoomPlan($roomPlanningParticipants, $roomPlanningSettings); + +$admins = $pdo->query( + 'SELECT id, username, email, created_at, updated_at + FROM admin_users + WHERE is_active = 1 + ORDER BY username' +)->fetchAll(PDO::FETCH_ASSOC); + +$pricingRows = $pdo->query('SELECT setting_key, price FROM pricing_settings')->fetchAll(PDO::FETCH_KEY_PAIR); +$priceGroups = [ + 'Zimmer' => ['single' => 'Einzelzimmer', 'double' => 'Doppelzimmer', 'multi' => 'Mehrbettzimmer'], + 'Mahlzeiten' => ['breakfast' => 'Frühstück', 'lunch' => 'Mittagessen', 'coffee' => 'Kaffee', 'dinner' => 'Abendessen'], +]; +$flatPriceItems = [ + 'drinks' => 'Getränkepauschale', + 'rehearsal_room' => 'Proberaumpauschale', +]; + +$csrf = csrfToken(); +$bulkEmailDraft = $_SESSION['bulk_email_draft'] ?? null; +$bulkEmailRecipients = is_array($bulkEmailDraft) && (int) ($bulkEmailDraft['created_at'] ?? 0) >= time() - 1800 + ? fetchUnpaidRecipients($pdo) + : []; +$bulkEmailPreview = $bulkEmailRecipients !== [] && is_array($bulkEmailDraft) + ? personalizeBulkEmail((string) $bulkEmailDraft['text'], $bulkEmailRecipients[0]) + : ''; +?> + + + + + Administration + + + +
+
+

Chorwochenende

+

Administration

+
+
+ + + +
+
+ +
+ +

+ + +
+
+
+

Anmeldungen

+

Registrierungen

+
+
+ + Excel-Export +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameE-MailAltersgruppeBezahltPreisTypZimmerZimmerpartner-WunschFrühzeitige AbreiseMahlzeiten / TageAllergienSonstigesAktion
+ + +
+ +
+ + + + +
> + + > + +
+
+
+
+ + + + +
+
Noch keine Registrierungen vorhanden.
+
+
+ +
+

Auswertung

+

Zahlungserinnerung an Nichtzahler

+

Angeschrieben werden alle angemeldeten Teilnehmer mit hinterlegter E-Mail-Adresse, deren Registrierung noch nicht als bezahlt markiert ist.

+
+ + + + + Platzhalter: {{name}} für den Namen und {{preis}} für den jeweils offenen Preis. + +
+ + +
+

Versand bestätigen

+

Empfänger gefunden. Beispiel für :

+
+
+ + + +
+
+ +
+ +
+

Auswertung

+

Zimmerbelegung planen

+

Berücksichtigt werden nur Übernachtungsgäste. Mehrbettzimmer werden mit maximal vier Schlafplätzen geplant.

+
+ + +
+

Verfügbare Zimmer

+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ Planungshinweise +
    + +
  • + +
+
+ + +
+ + + + + + + + + + + + + + +
ZimmerBelegungKapazität
/
Noch keine Zimmerplanung möglich. Bitte Zimmeranzahlen und Übernachtungsgäste prüfen.
+
+
+ +
+

Preise

+
+ + + $priceItems): ?> +
+

+ $priceLabel): ?> +
+ + + + + +
+ +
+ +
+

Pauschalen

+ $priceLabel): ?> +
+ + + +
+ +
+ +
+
+ +
+
+

Zugänge

+

Admin-Benutzer

+
+ +
+

+ +
+ +
+
+ +
+

Zugänge

+

Benutzer anlegen

+
+ + + + + + + + + +
+
+
+
+ + + diff --git a/booking.php b/booking.php new file mode 100644 index 0000000..8d55cd7 --- /dev/null +++ b/booking.php @@ -0,0 +1,138 @@ + true, + 'samesite' => 'Strict', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'use_strict_mode' => true, +]); +session_start(); +$_SESSION['registration_csrf'] ??= bin2hex(random_bytes(32)); +header('Cache-Control: no-store'); +header('X-Content-Type-Options: nosniff'); +header('X-Frame-Options: DENY'); +header('Referrer-Policy: no-referrer'); +header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); +header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"); +?> + + + + + Gastregistrierung + + + + +

Gastregistrierung

+ +
+ + + +
+ + + + + + + +
+ + +
+ + +
+ + +
+ +
+ + + + + + + +
+ + + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+ + + + + + diff --git a/cat_update_2026-09-19.sql b/cat_update_2026-09-19.sql new file mode 100644 index 0000000..f01057b --- /dev/null +++ b/cat_update_2026-09-19.sql @@ -0,0 +1,12 @@ +-- CAT production update: room-planning settings +-- Generated from the comparison of /home/raine/cat/cat_current_2026-09-14.zip +-- with /var/www/html/cat. +-- This migration does not alter participant, booking, admin, or price data. + +USE cat; + +INSERT INTO pricing_settings (setting_key, price) VALUES + ('room_count_single', 0.00), + ('room_count_double', 0.00), + ('room_count_multi', 0.00) +ON DUPLICATE KEY UPDATE setting_key = VALUES(setting_key); diff --git a/cat_update_2026-09-19.zip b/cat_update_2026-09-19.zip new file mode 100644 index 0000000000000000000000000000000000000000..6de3aef5b1e18fc0253cb0338811461bbf0bd48c GIT binary patch literal 33623 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ʡZT46!Y>4`rc-_#gax=>M(4EZM6N$zjE&-YU=qT-5KEP;2 zVoA+ohsH$%h_x%8GqKx2N{gO462)nW1(TdfV2NialFW!nqeLU=#0T6{gcHc(H)HtH z6T_efCv(uF4z zAKVs<5rQbc)PbCCa1n0PsT>Z!vK8T_h|~4Cu(gK-JcVnms$szLDgV~O8gcbNcSMUK zix50nriJ6r`Z5UG&v~3OmFQZs89?%$-^>fcXBZ;$DRhLhkL4c@e8`@mh7rQVM@Wq` z9-X$TRNwW+|C!{5yh(svuURNHeouGFp-S0%nS+lx=8V;`xj{4 z(>djx2kiDR2<>z(=kGsys&{@D56zWqSLan9tEDAw;xCe?Q^5;_mi3xKH?eiSofj0N z;JAh;kt7**etRr9jpN8x9$*(Rp59swk%D6c&sZD<>WU+qLXc~wLdS|B3@8AOrIj1) z(42B5Da=3hbYWD#YvNX{wjy}xu85+~JDt1A4T6Z=zqM1#4 zz_n?6PGh(9*E}XlR<{_uddz0)!2Z62m+Q|e-TmTo)x$+h!}imJcYr&O*KZGpIb;u#)UshHR^kcc@0SZyuIlNjx*s5v zU!rRe5wS=cz#uFBF{`!P$R>Q1#oL|xqfi;V#5!P@vCX<q&mm2S7@{r5f0(&*Wk*q`yGDW3e8US-F2yLA@nKa6EZgrI`l>KTh<8C zkmc@_3&w+UkSfFy{GQ=i_w|0fCGH&38ozDdhwW&q8QbQ+er4Y}J|FOg9!o+S&ugo@ z{SHsdui>ZglD|sW(1^xV=6FO};8F(o8&PPt+bj?1U0~mAZP@B?((wHkg8!Nl1eyHR z({L8x%Ex*YZ3diqCAIVLHy!>b%gYEK?pvh!&`|L^>?Tr~0#zpfa&c}+gW(11qAmQk zm9>x#-mYEoGW|?!_Nl??`8(;KJ6f*2Tc$<)Fx|jx-~R=1^?J%NFM8bE=7 zQ2(EJ=4@u->SX2O`Cm8-QQ7`8NQBk%NP8RJZE>siqn!@Z0LA3=R`n;(=GcRLW8w ztGPt-TB{DQ{%Fi|#)HiwNg&yZ(>OMuAY~ z90{*hnTjfxSR^06*)#g0Q2PI#h8(M!&p862lWdeLR|DdSM5V2LvrbZ@+RPg)&&@CjCr}gywlsoRkh3&_YwwV?VLM11rfo?ht18N|;>a4Yo!AM}xTW!`F53B> z$XcM#M%9s;Kc0F5rd<^TWy literal 0 HcmV?d00001 diff --git a/css/admin.css b/css/admin.css new file mode 100644 index 0000000..2a16d59 --- /dev/null +++ b/css/admin.css @@ -0,0 +1,119 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap'); + +:root { + --bg: #f7f7f7; + --surface: #ffffff; + --text: #131313; + --muted: #7c7c7c; + --border: #dcdcdc; + --accent: #008f95; + --accent-soft: #c8f0ee; + --focus: #006d73; + --danger: #a51d00; +} + +* { box-sizing: border-box; } + +body { + min-height: 100vh; + margin: 0; + padding: 32px; + color: var(--text); + background: var(--bg); + font-family: Roboto, Arial, sans-serif; + line-height: 1.5; +} + +.admin-header, +.admin-content { + width: min(100%, 1600px); + margin: 0 auto; +} + +.admin-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 32px; +} + +h1, h2 { margin: 0; line-height: 1.1; } +h1 { font-size: clamp(2rem, 5vw, 3.5rem); font-weight: 900; letter-spacing: -0.04em; } +h2 { margin-bottom: 24px; font-size: 1.6rem; font-weight: 700; } +.eyebrow { margin: 0 0 8px; color: var(--accent); font-size: .75rem; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; } + +.panel, +.login-card { + padding: 28px; + background: var(--surface); + box-shadow: 0 8px 24px rgba(19, 19, 19, .08); +} + +.panel { margin-bottom: 24px; } +.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; } +.count { display: inline-block; min-width: 40px; padding: 5px 10px; color: #fff; background: var(--accent); text-align: center; font-weight: 700; } +.export-button { display: inline-block; margin-left: 8px; padding: 8px 12px; text-decoration: none; } +.table-wrapper { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th, td { padding: 13px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; } +th { color: var(--muted); font-size: .75rem; letter-spacing: .04em; text-transform: uppercase; } +td { min-width: 120px; } +.paid-form { margin: 0; } +.manual-price-form { display: grid; gap: 6px; min-width: 150px; margin-top: 8px; } +.manual-price-form .checkbox-label { display: flex; align-items: center; gap: 7px; margin: 0; cursor: pointer; font-size: .8rem; } +.manual-price-form .checkbox-label input[type="checkbox"] { width: 18px; min-height: 18px; margin: 0; accent-color: var(--accent); } +.manual-price-input { display: grid; gap: 6px; } +.manual-price-input[hidden] { display: none; } +.manual-price-form input[type="number"] { min-height: 36px; padding: 6px 8px; font-size: .85rem; } +.manual-price-form button { min-height: 34px; padding: 6px 10px; font-size: .8rem; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.paid-form input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--accent); cursor: pointer; } +a { color: var(--text); } +a:hover { color: var(--accent); } + +.admin-grid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(280px, .7fr); gap: 24px; } +.price-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; } +.price-grid h3 { margin: 0 0 16px; font-size: 1.1rem; } +.price-grid input { margin-bottom: 12px; } +.price-group { padding: 16px; border: 1px solid var(--border); } +.price-item { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px; margin-bottom: 16px; } +.price-item:last-child { margin-bottom: 0; } +.price-item strong { grid-column: 1 / -1; } +.price-item input { margin-bottom: 0; } +.price-grid button { grid-column: 1 / -1; justify-self: start; } +.user-list { display: grid; gap: 14px; } +.user-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--border); } +.user-actions, .user-actions form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } + +label { display: block; margin: 0 0 7px; font-size: .875rem; font-weight: 700; } +input { width: 100%; min-height: 44px; padding: 10px 12px; color: var(--text); background: var(--surface); border: 1px solid var(--muted); border-radius: 0; font: inherit; } +.user-actions input { width: 180px; min-height: 38px; } +input:focus, button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.stacked-form { display: grid; gap: 10px; } +.stacked-form label:not(:first-of-type) { margin-top: 8px; } +textarea { width: 100%; padding: 10px 12px; color: var(--text); background: var(--surface); border: 1px solid var(--muted); border-radius: 0; font: inherit; resize: vertical; } +.bulk-email-preview { margin-top: 24px; padding: 16px; background: #f3fbfb; border: 1px solid var(--accent); } +.bulk-email-preview h3 { margin: 0 0 8px; } +.bulk-email-preview pre { max-height: 260px; margin: 12px 0 16px; padding: 12px; overflow: auto; white-space: pre-wrap; font: inherit; background: var(--surface); border: 1px solid var(--border); } + +button { min-height: 42px; padding: 9px 16px; color: var(--text); background: var(--accent); border: 2px solid var(--accent); border-radius: 0; cursor: pointer; font: inherit; font-weight: 700; } +button:hover { color: #fff; background: var(--text); border-color: var(--text); } +.button-secondary { color: var(--text); background: var(--surface); border-color: var(--text); } +.button-danger { background: var(--danger); border-color: var(--danger); font-size: .85rem; } +.message { margin: 0 0 24px; padding: 14px 16px; background: var(--accent-soft); border-left: 4px solid var(--accent); } +.message.error { margin-bottom: 20px; } + +.login-card { width: min(100%, 440px); margin: 10vh auto 0; } +.login-card h1 { margin-bottom: 24px; } +.login-card form { display: grid; gap: 10px; } +.login-card label:not(:first-of-type) { margin-top: 8px; } + +@media (max-width: 900px) { + body { padding: 20px 12px; } + .admin-grid { grid-template-columns: 1fr; } + .price-grid { grid-template-columns: 1fr; } + .price-grid button { grid-column: auto; } + + .user-row { align-items: flex-start; flex-direction: column; } +} diff --git a/css/booking.css b/css/booking.css new file mode 100644 index 0000000..765d31c --- /dev/null +++ b/css/booking.css @@ -0,0 +1,271 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap'); + +:root { + --theater-bg: #f7f7f7; + --theater-surface: #ffffff; + --theater-text: #131313; + --theater-muted: #7c7c7c; + --theater-border: #dcdcdc; + --theater-accent: #008f95; + --theater-accent-soft: #c8f0ee; + --theater-focus: #006d73; + --theater-radius: 0; + --theater-shadow: 0 8px 24px rgba(19, 19, 19, 0.08); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + min-height: 100vh; + margin: 0; + padding: 48px 20px; + color: var(--theater-text); + background: var(--theater-bg); + font-family: Roboto, Arial, sans-serif; + font-size: 16px; + line-height: 1.5; +} + +h1 { + width: min(100%, 760px); + margin: 0 auto 32px; + font-size: clamp(2rem, 5vw, 3.5rem); + font-weight: 900; + letter-spacing: -0.04em; + line-height: 1.05; +} + +form { + width: min(100%, 760px); + margin: 0 auto; + padding: clamp(24px, 5vw, 48px); + background: var(--theater-surface); + box-shadow: var(--theater-shadow); +} + +.success-card { + width: min(100%, 760px); + margin: 0 auto; + padding: clamp(24px, 5vw, 48px); + background: var(--theater-surface); + box-shadow: var(--theater-shadow); +} + +.credentials { + margin: 28px 0; + padding: 20px; + background: var(--theater-bg); + border-left: 4px solid var(--theater-accent); +} + +.credentials dt { + color: var(--theater-muted); + font-size: 0.875rem; + font-weight: 700; +} + +.credentials dd { + margin: 0 0 16px; + font-size: 1.1rem; +} + +.credentials dd:last-child { + margin-bottom: 0; +} + +.credentials code { + color: var(--theater-accent); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 700; + letter-spacing: 0.08em; +} + +.success-card a { + color: var(--theater-accent); + font-weight: 700; +} + +.message { + margin: 0 0 24px; + padding: 14px 16px; + background: var(--theater-accent-soft); + border-left: 4px solid var(--theater-accent); +} + +.form-group { + margin-bottom: 24px; + padding: 0 0 24px; + border: 0; + border-bottom: 1px solid var(--theater-border); +} + +.form-group:last-of-type { + margin-bottom: 32px; +} + +label { + display: block; + margin: 0 0 8px; + color: var(--theater-text); + font-size: 0.875rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + margin-top: 14px; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + min-height: 0; + margin: 0; + accent-color: var(--theater-accent); +} + +input[type="text"], +input[type="email"], +select, +textarea { + display: block; + width: 100%; + min-height: 48px; + margin-bottom: 18px; + padding: 12px 14px; + color: var(--theater-text); + background: var(--theater-surface); + border: 1px solid var(--theater-muted); + border-radius: var(--theater-radius); + font: inherit; + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +input[type="text"]:last-child, +input[type="email"]:last-child, +select:last-child, +textarea:last-child { + margin-bottom: 0; +} + +input::placeholder { + color: var(--theater-muted); +} + +input[type="text"]:focus, +input[type="email"]:focus, +select:focus, +textarea:focus, +button:focus-visible { + outline: 2px solid var(--theater-focus); + outline-offset: 2px; +} + +input[type="text"]:focus, +input[type="email"]:focus, +select:focus, +textarea:focus { + border-color: var(--theater-accent); + box-shadow: 0 0 0 1px var(--theater-accent); +} + +#room_details { + padding-top: 0; +} + +#regular_guest_options, +#day_guest_options { + margin-top: 8px; +} + +#roommate_info { + margin-top: 18px; + padding-left: 16px; + border-left: 4px solid var(--theater-accent-soft); +} + +.meal-day { + margin: 0 0 16px; + padding: 12px 16px 4px; + border: 1px solid var(--theater-border); +} + +.meal-day legend { + padding: 0 8px; + color: var(--theater-accent); + font-weight: 700; +} + +.form-help { + color: var(--theater-muted); + font-size: 0.9rem; +} + +.checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 12px 20px; + margin: 0 0 24px; +} + +.checkbox-group:last-child { + margin-bottom: 0; +} + +.checkbox-group input[type="checkbox"] { + width: 18px; + height: 18px; + margin: 0 6px 0 0; + accent-color: var(--theater-accent); + vertical-align: -3px; +} + +button[type="submit"] { + display: inline-flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 12px 24px; + color: var(--theater-text); + background: var(--theater-accent); + border: 2px solid var(--theater-accent); + border-radius: var(--theater-radius); + cursor: pointer; + font: inherit; + font-weight: 700; + transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease; +} + +button[type="submit"]:hover { + background: var(--theater-text); + border-color: var(--theater-text); +} + +button[type="submit"]:active { + transform: translateY(1px); +} + +@media (max-width: 600px) { + body { + padding: 28px 12px; + } + + form { + padding: 24px 18px; + } + + .checkbox-group { + display: grid; + gap: 12px; + } +} diff --git a/css/index.css b/css/index.css new file mode 100644 index 0000000..5753390 --- /dev/null +++ b/css/index.css @@ -0,0 +1,107 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap'); + +:root { + --background: #f7f7f7; + --surface: #ffffff; + --text: #131313; + --muted: #7c7c7c; + --accent: #008f95; + --accent-soft: #c8f0ee; + --focus: #006d73; +} + +* { + box-sizing: border-box; +} + +body { + display: grid; + min-height: 100vh; + margin: 0; + padding: 24px; + place-items: center; + color: var(--text); + background: var(--background); + font-family: Roboto, Arial, sans-serif; +} + +.start-page { + width: min(100%, 640px); + padding: clamp(32px, 8vw, 72px); + background: var(--surface); + box-shadow: 0 8px 24px rgba(19, 19, 19, 0.08); + text-align: center; +} + +.eyebrow { + margin: 0 0 12px; + color: var(--accent); + font-size: 0.75rem; + font-weight: 900; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: clamp(2.5rem, 8vw, 5rem); + font-weight: 900; + letter-spacing: -0.05em; + line-height: 1; +} + +.intro { + margin: 20px 0 36px; + color: var(--muted); + font-size: 1.05rem; +} + +.main-actions { + display: grid; + gap: 12px; +} + +.action-button { + display: flex; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 13px 20px; + color: var(--text); + background: var(--accent); + border: 2px solid var(--accent); + border-radius: 0; + cursor: pointer; + font: inherit; + font-weight: 700; + text-decoration: none; + transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.action-button:hover:not(:disabled) { + color: #ffffff; + background: var(--text); + border-color: var(--text); +} + +.action-button:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 3px; +} + +.action-button:disabled { + color: var(--muted); + background: #eeeeee; + border-color: #eeeeee; + cursor: not-allowed; +} + +.action-button-secondary { + color: var(--text); + background: var(--surface); + border-color: var(--text); +} + +.action-button-secondary:hover { + color: #ffffff; +} diff --git a/css/index.html b/css/index.html new file mode 100644 index 0000000..3ff3135 --- /dev/null +++ b/css/index.html @@ -0,0 +1,2 @@ + +Not found diff --git a/edit_registration.php b/edit_registration.php new file mode 100644 index 0000000..c6f1110 --- /dev/null +++ b/edit_registration.php @@ -0,0 +1,731 @@ + true, + 'samesite' => 'Strict', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'use_strict_mode' => true, +]); +session_start(); +header('Cache-Control: no-store, no-cache, must-revalidate'); +header('Pragma: no-cache'); +header('X-Content-Type-Options: nosniff'); +header('X-Frame-Options: DENY'); +header('Referrer-Policy: no-referrer'); +header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); +header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"); + +require_once __DIR__ . '/inc/db.inc.php'; +require_once __DIR__ . '/inc/mail.inc.php'; + +function postString(string $key): string +{ + $value = $_POST[$key] ?? ''; + + return is_string($value) ? trim($value) : ''; +} + +function postStringList(string $key): array +{ + $value = $_POST[$key] ?? []; + $values = is_array($value) ? $value : [$value]; + + return array_values(array_filter( + array_map( + static fn ($item): string => is_string($item) ? trim($item) : '', + $values + ), + static fn (string $item): bool => $item !== '' + )); +} + +function postMealSelectionsByDay(): array +{ + $value = $_POST['meals'] ?? []; + if (!is_array($value)) { + return []; + } + + $result = []; + foreach ($value as $day => $meals) { + if (!is_array($meals)) { + continue; + } + $result[(string) $day] = array_values(array_filter( + array_map(static fn ($item): string => is_string($item) ? trim($item) : '', $meals), + static fn (string $item): bool => $item !== '' + )); + } + + return $result; +} + +function escapeHtml(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function earlyDepartureLabel(?string $value): string +{ + return match ($value) { + 'sat_breakfast' => 'Samstag nach dem Frühstück', + 'sat_lunch' => 'Samstag nach dem Mittagessen', + 'sat_coffee' => 'Samstag nach dem Kaffee', + 'sat_dinner' => 'Samstag nach dem Abendessen', + 'sun_breakfast' => 'Sonntag nach dem Frühstück', + null, '' => 'Keine vorzeitige Abreise', + default => $value, + }; +} + +function calculateBookingTotal(PDO $pdo, bool $under27, string $guestType, ?string $roomType, array $mealsByDay): float +{ + $ageSuffix = $under27 ? 'under_27' : 'over_27'; + $keys = []; + + if ($guestType === 'regular_guest' && $roomType !== null) { + $keys[] = $roomType . '_' . $ageSuffix; + } + if ($guestType === 'day_guest') { + foreach ($mealsByDay as $meals) { + foreach (array_unique(array_merge($meals, ['drinks'])) as $meal) { + if (in_array($meal, ['breakfast', 'lunch', 'coffee', 'dinner'], true)) { + $keys[] = $meal . '_' . $ageSuffix; + } elseif ($meal === 'drinks') { + $keys[] = 'drinks'; + } + } + $keys[] = 'rehearsal_room'; + } + } + if ($keys === []) { + return 0.0; + } + + $uniqueKeys = array_values(array_unique($keys)); + $placeholders = implode(',', array_fill(0, count($uniqueKeys), '?')); + $statement = $pdo->prepare("SELECT setting_key, price FROM pricing_settings WHERE setting_key IN ($placeholders)"); + $statement->execute($uniqueKeys); + $prices = []; + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + $prices[(string) $row['setting_key']] = (float) $row['price']; + } + + return round(array_sum(array_map( + static fn (string $key): float => $prices[$key] ?? 0.0, + $keys + )), 2); +} + +function createPdo(array $dbConfig): PDO +{ + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $dbConfig['host'], + $dbConfig['port'], + $dbConfig['database'], + $dbConfig['charset'] + ); + + return new PDO($dsn, $dbConfig['username'], $dbConfig['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => false, + ]); +} + +function editSmtpResponse($socket): string +{ + $response = ''; + do { + $line = fgets($socket); + if ($line === false) { + throw new RuntimeException('Keine gültige SMTP-Antwort erhalten.'); + } + $response .= $line; + } while (isset($line[3]) && $line[3] === '-'); + + return $response; +} + +function editSmtpCommand($socket, string $command, array $expectedCodes): void +{ + fwrite($socket, $command . "\r\n"); + $response = editSmtpResponse($socket); + if (!in_array((int) substr($response, 0, 3), $expectedCodes, true)) { + throw new RuntimeException('SMTP-Befehl wurde abgelehnt.'); + } +} + +function editEncodedHeader(string $value): string +{ + return '=?UTF-8?B?' . base64_encode($value) . '?='; +} + +function sendDeletionAdminEmail(array $mailConfig, string $recipient, array $booking): void +{ + $message = 'From: ' . editEncodedHeader($mailConfig['from_name']) . ' <' . $mailConfig['from_address'] . ">\r\n" + . 'To: ' . $recipient . "\r\n" + . 'Subject: ' . editEncodedHeader('Registrierung gelöscht – Chorwochenende') . "\r\n" + . "MIME-Version: 1.0\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "Content-Transfer-Encoding: 8bit\r\n\r\n" + . "Eine Teilnehmerregistrierung wurde selbstständig gelöscht.\n\n" + . "Name: {$booking['name']}\n" + . "E-Mail: {$booking['email']}\n" + . "Gasttyp: {$booking['role']}\n" + . "Zimmerart: {$booking['accommodation_type']}\n" + . "Zimmerpartner-Wunsch: {$booking['roommate_requests']}\n" + . "Sonstiges: {$booking['other_notes']}\n"; + + $context = stream_context_create([ + 'ssl' => [ + 'peer_name' => $mailConfig['host'], + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + $socket = stream_socket_client( + 'tcp://' . $mailConfig['host'] . ':' . $mailConfig['port'], + $errorNumber, + $errorMessage, + 20, + STREAM_CLIENT_CONNECT, + $context + ); + if ($socket === false) { + throw new RuntimeException('SMTP-Server nicht erreichbar.'); + } + stream_set_timeout($socket, 20); + + try { + if ((int) substr(editSmtpResponse($socket), 0, 3) !== 220) { + throw new RuntimeException('SMTP-Verbindung wurde abgelehnt.'); + } + editSmtpCommand($socket, 'EHLO localhost', [250]); + editSmtpCommand($socket, 'STARTTLS', [220]); + if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new RuntimeException('STARTTLS konnte nicht aktiviert werden.'); + } + editSmtpCommand($socket, 'EHLO localhost', [250]); + editSmtpCommand($socket, 'AUTH LOGIN', [334]); + editSmtpCommand($socket, base64_encode($mailConfig['username']), [334]); + editSmtpCommand($socket, base64_encode($mailConfig['password']), [235]); + editSmtpCommand($socket, 'MAIL FROM:<' . $mailConfig['from_address'] . '>', [250]); + editSmtpCommand($socket, 'RCPT TO:<' . $recipient . '>', [250, 251]); + editSmtpCommand($socket, 'DATA', [354]); + fwrite($socket, preg_replace('/^\./m', '..', $message) . "\r\n.\r\n"); + if ((int) substr(editSmtpResponse($socket), 0, 3) !== 250) { + throw new RuntimeException('E-Mail konnte nicht angenommen werden.'); + } + fwrite($socket, "QUIT\r\n"); + } finally { + fclose($socket); + } +} + +function redirectToEdit(): never +{ + header('Location: edit_registration.php'); + exit; +} + +function editCsrfToken(): string +{ + if (!isset($_SESSION['edit_csrf'])) { + $_SESSION['edit_csrf'] = bin2hex(random_bytes(32)); + } + + return $_SESSION['edit_csrf']; +} + +function requireEditCsrf(): void +{ + $submitted = postString('csrf_token'); + $stored = $_SESSION['edit_csrf'] ?? ''; + + if ($stored === '' || !hash_equals($stored, $submitted)) { + http_response_code(403); + exit('Ungültige Anfrage.'); + } +} + +function validateBookingInput(): array +{ + $data = [ + 'name' => postString('name'), + 'email' => postString('email'), + 'guest_type' => postString('guest_type'), + 'room_type' => postString('room_type'), + 'roommates' => postString('roommates'), + 'early_departure' => postString('early_departure'), + 'allergies' => postString('allergies'), + 'other_notes' => postString('other_notes'), + 'days' => postStringList('days'), + 'meals_by_day' => postMealSelectionsByDay(), + ]; + $errors = []; + + if ($data['name'] === '' || mb_strlen($data['name']) > 255) { + $errors[] = 'Bitte einen gültigen Namen eingeben.'; + } + if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL) || mb_strlen($data['email']) > 255) { + $errors[] = 'Bitte eine gültige E-Mail-Adresse eingeben.'; + } + if (!in_array($data['guest_type'], ['day_guest', 'regular_guest'], true)) { + $errors[] = 'Bitte einen gültigen Gasttyp auswählen.'; + } + if ($data['guest_type'] === 'regular_guest' + && !in_array($data['room_type'], ['single', 'double', 'multi'], true)) { + $errors[] = 'Bitte eine gültige Zimmerart auswählen.'; + } + $allowedEarlyDepartures = ['sat_breakfast', 'sat_lunch', 'sat_coffee', 'sat_dinner', 'sun_breakfast']; + if ($data['early_departure'] !== '' + && ($data['guest_type'] !== 'regular_guest' || !in_array($data['early_departure'], $allowedEarlyDepartures, true))) { + $errors[] = 'Bitte einen gültigen Abreisezeitpunkt auswählen.'; + } + if (mb_strlen($data['other_notes']) > 5000) { + $errors[] = 'Das Feld Sonstiges darf höchstens 5000 Zeichen enthalten.'; + } + if (mb_strlen($data['roommates']) > 1000 || mb_strlen($data['allergies']) > 1000) { + $errors[] = 'Zimmerwunsch und Allergieangaben dürfen höchstens 1000 Zeichen enthalten.'; + } + + $allowedDays = ['1', '2', '3']; + $allowedMealsByDay = [ + '1' => ['dinner'], + '2' => ['breakfast', 'lunch', 'coffee', 'dinner'], + '3' => ['breakfast', 'lunch'], + ]; + $data['days'] = array_values(array_unique($data['days'])); + foreach ($data['meals_by_day'] as $day => $dayMeals) { + $data['meals_by_day'][$day] = array_values(array_unique($dayMeals)); + } + if (count($data['days']) > count($allowedDays) || array_diff($data['days'], $allowedDays) !== []) { + $errors[] = 'Bitte nur gültige Anwesenheitstage auswählen.'; + } + if (array_diff(array_keys($data['meals_by_day']), $allowedDays) !== []) { + $errors[] = 'Bitte nur gültige Mahlzeitentage auswählen.'; + } + foreach ($data['meals_by_day'] as $day => $dayMeals) { + $allowedMeals = $allowedMealsByDay[(string) $day] ?? []; + if (array_diff($dayMeals, $allowedMeals) !== []) { + $errors[] = 'Bitte nur gültige Mahlzeiten auswählen.'; + } + if (!in_array((string) $day, $data['days'], true) && $dayMeals !== []) { + $errors[] = 'Mahlzeiten dürfen nur für ausgewählte Anwesenheitstage angegeben werden.'; + } + } + foreach ($data['days'] as $day) { + $data['meals_by_day'][$day] ??= []; + } + + if ($errors !== []) { + throw new InvalidArgumentException(implode(' ', $errors)); + } + + return $data; +} + +$pdo = createPdo($dbConfig); +$message = $_SESSION['edit_message'] ?? ''; +unset($_SESSION['edit_message']); + +if (postString('action') === 'login') { + $email = postString('email'); + $password = postString('password'); + $lockedUntil = (int) ($_SESSION['edit_locked_until'] ?? 0); + + if ($lockedUntil > time()) { + $message = 'Zu viele Fehlversuche. Bitte später erneut versuchen.'; + } else { + $statement = $pdo->prepare( + 'SELECT p.id AS participant_id, b.id AS booking_id, p.password_hash + FROM participants p + INNER JOIN bookings b ON b.participant_id = p.id + WHERE p.email = :email + LIMIT 1' + ); + $statement->execute([':email' => $email]); + $participant = $statement->fetch(PDO::FETCH_ASSOC); + + if ($participant !== false && password_verify($password, $participant['password_hash'])) { + unset($_SESSION['edit_login_failures'], $_SESSION['edit_locked_until']); + session_regenerate_id(true); + $_SESSION['edit_participant_id'] = $participant['participant_id']; + $_SESSION['edit_booking_id'] = (int) $participant['booking_id']; + $_SESSION['edit_csrf'] = bin2hex(random_bytes(32)); + $_SESSION['edit_last_activity'] = time(); + redirectToEdit(); + } + + $failures = (int) ($_SESSION['edit_login_failures'] ?? 0) + 1; + $_SESSION['edit_login_failures'] = $failures; + if ($failures >= 5) { + $_SESSION['edit_locked_until'] = time() + 300; + } + usleep(500000); + $message = 'E-Mail-Adresse oder Passwort ist ungültig.'; + } +} + +if (isset($_SESSION['edit_participant_id'], $_SESSION['edit_booking_id'])) { + $lastActivity = (int) ($_SESSION['edit_last_activity'] ?? 0); + if ($lastActivity > 0 && $lastActivity < time() - 1800) { + unset($_SESSION['edit_participant_id'], $_SESSION['edit_booking_id'], $_SESSION['edit_csrf']); + session_regenerate_id(true); + } else { + $_SESSION['edit_last_activity'] = time(); + } +} + +if (!isset($_SESSION['edit_participant_id'], $_SESSION['edit_booking_id'])) { + ?> + + + + + Registrierung ändern + + + +
+

Registrierung ändern

+

Gib die E-Mail-Adresse und das Passwort ein, die du nach der Registrierung erhalten hast.

+ +

+ +
+ +
+ + + + +
+ +
+

Zur Startseite

+
+ + time() - 42000, + 'path' => '/', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'httponly' => true, + 'samesite' => 'Strict', + ]); + session_destroy(); + redirectToEdit(); + } + + if ($action === 'delete_booking') { + $deletedStatement = $pdo->prepare( + 'SELECT p.name, p.email, p.role, b.accommodation_type, + b.roommate_requests, b.early_departure, b.other_notes + FROM participants p + INNER JOIN bookings b ON b.participant_id = p.id + WHERE p.id = :participant_id AND b.id = :booking_id' + ); + $deletedStatement->execute([ + ':participant_id' => $participantId, + ':booking_id' => $bookingId, + ]); + $deletedBooking = $deletedStatement->fetch(PDO::FETCH_ASSOC); + if ($deletedBooking === false) { + throw new InvalidArgumentException('Registrierung nicht gefunden.'); + } + $deletedBooking['accommodation_type'] = match ($deletedBooking['accommodation_type']) { + 'single' => 'Einzelzimmer', + 'double' => 'Doppelzimmer', + 'multi' => 'Mehrbettzimmer', + default => '–', + }; + $deletedBooking['roommate_requests'] = $deletedBooking['roommate_requests'] ?: '–'; + $deletedBooking['other_notes'] = $deletedBooking['other_notes'] ?: '–'; + + $pdo->beginTransaction(); + $deleteBooking = $pdo->prepare( + 'DELETE FROM bookings WHERE id = :booking_id AND participant_id = :participant_id' + ); + $deleteBooking->execute([ + ':booking_id' => $bookingId, + ':participant_id' => $participantId, + ]); + $deleteParticipant = $pdo->prepare('DELETE FROM participants WHERE id = :participant_id'); + $deleteParticipant->execute([':participant_id' => $participantId]); + $pdo->commit(); + + $adminEmails = $pdo->query( + "SELECT email FROM admin_users WHERE is_active = 1 AND email <> ''" + )->fetchAll(PDO::FETCH_COLUMN); + foreach ($adminEmails as $adminEmail) { + try { + sendDeletionAdminEmail($mailConfig, (string) $adminEmail, $deletedBooking); + } catch (Throwable $exception) { + error_log('Admin-Benachrichtigung über Löschung konnte nicht versendet werden: ' . $exception->getMessage()); + } + } + + unset($_SESSION['edit_participant_id'], $_SESSION['edit_booking_id'], $_SESSION['edit_csrf']); + $_SESSION['edit_message'] = 'Die Registrierung wurde gelöscht.'; + redirectToEdit(); + } + + if ($action === 'update_booking') { + $data = validateBookingInput(); + $role = $data['guest_type'] === 'day_guest' ? 'Tagesgast' : 'Dauergast'; + $bookingType = $data['guest_type'] === 'day_guest' ? 'Day' : 'Stay'; + $accommodationType = $data['guest_type'] === 'regular_guest' ? $data['room_type'] : null; + $roommateRequest = $data['guest_type'] === 'regular_guest' && $data['roommates'] !== '' + ? $data['roommates'] + : null; + $allowedEarlyDepartures = ['sat_breakfast', 'sat_lunch', 'sat_coffee', 'sat_dinner', 'sun_breakfast']; + $earlyDeparture = $data['guest_type'] === 'regular_guest' + && in_array($data['early_departure'], $allowedEarlyDepartures, true) + ? $data['early_departure'] + : null; + $mealPreferences = $data['guest_type'] === 'day_guest' + ? json_encode(['days' => $data['days'], 'meals_by_day' => $data['meals_by_day']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) + : null; + + $pdo->beginTransaction(); + $existingPriceStatement = $pdo->prepare( + 'SELECT manual_total_price FROM bookings WHERE id = :booking_id AND participant_id = :participant_id FOR UPDATE' + ); + $existingPriceStatement->execute([ + ':booking_id' => $bookingId, + ':participant_id' => $participantId, + ]); + $existingManualPrice = $existingPriceStatement->fetchColumn(); + if ($existingManualPrice === false) { + throw new InvalidArgumentException('Registrierung nicht gefunden.'); + } + + $participantStatement = $pdo->prepare( + 'UPDATE participants + SET name = :name, email = :email, allergies = :allergies, role = :role, under_27 = :under_27 + WHERE id = :participant_id' + ); + $participantStatement->execute([ + ':name' => $data['name'], + ':email' => $data['email'], + ':allergies' => $data['allergies'] !== '' ? $data['allergies'] : null, + ':role' => $role, + ':under_27' => isset($_POST['under_27']) && $_POST['under_27'] === '1' ? 1 : 0, + ':participant_id' => $participantId, + ]); + + $totalPrice = calculateBookingTotal( + $pdo, + isset($_POST['under_27']) && $_POST['under_27'] === '1', + $data['guest_type'], + $accommodationType, + $data['meals_by_day'] + ); + $bookingStatement = $pdo->prepare( + 'UPDATE bookings + SET type = :type, accommodation_type = :accommodation_type, + meal_preferences = :meal_preferences, roommate_requests = :roommate_requests, + early_departure = :early_departure, + other_notes = :other_notes, total_price = :total_price + WHERE id = :booking_id AND participant_id = :participant_id' + ); + $bookingStatement->execute([ + ':type' => $bookingType, + ':accommodation_type' => $accommodationType, + ':meal_preferences' => $mealPreferences, + ':roommate_requests' => $roommateRequest, + ':early_departure' => $earlyDeparture, + ':other_notes' => $data['other_notes'] !== '' ? $data['other_notes'] : null, + ':total_price' => $existingManualPrice !== null + ? number_format((float) $existingManualPrice, 2, '.', '') + : number_format($totalPrice, 2, '.', ''), + ':booking_id' => $bookingId, + ':participant_id' => $participantId, + ]); + $pdo->commit(); + $_SESSION['edit_message'] = 'Die Registrierung wurde aktualisiert.'; + redirectToEdit(); + } + } catch (InvalidArgumentException | JsonException $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $message = $exception->getMessage(); + } catch (PDOException $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log($exception->getMessage()); + $message = 'Die Änderung konnte nicht gespeichert werden. Prüfe bitte deine Eingaben.'; + } +} + +$statement = $pdo->prepare( + 'SELECT p.name, p.email, p.role, p.under_27, p.allergies, + b.type, b.accommodation_type, b.meal_preferences, + b.roommate_requests, b.early_departure, b.other_notes + FROM participants p + INNER JOIN bookings b ON b.participant_id = p.id + WHERE p.id = :participant_id AND b.id = :booking_id' +); +$statement->execute([':participant_id' => $participantId, ':booking_id' => $bookingId]); +$booking = $statement->fetch(PDO::FETCH_ASSOC); + +if ($booking === false) { + unset($_SESSION['edit_participant_id'], $_SESSION['edit_booking_id'], $_SESSION['edit_csrf']); + http_response_code(404); + exit('Registrierung nicht gefunden.'); +} + +$mealData = $booking['meal_preferences'] !== null + ? json_decode($booking['meal_preferences'], true) + : []; +$mealData = is_array($mealData) ? $mealData : []; +$selectedMealsByDay = is_array($mealData['meals_by_day'] ?? null) ? $mealData['meals_by_day'] : []; +if ($selectedMealsByDay === [] && is_array($mealData['meals'] ?? null)) { + foreach ($mealData['days'] ?? [] as $day) { + $selectedMealsByDay[(string) $day] = $mealData['meals']; + } +} +$guestType = $booking['type'] === 'Day' ? 'day_guest' : 'regular_guest'; +$csrf = editCsrfToken(); +?> + + + + + Registrierung ändern + + + +
+

Registrierung ändern

+ +

+ + +
+ + + +
+ + + + + +
+ +
+ + +
+ +
+
+ + + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + + +
+

Zur Startseite

+
+ + + + diff --git a/export.php b/export.php new file mode 100644 index 0000000..2252e9d --- /dev/null +++ b/export.php @@ -0,0 +1,167 @@ + true, + 'samesite' => 'Strict', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'use_strict_mode' => true, +]); +session_start(); +header('Cache-Control: no-store, no-cache, must-revalidate'); +header('Pragma: no-cache'); +header('X-Content-Type-Options: nosniff'); +header('X-Frame-Options: DENY'); +header('Referrer-Policy: no-referrer'); +header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); +header("Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'self'; frame-ancestors 'none'"); + +require_once __DIR__ . '/inc/db.inc.php'; + +if (!isset($_SESSION['admin_id'])) { + http_response_code(403); + exit('Zugriff verweigert.'); +} + +$dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $dbConfig['host'], + $dbConfig['port'], + $dbConfig['database'], + $dbConfig['charset'] +); +$pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => false, +]); + +$adminCheck = $pdo->prepare( + 'SELECT id FROM admin_users WHERE id = :id AND is_active = 1 LIMIT 1' +); +$adminCheck->execute([':id' => (int) $_SESSION['admin_id']]); +if ($adminCheck->fetchColumn() === false) { + http_response_code(403); + exit('Zugriff verweigert.'); +} + +function exportAccommodation(?string $value): string +{ + return match ($value) { + 'single' => 'Einzelzimmer', + 'double' => 'Doppelzimmer', + 'multi' => 'Mehrbettzimmer', + null, '' => '', + default => $value, + }; +} + +function exportDay(mixed $value): string +{ + return match ((string) $value) { + '1' => 'Fr.', + '2' => 'Sa.', + '3' => 'So.', + default => (string) $value, + }; +} + +function csvSafe(mixed $value): string +{ + $value = (string) $value; + return preg_match('/^[=+\-@]/', $value) === 1 ? "'" . $value : $value; +} + +function exportMeal(mixed $value): string +{ + return match ((string) $value) { + 'breakfast' => 'Frühstück', + 'lunch' => 'Mittagessen', + 'coffee' => 'Kaffee', + 'drinks' => 'Getränkepauschale', + 'dinner' => 'Abendessen', + default => (string) $value, + }; +} + +function exportEarlyDeparture(mixed $value): string +{ + return match ((string) $value) { + 'sat_breakfast' => 'Samstag nach dem Frühstück', + 'sat_lunch' => 'Samstag nach dem Mittagessen', + 'sat_coffee' => 'Samstag nach dem Kaffee', + 'sat_dinner' => 'Samstag nach dem Abendessen', + 'sun_breakfast' => 'Sonntag nach dem Frühstück', + default => (string) $value, + }; +} + +$rows = $pdo->query( + 'SELECT p.name, p.email, p.role, p.under_27, p.allergies, + b.type, b.accommodation_type, b.meal_preferences, b.is_paid, b.total_price, + b.roommate_requests, b.early_departure, b.other_notes + FROM bookings b + INNER JOIN participants p ON p.id = b.participant_id + ORDER BY b.id DESC' +)->fetchAll(PDO::FETCH_ASSOC); + +header('Content-Type: text/csv; charset=UTF-8'); +header('Content-Disposition: attachment; filename="registrierungen.csv"'); +header('Cache-Control: no-store, no-cache, must-revalidate'); + +echo "\xEF\xBB\xBF"; +$output = fopen('php://output', 'wb'); +fputcsv($output, [ + 'Name', + 'E-Mail', + 'Altersgruppe', + 'Bezahlt', + 'Preis', + 'Gasttyp', + 'Zimmerart', + 'Zimmerpartner-Wunsch', + 'Frühzeitige Abreise', + 'Anwesenheitstage', + 'Mahlzeiten', + 'Allergien / Essensvorlieben', + 'Sonstiges', +], ';', '"', '\\'); + +foreach ($rows as $row) { + $mealData = $row['meal_preferences'] !== null + ? json_decode($row['meal_preferences'], true) + : null; + $daysRaw = is_array($mealData['days'] ?? null) ? $mealData['days'] : []; + $mealsByDay = is_array($mealData['meals_by_day'] ?? null) ? $mealData['meals_by_day'] : []; + if ($mealsByDay === [] && is_array($mealData['meals'] ?? null)) { + foreach ($daysRaw as $day) { + $mealsByDay[(string) $day] = $mealData['meals']; + } + } + $days = array_map('exportDay', $daysRaw); + $meals = []; + foreach ($daysRaw as $day) { + $dayMeals = array_map('exportMeal', $mealsByDay[(string) $day] ?? []); + $dayMeals[] = 'Getränkepauschale'; + $dayMeals[] = 'Proberaumpauschale'; + $meals[] = exportDay($day) . ': ' . implode(', ', $dayMeals); + } + + fputcsv($output, [ + csvSafe($row['name']), + csvSafe($row['email']), + csvSafe((int) $row['under_27'] === 1 ? 'Unter 27' : 'Ab 27'), + csvSafe((int) $row['is_paid'] === 1 ? 'Ja' : 'Nein'), + csvSafe(number_format((float) $row['total_price'], 2, ',', '.') . ' €'), + csvSafe($row['role']), + csvSafe(exportAccommodation($row['accommodation_type'])), + csvSafe($row['roommate_requests'] ?? ''), + csvSafe($row['early_departure'] !== null && $row['early_departure'] !== '' ? exportEarlyDeparture($row['early_departure']) : ''), + csvSafe(implode(', ', $days)), + csvSafe(implode(', ', $meals)), + csvSafe($row['allergies'] ?? ''), + csvSafe($row['other_notes'] ?? ''), + ], ';', '"', '\\'); +} + +fclose($output); diff --git a/inc/.htaccess b/inc/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/inc/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/inc/index.html b/inc/index.html new file mode 100644 index 0000000..3ff3135 --- /dev/null +++ b/inc/index.html @@ -0,0 +1,2 @@ + +Not found diff --git a/inc/room_planner.php b/inc/room_planner.php new file mode 100644 index 0000000..d43367a --- /dev/null +++ b/inc/room_planner.php @@ -0,0 +1,138 @@ + 0, + 'double' => 0, + 'multi' => 0, + ]; + $statement = $pdo->query( + "SELECT setting_key, price FROM pricing_settings + WHERE setting_key IN ('room_count_single', 'room_count_double', 'room_count_multi')" + ); + foreach ($statement->fetchAll(PDO::FETCH_KEY_PAIR) as $key => $value) { + $type = str_replace('room_count_', '', (string) $key); + if (array_key_exists($type, $defaults)) { + $defaults[$type] = max(0, (int) $value); + } + } + + return $defaults; +} + +function normalizedRoommateName(string $name): string +{ + return mb_strtolower(preg_replace('/\\s+/u', ' ', trim($name)) ?? '', 'UTF-8'); +} + +function buildRoomPlan(array $participants, array $roomCounts): array +{ + $rooms = []; + $warnings = []; + $unassigned = []; + $available = [ + 'single' => max(0, (int) ($roomCounts['single'] ?? 0)), + 'double' => max(0, (int) ($roomCounts['double'] ?? 0)), + 'multi' => max(0, (int) ($roomCounts['multi'] ?? 0)), + ]; + $roomNumber = ['single' => 0, 'double' => 0, 'multi' => 0]; + $assigned = []; + $blocked = []; + $byName = []; + + foreach ($participants as &$participant) { + $participant['roommate_key'] = normalizedRoommateName((string) ($participant['roommate_requests'] ?? '')); + $participant['name_key'] = normalizedRoommateName((string) $participant['name']); + $byName[$participant['name_key']][] = $participant; + } + unset($participant); + + $addRoom = static function (string $type, array $members) use (&$rooms, &$roomNumber, &$available, &$assigned): bool { + if (($available[$type] ?? 0) < 1) { + return false; + } + $roomNumber[$type]++; + $capacity = $type === 'single' ? 1 : ($type === 'double' ? 2 : 4); + $rooms[] = [ + 'type' => $type, + 'number' => $roomNumber[$type], + 'capacity' => $capacity, + 'members' => $members, + ]; + $available[$type]--; + foreach ($members as $member) { + $assigned[(string) $member['id']] = true; + } + return true; + }; + + // Mutual wishes are handled first, but only when both participants requested the same room type. + foreach ($participants as $participant) { + $id = (string) $participant['id']; + if (isset($assigned[$id]) || $participant['roommate_key'] === '') { + continue; + } + foreach ($byName[$participant['roommate_key']] ?? [] as $requested) { + $requestedId = (string) $requested['id']; + if ($requestedId === $id || isset($assigned[$requestedId])) { + continue; + } + $mutual = $requested['roommate_key'] === $participant['name_key']; + $sameType = $requested['room_type'] === $participant['room_type']; + if (!$mutual || !$sameType) { + continue; + } + if (!$addRoom((string) $participant['room_type'], [$participant, $requested])) { + $warnings[] = sprintf( + 'Der gegenseitige Zimmerwunsch von %s und %s konnte nicht erfüllt werden: kein %s-Zimmer verfügbar.', + $participant['name'], + $requested['name'], + $participant['room_type'] === 'double' ? 'Doppel' : 'Mehrbett' + ); + $unassigned[$id] = $participant; + $unassigned[$requestedId] = $requested; + $blocked[$id] = true; + $blocked[$requestedId] = true; + } + break; + } + } + + foreach (['single', 'double', 'multi'] as $type) { + $waiting = []; + foreach ($participants as $participant) { + $id = (string) $participant['id']; + if (!isset($assigned[$id]) && !isset($blocked[$id]) && $participant['room_type'] === $type) { + $waiting[] = $participant; + } + } + $capacity = $type === 'single' ? 1 : ($type === 'double' ? 2 : 4); + while ($waiting !== []) { + $group = array_splice($waiting, 0, $capacity); + if (!$addRoom($type, $group)) { + foreach ($group as $participant) { + $unassigned[(string) $participant['id']] = $participant; + } + $warnings[] = sprintf( + '%d Teilnehmer mit Wunsch %s konnten keinem verfügbaren Zimmer zugeordnet werden.', + count($group), + $type === 'single' ? 'Einzelzimmer' : ($type === 'double' ? 'Doppelzimmer' : 'Mehrbettzimmer') + ); + } + } + } + + foreach ($unassigned as $participant) { + $warnings[] = sprintf('Nicht eingeplant: %s (%s).', $participant['name'], $participant['email']); + } + + return [ + 'rooms' => $rooms, + 'warnings' => array_values(array_unique($warnings)), + 'unassigned' => array_values($unassigned), + 'available' => $available, + ]; +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..424b13a --- /dev/null +++ b/index.php @@ -0,0 +1,36 @@ + true, + 'samesite' => 'Strict', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'use_strict_mode' => true, +]); +session_start(); +header('X-Content-Type-Options: nosniff'); +header('X-Frame-Options: DENY'); +header('Referrer-Policy: no-referrer'); +header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); +header("Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'"); +?> + + + + + + Chorwochenende + + + +
+

Chorwochenende

+

Willkommen

+

Bitte wählen Sie den gewünschten Bereich aus.

+ + +
+ + diff --git a/process_registration.php b/process_registration.php new file mode 100644 index 0000000..9dbea5a --- /dev/null +++ b/process_registration.php @@ -0,0 +1,662 @@ + true, + 'samesite' => 'Strict', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'use_strict_mode' => true, +]); +session_start(); +if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 1048576) { + http_response_code(413); + exit('Anfrage zu groß.'); +} +header('Cache-Control: no-store'); +header('X-Content-Type-Options: nosniff'); +header('X-Frame-Options: DENY'); +header('Referrer-Policy: no-referrer'); +header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); +header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"); + +require_once __DIR__ . '/inc/db.inc.php'; +require_once __DIR__ . '/inc/mail.inc.php'; + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + header('Allow: POST'); + exit('Methode nicht erlaubt.'); +} + +$submittedCsrf = $_POST['csrf_token'] ?? ''; +$storedCsrf = $_SESSION['registration_csrf'] ?? ''; +if (!is_string($submittedCsrf) || $storedCsrf === '' || !hash_equals($storedCsrf, $submittedCsrf)) { + http_response_code(403); + exit('Ungültige Anfrage.'); +} +unset($_SESSION['registration_csrf']); + +function postString(string $key): string +{ + $value = $_POST[$key] ?? ''; + + return is_string($value) ? trim($value) : ''; +} + +function postStringList(string $key): array +{ + $value = $_POST[$key] ?? []; + $values = is_array($value) ? $value : [$value]; + + return array_values(array_filter( + array_map( + static fn ($item): string => is_string($item) ? trim($item) : '', + $values + ), + static fn (string $item): bool => $item !== '' + )); +} + +function postMealSelectionsByDay(): array +{ + $value = $_POST['meals'] ?? []; + if (!is_array($value)) { + return []; + } + + $result = []; + foreach ($value as $day => $meals) { + if (!is_array($meals)) { + continue; + } + $result[(string) $day] = array_values(array_filter( + array_map(static fn ($item): string => is_string($item) ? trim($item) : '', $meals), + static fn (string $item): bool => $item !== '' + )); + } + + return $result; +} + +function formatMealSummary(array $days, array $mealsByDay): string +{ + $dayLabels = ['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.']; + $mealLabels = [ + 'breakfast' => 'Frühstück', + 'lunch' => 'Mittagessen', + 'coffee' => 'Kaffee', + 'dinner' => 'Abendessen', + 'drinks' => 'Getränkepauschale', + ]; + $parts = []; + foreach ($days as $day) { + $labels = array_map(static fn (string $meal): string => $mealLabels[$meal] ?? $meal, $mealsByDay[$day] ?? []); + $labels[] = $mealLabels['drinks']; + $labels[] = $mealLabels['rehearsal_room']; + $parts[] = ($dayLabels[$day] ?? $day) . ': ' . implode(', ', $labels); + } + + return implode('; ', $parts) ?: '–'; +} + +function escapeHtml(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function smtpResponse($socket): string +{ + $response = ''; + do { + $line = fgets($socket); + if ($line === false) { + throw new RuntimeException('Keine gültige SMTP-Antwort erhalten.'); + } + $response .= $line; + } while (isset($line[3]) && $line[3] === '-'); + + return $response; +} + +function smtpCommand($socket, string $command, array $expectedCodes): void +{ + fwrite($socket, $command . "\r\n"); + $response = smtpResponse($socket); + $code = (int) substr($response, 0, 3); + + if (!in_array($code, $expectedCodes, true)) { + throw new RuntimeException('SMTP-Befehl wurde abgelehnt.'); + } +} + +function encodedHeader(string $value): string +{ + return '=?UTF-8?B?' . base64_encode($value) . '?='; +} + +function calculateBookingTotal(PDO $pdo, bool $under27, string $guestType, ?string $roomType, array $mealsByDay): float +{ + $ageSuffix = $under27 ? 'under_27' : 'over_27'; + $keys = []; + if ($guestType === 'regular_guest' && $roomType !== null) { + $keys[] = $roomType . '_' . $ageSuffix; + } + if ($guestType === 'day_guest') { + foreach ($mealsByDay as $meals) { + foreach (array_unique(array_merge($meals, ['drinks'])) as $meal) { + if (in_array($meal, ['breakfast', 'lunch', 'coffee', 'dinner'], true)) { + $keys[] = $meal . '_' . $ageSuffix; + } elseif ($meal === 'drinks') { + $keys[] = 'drinks'; + } + } + $keys[] = 'rehearsal_room'; + } + } + if ($keys === []) { + return 0.0; + } + + $uniqueKeys = array_values(array_unique($keys)); + $placeholders = implode(',', array_fill(0, count($uniqueKeys), '?')); + $statement = $pdo->prepare("SELECT setting_key, price FROM pricing_settings WHERE setting_key IN ($placeholders)"); + $statement->execute($uniqueKeys); + $prices = []; + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + $prices[(string) $row['setting_key']] = (float) $row['price']; + } + + return round(array_sum(array_map( + static fn (string $key): float => $prices[$key] ?? 0.0, + $keys + )), 2); +} + +function sendRegistrationEmail( + array $mailConfig, + string $recipient, + string $name, + string $participantPassword, + string $guestType, + bool $under27, + ?string $roomType, + array $days, + array $mealsByDay, + string $allergies, + string $roommates, + string $otherNotes +): void { + $roomLabels = [ + 'single' => 'Einzelzimmer', + 'double' => 'Doppelzimmer', + 'multi' => 'Mehrbettzimmer', + ]; + $dayLabels = ['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.']; + $mealLabels = [ + 'breakfast' => 'Frühstück', + 'lunch' => 'Mittagessen', + 'coffee' => 'Kaffee', + 'drinks' => 'Getränkepauschale', + 'dinner' => 'Abendessen', + 'rehearsal_room' => 'Proberaumpauschale', + ]; + $guestLabel = $guestType === 'day_guest' ? 'Tagesgast' : 'Dauergast'; + $ageLabel = $under27 ? 'Unter 27' : 'Ab 27'; + $roomLabel = $roomLabels[$roomType ?? ''] ?? '–'; + $dayText = implode(', ', array_map(static fn (string $day): string => $dayLabels[$day] ?? $day, $days)) ?: '–'; + $mealText = formatMealSummary($days, $mealsByDay); + $roommates = $roommates !== '' ? $roommates : '–'; + $allergies = $allergies !== '' ? $allergies : '–'; + $otherNotes = $otherNotes !== '' ? $otherNotes : '–'; + + $textBody = "Hallo {$name},\n\n" + . "deine Registrierung für das Chorwochenende wurde erfolgreich gespeichert.\n\n" + . "Zugang zur Änderung oder Löschung:\n" + . "E-Mail-Adresse: {$recipient}\n" + . "Passwort: {$participantPassword}\n\n" + . "Zusammenfassung:\n" + . "Altersgruppe: {$ageLabel}\n" + . "Gasttyp: {$guestLabel}\n" + . "Zimmerart: {$roomLabel}\n" + . "Anwesenheitstage: {$dayText}\n" + . "Mahlzeiten: {$mealText}\n" + . "Zimmerpartner-Wunsch: {$roommates}\n" + . "Allergien / Essensvorlieben: {$allergies}\n" + . "Sonstiges: {$otherNotes}\n\n" + . "Viele Grüße\n{$mailConfig['from_name']}"; + + $html = static fn (string $value): string => escapeHtml($value); + $htmlBody = '' + . '

Hallo ' . $html($name) . ',

' + . '

deine Registrierung für das Chorwochenende wurde erfolgreich gespeichert.

' + . '

Deine Zugangsdaten

E-Mail-Adresse: ' . $html($recipient) + . '
Passwort: ' . $html($participantPassword) . '

' + . '

Zusammenfassung

    ' + . '
  • Altersgruppe: ' . $html($ageLabel) . '
  • ' + . '
  • Gasttyp: ' . $html($guestLabel) . '
  • ' + . '
  • Zimmerart: ' . $html($roomLabel) . '
  • ' + . '
  • Anwesenheitstage: ' . $html($dayText) . '
  • ' + . '
  • Mahlzeiten: ' . $html($mealText) . '
  • ' + . '
  • Zimmerpartner-Wunsch: ' . $html($roommates) . '
  • ' + . '
  • Allergien / Essensvorlieben: ' . $html($allergies) . '
  • ' + . '
  • Sonstiges: ' . $html($otherNotes) . '
' + . '

Viele Grüße
' . $html($mailConfig['from_name']) . '

'; + + $boundary = '=_cat_' . bin2hex(random_bytes(12)); + $message = 'From: ' . encodedHeader($mailConfig['from_name']) . ' <' . $mailConfig['from_address'] . ">\r\n" + . 'To: ' . $recipient . "\r\n" + . 'Subject: ' . encodedHeader('Deine Registrierung für das Chorwochenende') . "\r\n" + . "MIME-Version: 1.0\r\n" + . 'Content-Type: multipart/alternative; boundary="' . $boundary . "\"\r\n\r\n" + . '--' . $boundary . "\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n" + . $textBody . "\r\n\r\n" + . '--' . $boundary . "\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n" + . $htmlBody . "\r\n\r\n" + . '--' . $boundary . "--\r\n"; + + $context = stream_context_create([ + 'ssl' => [ + 'peer_name' => $mailConfig['host'], + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + $socket = stream_socket_client( + 'tcp://' . $mailConfig['host'] . ':' . $mailConfig['port'], + $errorNumber, + $errorMessage, + 20, + STREAM_CLIENT_CONNECT, + $context + ); + if ($socket === false) { + throw new RuntimeException('SMTP-Server nicht erreichbar.'); + } + stream_set_timeout($socket, 20); + + try { + $response = smtpResponse($socket); + if ((int) substr($response, 0, 3) !== 220) { + throw new RuntimeException('SMTP-Verbindung wurde abgelehnt.'); + } + smtpCommand($socket, 'EHLO localhost', [250]); + smtpCommand($socket, 'STARTTLS', [220]); + if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new RuntimeException('STARTTLS konnte nicht aktiviert werden.'); + } + smtpCommand($socket, 'EHLO localhost', [250]); + smtpCommand($socket, 'AUTH LOGIN', [334]); + smtpCommand($socket, base64_encode($mailConfig['username']), [334]); + smtpCommand($socket, base64_encode($mailConfig['password']), [235]); + smtpCommand($socket, 'MAIL FROM:<' . $mailConfig['from_address'] . '>', [250]); + smtpCommand($socket, 'RCPT TO:<' . $recipient . '>', [250, 251]); + smtpCommand($socket, 'DATA', [354]); + $message = preg_replace('/^\./m', '..', $message); + fwrite($socket, $message . "\r\n.\r\n"); + $response = smtpResponse($socket); + if ((int) substr($response, 0, 3) !== 250) { + throw new RuntimeException('E-Mail konnte nicht angenommen werden.'); + } + fwrite($socket, "QUIT\r\n"); + } finally { + fclose($socket); + } +} + +function sendAdminNotificationEmail( + array $mailConfig, + string $recipient, + string $name, + string $participantEmail, + string $guestLabel, + string $ageLabel, + string $roomLabel, + string $dayText, + string $mealText, + string $roommates, + string $allergies, + string $otherNotes +): void { + $textBody = "Neue Registrierung für das Chorwochenende\n\n" + . "Name: {$name}\n" + . "E-Mail: {$participantEmail}\n" + . "Altersgruppe: {$ageLabel}\n" + . "Gasttyp: {$guestLabel}\n" + . "Zimmerart: {$roomLabel}\n" + . "Anwesenheitstage: {$dayText}\n" + . "Mahlzeiten: {$mealText}\n" + . "Zimmerpartner-Wunsch: {$roommates}\n" + . "Allergien / Essensvorlieben: {$allergies}\n" + . "Sonstiges: {$otherNotes}\n"; + $subject = encodedHeader('Neue Registrierung – Chorwochenende'); + $message = 'From: ' . encodedHeader($mailConfig['from_name']) . ' <' . $mailConfig['from_address'] . ">\r\n" + . 'To: ' . $recipient . "\r\n" + . 'Subject: ' . $subject . "\r\n" + . "MIME-Version: 1.0\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "Content-Transfer-Encoding: 8bit\r\n\r\n" + . $textBody; + + $context = stream_context_create([ + 'ssl' => [ + 'peer_name' => $mailConfig['host'], + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + $socket = stream_socket_client( + 'tcp://' . $mailConfig['host'] . ':' . $mailConfig['port'], + $errorNumber, + $errorMessage, + 20, + STREAM_CLIENT_CONNECT, + $context + ); + if ($socket === false) { + throw new RuntimeException('SMTP-Server nicht erreichbar.'); + } + stream_set_timeout($socket, 20); + + try { + $response = smtpResponse($socket); + if ((int) substr($response, 0, 3) !== 220) { + throw new RuntimeException('SMTP-Verbindung wurde abgelehnt.'); + } + smtpCommand($socket, 'EHLO localhost', [250]); + smtpCommand($socket, 'STARTTLS', [220]); + if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new RuntimeException('STARTTLS konnte nicht aktiviert werden.'); + } + smtpCommand($socket, 'EHLO localhost', [250]); + smtpCommand($socket, 'AUTH LOGIN', [334]); + smtpCommand($socket, base64_encode($mailConfig['username']), [334]); + smtpCommand($socket, base64_encode($mailConfig['password']), [235]); + smtpCommand($socket, 'MAIL FROM:<' . $mailConfig['from_address'] . '>', [250]); + smtpCommand($socket, 'RCPT TO:<' . $recipient . '>', [250, 251]); + smtpCommand($socket, 'DATA', [354]); + fwrite($socket, preg_replace('/^\./m', '..', $message) . "\r\n.\r\n"); + $response = smtpResponse($socket); + if ((int) substr($response, 0, 3) !== 250) { + throw new RuntimeException('E-Mail konnte nicht angenommen werden.'); + } + fwrite($socket, "QUIT\r\n"); + } finally { + fclose($socket); + } +} + +$name = postString('name'); +$email = postString('email'); +$under27 = isset($_POST['under_27']) && $_POST['under_27'] === '1'; +$guestType = postString('guest_type'); +$roomType = postString('room_type'); +$roommates = postString('roommates'); +$earlyDeparture = postString('early_departure'); +$allergies = postString('allergies'); +$otherNotes = postString('other_notes'); +$days = postStringList('days'); +$mealsByDay = postMealSelectionsByDay(); + +$errors = []; +$allowedDays = ['1', '2', '3']; +$allowedEarlyDepartures = [ + 'sat_breakfast', + 'sat_lunch', + 'sat_coffee', + 'sat_dinner', + 'sun_breakfast', +]; +$allowedMealsByDay = [ + '1' => ['dinner'], + '2' => ['breakfast', 'lunch', 'coffee', 'dinner'], + '3' => ['breakfast', 'lunch'], +]; +$days = array_values(array_unique($days)); +foreach ($mealsByDay as $day => $dayMeals) { + $mealsByDay[$day] = array_values(array_unique($dayMeals)); +} + +if (count($days) > count($allowedDays) || array_diff($days, $allowedDays) !== []) { + $errors[] = 'Bitte nur gültige Anwesenheitstage auswählen.'; +} +if (array_diff(array_keys($mealsByDay), $allowedDays) !== []) { + $errors[] = 'Bitte nur gültige Mahlzeitentage auswählen.'; +} +foreach ($mealsByDay as $day => $dayMeals) { + $allowedMeals = $allowedMealsByDay[(string) $day] ?? []; + if (array_diff($dayMeals, $allowedMeals) !== []) { + $errors[] = 'Bitte nur gültige Mahlzeiten auswählen.'; + } + if (!in_array((string) $day, $days, true) && $dayMeals !== []) { + $errors[] = 'Mahlzeiten dürfen nur für ausgewählte Anwesenheitstage angegeben werden.'; + } +} +foreach ($days as $day) { + $mealsByDay[$day] ??= []; +} +if (mb_strlen($roommates) > 1000 || mb_strlen($allergies) > 1000) { + $errors[] = 'Zimmerwunsch und Allergieangaben dürfen höchstens 1000 Zeichen enthalten.'; +} + +if ($name === '' || mb_strlen($name) > 255) { + $errors[] = 'Bitte einen gültigen Namen eingeben.'; +} + +if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 255) { + $errors[] = 'Bitte eine gültige E-Mail-Adresse eingeben.'; +} + +if ($otherNotes !== '' && mb_strlen($otherNotes) > 5000) { + $errors[] = 'Das Feld Sonstiges darf höchstens 5000 Zeichen enthalten.'; +} + +if (!in_array($guestType, ['day_guest', 'regular_guest'], true)) { + $errors[] = 'Bitte einen gültigen Gasttyp auswählen.'; +} + +if ($guestType === 'regular_guest' && !in_array($roomType, ['single', 'double', 'multi'], true)) { + $errors[] = 'Bitte eine gültige Zimmerart auswählen.'; +} +if ($earlyDeparture !== '' && ($guestType !== 'regular_guest' || !in_array($earlyDeparture, $allowedEarlyDepartures, true))) { + $errors[] = 'Bitte einen gültigen Abreisezeitpunkt auswählen.'; +} + +if ($errors !== []) { + http_response_code(422); + exit(implode("\n", $errors)); +} + +$roommateEligible = $guestType === 'regular_guest' && in_array($roomType, ['double', 'multi'], true); +if (!$roommateEligible) { + $roommates = ''; +} + +$role = $guestType === 'day_guest' ? 'Tagesgast' : 'Dauergast'; +$bookingType = $guestType === 'day_guest' ? 'Day' : 'Stay'; +$accommodationType = $guestType === 'regular_guest' ? $roomType : null; +$roommateRequest = $roommateEligible && $roommates !== '' ? $roommates : null; +$earlyDeparture = $guestType === 'regular_guest' && in_array($earlyDeparture, $allowedEarlyDepartures, true) + ? $earlyDeparture + : null; + +$mealPreferences = null; +if ($guestType === 'day_guest') { + $mealPreferences = json_encode( + [ + 'days' => $days, + 'meals_by_day' => $mealsByDay, + ], + JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR + ); +} + +$participantId = sprintf( + '%s-%s-%s-%s-%s', + bin2hex(random_bytes(4)), + bin2hex(random_bytes(2)), + bin2hex(random_bytes(2)), + bin2hex(random_bytes(2)), + bin2hex(random_bytes(6)) +); +$participantPassword = bin2hex(random_bytes(8)); +$participantPasswordHash = password_hash($participantPassword, PASSWORD_DEFAULT); + +try { + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $dbConfig['host'], + $dbConfig['port'], + $dbConfig['database'], + $dbConfig['charset'] + ); + + $pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + $totalPrice = calculateBookingTotal($pdo, $under27, $guestType, $accommodationType, $mealsByDay); + + $pdo->beginTransaction(); + + $participantStatement = $pdo->prepare( + 'INSERT INTO participants (id, name, email, allergies, role, under_27, password_hash) + VALUES (:id, :name, :email, :allergies, :role, :under_27, :password_hash)' + ); + $participantStatement->execute([ + ':id' => $participantId, + ':name' => $name, + ':email' => $email, + ':allergies' => $allergies !== '' ? $allergies : null, + ':role' => $role, + ':under_27' => $under27 ? 1 : 0, + ':password_hash' => $participantPasswordHash, + ]); + + $bookingStatement = $pdo->prepare( + 'INSERT INTO bookings + (participant_id, type, accommodation_type, meal_preferences, roommate_requests, early_departure, other_notes, total_price) + VALUES + (:participant_id, :type, :accommodation_type, :meal_preferences, :roommate_requests, :early_departure, :other_notes, :total_price)' + ); + $bookingStatement->execute([ + ':participant_id' => $participantId, + ':type' => $bookingType, + ':accommodation_type' => $accommodationType, + ':meal_preferences' => $mealPreferences, + ':roommate_requests' => $roommateRequest, + ':early_departure' => $earlyDeparture, + ':other_notes' => $otherNotes !== '' ? $otherNotes : null, + ':total_price' => number_format($totalPrice, 2, '.', ''), + ]); + + $pdo->commit(); + + $mailError = ''; + try { + sendRegistrationEmail( + $mailConfig, + $email, + $name, + $participantPassword, + $guestType, + $under27, + $roomType, + $days, + $mealsByDay, + $allergies, + $roommates, + $otherNotes + ); + } catch (Throwable $exception) { + error_log('Registrierung gespeichert, E-Mail konnte nicht versendet werden: ' . $exception->getMessage()); + $mailError = '

Die Registrierung wurde gespeichert, aber die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte bewahre die Zugangsdaten auf dieser Seite auf.

'; + } + + $roomLabels = ['single' => 'Einzelzimmer', 'double' => 'Doppelzimmer', 'multi' => 'Mehrbettzimmer']; + $dayLabels = ['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.']; + $mealLabels = [ + 'breakfast' => 'Frühstück', + 'lunch' => 'Mittagessen', + 'coffee' => 'Kaffee', + 'drinks' => 'Getränkepauschale', + 'dinner' => 'Abendessen', + 'rehearsal_room' => 'Proberaumpauschale', + ]; + $guestLabel = $guestType === 'day_guest' ? 'Tagesgast' : 'Dauergast'; + $ageLabel = $under27 ? 'Unter 27' : 'Ab 27'; + $roomLabel = $roomLabels[$roomType] ?? '–'; + $dayText = implode(', ', array_map(static fn (string $day): string => $dayLabels[$day] ?? $day, $days)) ?: '–'; + $mealText = formatMealSummary($days, $mealsByDay); + $adminEmails = $pdo->query( + "SELECT email FROM admin_users WHERE is_active = 1 AND email <> ''" + )->fetchAll(PDO::FETCH_COLUMN); + foreach ($adminEmails as $adminEmail) { + if (!is_string($adminEmail) || !filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) { + continue; + } + try { + sendAdminNotificationEmail( + $mailConfig, + (string) $adminEmail, + $name, + $email, + $guestLabel, + $ageLabel, + $roomLabel, + $dayText, + $mealText, + $roommates !== '' ? $roommates : '–', + $allergies !== '' ? $allergies : '–', + $otherNotes !== '' ? $otherNotes : '–' + ); + } catch (Throwable $exception) { + error_log('Admin-Benachrichtigung konnte nicht versendet werden: ' . $exception->getMessage()); + } + } + + http_response_code(201); + $safeEmail = escapeHtml($email); + $safePassword = escapeHtml($participantPassword); + exit(<< + + + + + Registrierung erfolgreich + + + +
+

Registrierung erfolgreich

+ {$mailError} +

Bitte bewahre die folgenden Zugangsdaten auf. Du benötigst sie, um deine Registrierung später zu ändern oder zu löschen.

+
+
E-Mail-Adresse
+
{$safeEmail}
+
Passwort
+
{$safePassword}
+
+

Registrierung ändern oder löschen

+

Zur Startseite

+
+ + +HTML); +} catch (Throwable $exception) { + if (isset($pdo) && $pdo->inTransaction()) { + $pdo->rollBack(); + } + + error_log($exception->getMessage()); + if ($exception instanceof PDOException && $exception->getCode() === '23000' + && str_contains($exception->getMessage(), "for key 'email'")) { + http_response_code(409); + exit('Diese E-Mail-Adresse ist bereits registriert. Bitte nutze die Änderungs- oder Löschfunktion deiner bestehenden Registrierung.'); + } + + http_response_code(500); + exit('Die Registrierung konnte nicht gespeichert werden.'); +} diff --git a/roommate_suggestions.php b/roommate_suggestions.php new file mode 100644 index 0000000..62aedbb --- /dev/null +++ b/roommate_suggestions.php @@ -0,0 +1,65 @@ + true, + 'samesite' => 'Strict', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'use_strict_mode' => true, +]); +session_start(); +header('Content-Type: application/json; charset=UTF-8'); +header('Cache-Control: no-store'); +header('X-Content-Type-Options: nosniff'); +header('X-Frame-Options: DENY'); +header('Referrer-Policy: no-referrer'); +header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); +header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'"); + +require_once __DIR__ . '/inc/db.inc.php'; + +$query = trim((string) ($_GET['q'] ?? '')); +$roomType = trim((string) ($_GET['room_type'] ?? '')); + +if (!in_array($roomType, ['double', 'multi'], true) || mb_strlen($query) < 2) { + echo json_encode([], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + exit; +} + +try { + $pdo = new PDO( + sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $dbConfig['host'], + $dbConfig['port'], + $dbConfig['database'], + $dbConfig['charset'] + ), + $dbConfig['username'], + $dbConfig['password'], + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_COLUMN, + ] + ); + + $statement = $pdo->prepare( + 'SELECT DISTINCT p.name + FROM participants p + INNER JOIN bookings b ON b.participant_id = p.id + WHERE p.name LIKE :query + ORDER BY p.name + LIMIT 10' + ); + $statement->execute([ + ':query' => '%' . $query . '%', + ]); + + echo json_encode($statement->fetchAll(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); +} catch (Throwable $exception) { + error_log('Zimmernachbar-Suche fehlgeschlagen: ' . $exception->getMessage()); + http_response_code(500); + echo json_encode(['error' => 'Vorschläge konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE); +} diff --git a/scripts/booking.js b/scripts/booking.js new file mode 100644 index 0000000..af6d351 --- /dev/null +++ b/scripts/booking.js @@ -0,0 +1,103 @@ +let roommateSearchController = null; + +function updateRoommateSuggestions() { + const guestTypeElement = document.getElementById('guest_type'); + const roomTypeElement = document.getElementById('room_type'); + const roommateInput = document.getElementById('roommates'); + const suggestionList = document.getElementById('roommate_suggestions'); + + if (!guestTypeElement || !roomTypeElement || !roommateInput || !suggestionList) { + return; + } + + const eligible = guestTypeElement.value === 'regular_guest' + && ['double', 'multi'].includes(roomTypeElement.value); + const query = roommateInput.value.trim(); + suggestionList.replaceChildren(); + + if (!eligible || query.length < 2) { + if (roommateSearchController) { + roommateSearchController.abort(); + roommateSearchController = null; + } + return; + } + + if (roommateSearchController) { + roommateSearchController.abort(); + } + roommateSearchController = new AbortController(); + + const params = new URLSearchParams({ + q: query, + room_type: roomTypeElement.value, + }); + fetch(`./roommate_suggestions.php?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: roommateSearchController.signal, + }) + .then((response) => response.ok ? response.json() : []) + .then((names) => { + if (!Array.isArray(names)) { + return; + } + names.forEach((name) => { + if (typeof name !== 'string') { + return; + } + const option = document.createElement('option'); + option.value = name; + suggestionList.appendChild(option); + }); + }) + .catch((error) => { + if (error.name !== 'AbortError') { + console.warn('Zimmernachbar-Vorschläge konnten nicht geladen werden.'); + } + }); +} + +function updateRoomOptions() { + const guestTypeElement = document.getElementById('guest_type'); + const roomTypeElement = document.getElementById('room_type'); + const regularOptions = document.getElementById('regular_guest_options'); + const dayOptions = document.getElementById('day_guest_options'); + const roommateInfo = document.getElementById('roommate_info'); + const roommateInput = document.getElementById('roommates'); + + const guestType = guestTypeElement ? guestTypeElement.value : ''; + const roomType = roomTypeElement ? roomTypeElement.value : ''; + const roommateEligible = guestType === 'regular_guest' && ['double', 'multi'].includes(roomType); + + if (regularOptions) { + regularOptions.style.display = guestType === 'regular_guest' ? 'block' : 'none'; + } + if (dayOptions) { + dayOptions.style.display = guestType === 'day_guest' ? 'block' : 'none'; + } + if (roommateInfo) { + roommateInfo.style.display = roommateEligible ? 'block' : 'none'; + } + if (roommateInput) { + roommateInput.disabled = !roommateEligible; + } + updateRoommateSuggestions(); +} + +document.addEventListener('DOMContentLoaded', function () { + const guestTypeElement = document.getElementById('guest_type'); + const roomTypeElement = document.getElementById('room_type'); + const roommateInput = document.getElementById('roommates'); + + if (guestTypeElement) { + guestTypeElement.addEventListener('change', updateRoomOptions); + } + if (roomTypeElement) { + roomTypeElement.addEventListener('change', updateRoomOptions); + } + if (roommateInput) { + roommateInput.addEventListener('input', updateRoommateSuggestions); + } + updateRoomOptions(); +}); diff --git a/scripts/functions.js b/scripts/functions.js new file mode 100644 index 0000000..2569185 --- /dev/null +++ b/scripts/functions.js @@ -0,0 +1,126 @@ +// Gemeinsame JavaScript-Funktionen für das Projekt "cat". + +let roommateSearchController = null; + +function updateRoommateSuggestions() { + const guestTypeElement = document.getElementById('guest_type'); + const roomTypeElement = document.getElementById('room_type'); + const roommateInput = document.getElementById('roommates'); + const suggestionList = document.getElementById('roommate_suggestions'); + + if (!guestTypeElement || !roomTypeElement || !roommateInput || !suggestionList) { + return; + } + + const eligible = guestTypeElement.value === 'regular_guest' + && ['double', 'multi'].includes(roomTypeElement.value); + const query = roommateInput.value.trim(); + suggestionList.replaceChildren(); + + if (!eligible || query.length < 2) { + if (roommateSearchController) { + roommateSearchController.abort(); + roommateSearchController = null; + } + return; + } + + if (roommateSearchController) { + roommateSearchController.abort(); + } + roommateSearchController = new AbortController(); + + const params = new URLSearchParams({ + q: query, + room_type: roomTypeElement.value, + }); + fetch(`./roommate_suggestions.php?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: roommateSearchController.signal, + }) + .then((response) => response.ok ? response.json() : []) + .then((names) => { + if (!Array.isArray(names)) { + return; + } + names.forEach((name) => { + if (typeof name !== 'string') { + return; + } + const option = document.createElement('option'); + option.value = name; + suggestionList.appendChild(option); + }); + }) + .catch((error) => { + if (error.name !== 'AbortError') { + console.warn('Zimmernachbar-Vorschläge konnten nicht geladen werden.'); + } + }); +} + +function updateRoomOptions() { + const guestTypeElement = document.getElementById('guest_type'); + const roomTypeElement = document.getElementById('room_type'); + const regularOptions = document.getElementById('regular_guest_options'); + const dayOptions = document.getElementById('day_guest_options'); + const roommateInfo = document.getElementById('roommate_info'); + const roommateInput = document.getElementById('roommates'); + const earlyDepartureEnabled = document.getElementById('early_departure_enabled'); + const earlyDepartureOptions = document.getElementById('early_departure_options'); + const earlyDepartureInputs = document.querySelectorAll('input[name="early_departure"]'); + + const guestType = guestTypeElement ? guestTypeElement.value : ''; + const roomType = roomTypeElement ? roomTypeElement.value : ''; + const roommateEligible = guestType === 'regular_guest' && ['double', 'multi'].includes(roomType); + + if (regularOptions) { + regularOptions.style.display = guestType === 'regular_guest' ? 'block' : 'none'; + } + if (dayOptions) { + dayOptions.style.display = guestType === 'day_guest' ? 'block' : 'none'; + } + if (roommateInfo) { + roommateInfo.style.display = roommateEligible ? 'block' : 'none'; + } + if (roommateInput) { + roommateInput.disabled = !roommateEligible; + } + const earlyDepartureVisible = guestType === 'regular_guest' + && Boolean(earlyDepartureEnabled && earlyDepartureEnabled.checked); + if (earlyDepartureEnabled) { + earlyDepartureEnabled.disabled = guestType !== 'regular_guest'; + if (guestType !== 'regular_guest') { + earlyDepartureEnabled.checked = false; + } + } + if (earlyDepartureOptions) { + earlyDepartureOptions.style.display = earlyDepartureVisible ? 'block' : 'none'; + } + earlyDepartureInputs.forEach((input) => { + input.disabled = !earlyDepartureVisible; + }); + updateRoommateSuggestions(); +} + +document.addEventListener('DOMContentLoaded', function () { + const guestTypeElement = document.getElementById('guest_type'); + const roomTypeElement = document.getElementById('room_type'); + const roommateInput = document.getElementById('roommates'); + + if (guestTypeElement) { + guestTypeElement.addEventListener('change', updateRoomOptions); + } + if (roomTypeElement) { + roomTypeElement.addEventListener('change', updateRoomOptions); + } + if (roommateInput) { + roommateInput.addEventListener('input', updateRoommateSuggestions); + } + const earlyDepartureEnabled = document.getElementById('early_departure_enabled'); + if (earlyDepartureEnabled) { + earlyDepartureEnabled.addEventListener('change', updateRoomOptions); + } + updateRoomOptions(); +}); diff --git a/scripts/index.html b/scripts/index.html new file mode 100644 index 0000000..3ff3135 --- /dev/null +++ b/scripts/index.html @@ -0,0 +1,2 @@ + +Not found diff --git a/scripts/security.js b/scripts/security.js new file mode 100644 index 0000000..e61b64e --- /dev/null +++ b/scripts/security.js @@ -0,0 +1,30 @@ +function syncToggle(toggle) { + const targetId = toggle.dataset.toggleTarget; + const content = document.querySelector(`[data-toggle-content="${targetId}"]`); + const input = document.getElementById(targetId); + if (!content || !input) { + return; + } + content.hidden = !toggle.checked; + input.disabled = !toggle.checked; +} + +document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('[data-toggle-target]').forEach(syncToggle); +}); + +document.addEventListener('change', function (event) { + if (event.target.matches('[data-toggle-target]')) { + syncToggle(event.target); + } + if (event.target.matches('[data-submit-on-change="true"]')) { + event.target.form.submit(); + } +}); + +document.addEventListener('submit', function (event) { + const message = event.target.dataset.confirm; + if (message && !window.confirm(message)) { + event.preventDefault(); + } +});