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
Name E-Mail Altersgruppe Bezahlt Preis Typ Zimmer Zimmerpartner-Wunsch Frühzeitige Abreise Mahlzeiten / Tage Allergien Sonstiges Aktion
> >
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