commit 6684062400f7d2288992785ef56ec3c6bd5d3d72 Author: RaineSchroeder Date: Sat Sep 19 23:27:40 2026 +0000 Initial CAT application and deployment update 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 0000000..6de3aef Binary files /dev/null and b/cat_update_2026-09-19.zip differ 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

' + . '

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(); + } +});