true,
'samesite' => 'Strict',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'use_strict_mode' => true,
]);
session_start();
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 1048576) {
http_response_code(413);
exit('Anfrage zu groß.');
}
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: no-referrer');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
require_once __DIR__ . '/inc/db.inc.php';
require_once __DIR__ . '/inc/mail.inc.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit('Methode nicht erlaubt.');
}
$submittedCsrf = $_POST['csrf_token'] ?? '';
$storedCsrf = $_SESSION['registration_csrf'] ?? '';
if (!is_string($submittedCsrf) || $storedCsrf === '' || !hash_equals($storedCsrf, $submittedCsrf)) {
http_response_code(403);
exit('Ungültige Anfrage.');
}
unset($_SESSION['registration_csrf']);
function postString(string $key): string
{
$value = $_POST[$key] ?? '';
return is_string($value) ? trim($value) : '';
}
function postStringList(string $key): array
{
$value = $_POST[$key] ?? [];
$values = is_array($value) ? $value : [$value];
return array_values(array_filter(
array_map(
static fn ($item): string => is_string($item) ? trim($item) : '',
$values
),
static fn (string $item): bool => $item !== ''
));
}
function postMealSelectionsByDay(): array
{
$value = $_POST['meals'] ?? [];
if (!is_array($value)) {
return [];
}
$result = [];
foreach ($value as $day => $meals) {
if (!is_array($meals)) {
continue;
}
$result[(string) $day] = array_values(array_filter(
array_map(static fn ($item): string => is_string($item) ? trim($item) : '', $meals),
static fn (string $item): bool => $item !== ''
));
}
return $result;
}
function formatMealSummary(array $days, array $mealsByDay): string
{
$dayLabels = ['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.'];
$mealLabels = [
'breakfast' => 'Frühstück',
'lunch' => 'Mittagessen',
'coffee' => 'Kaffee',
'dinner' => 'Abendessen',
'drinks' => 'Getränkepauschale',
];
$parts = [];
foreach ($days as $day) {
$labels = array_map(static fn (string $meal): string => $mealLabels[$meal] ?? $meal, $mealsByDay[$day] ?? []);
$labels[] = $mealLabels['drinks'];
$labels[] = $mealLabels['rehearsal_room'];
$parts[] = ($dayLabels[$day] ?? $day) . ': ' . implode(', ', $labels);
}
return implode('; ', $parts) ?: '–';
}
function escapeHtml(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function smtpResponse($socket): string
{
$response = '';
do {
$line = fgets($socket);
if ($line === false) {
throw new RuntimeException('Keine gültige SMTP-Antwort erhalten.');
}
$response .= $line;
} while (isset($line[3]) && $line[3] === '-');
return $response;
}
function smtpCommand($socket, string $command, array $expectedCodes): void
{
fwrite($socket, $command . "\r\n");
$response = smtpResponse($socket);
$code = (int) substr($response, 0, 3);
if (!in_array($code, $expectedCodes, true)) {
throw new RuntimeException('SMTP-Befehl wurde abgelehnt.');
}
}
function encodedHeader(string $value): string
{
return '=?UTF-8?B?' . base64_encode($value) . '?=';
}
function calculateBookingTotal(PDO $pdo, bool $under27, string $guestType, ?string $roomType, array $mealsByDay): float
{
$ageSuffix = $under27 ? 'under_27' : 'over_27';
$keys = [];
if ($guestType === 'regular_guest' && $roomType !== null) {
$keys[] = $roomType . '_' . $ageSuffix;
}
if ($guestType === 'day_guest') {
foreach ($mealsByDay as $meals) {
foreach (array_unique(array_merge($meals, ['drinks'])) as $meal) {
if (in_array($meal, ['breakfast', 'lunch', 'coffee', 'dinner'], true)) {
$keys[] = $meal . '_' . $ageSuffix;
} elseif ($meal === 'drinks') {
$keys[] = 'drinks';
}
}
$keys[] = 'rehearsal_room';
}
}
if ($keys === []) {
return 0.0;
}
$uniqueKeys = array_values(array_unique($keys));
$placeholders = implode(',', array_fill(0, count($uniqueKeys), '?'));
$statement = $pdo->prepare("SELECT setting_key, price FROM pricing_settings WHERE setting_key IN ($placeholders)");
$statement->execute($uniqueKeys);
$prices = [];
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
$prices[(string) $row['setting_key']] = (float) $row['price'];
}
return round(array_sum(array_map(
static fn (string $key): float => $prices[$key] ?? 0.0,
$keys
)), 2);
}
function sendRegistrationEmail(
array $mailConfig,
string $recipient,
string $name,
string $participantPassword,
string $guestType,
bool $under27,
?string $roomType,
array $days,
array $mealsByDay,
string $allergies,
string $roommates,
string $otherNotes
): void {
$roomLabels = [
'single' => 'Einzelzimmer',
'double' => 'Doppelzimmer',
'multi' => 'Mehrbettzimmer',
];
$dayLabels = ['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.'];
$mealLabels = [
'breakfast' => 'Frühstück',
'lunch' => 'Mittagessen',
'coffee' => 'Kaffee',
'drinks' => 'Getränkepauschale',
'dinner' => 'Abendessen',
'rehearsal_room' => 'Proberaumpauschale',
];
$guestLabel = $guestType === 'day_guest' ? 'Tagesgast' : 'Dauergast';
$ageLabel = $under27 ? 'Unter 27' : 'Ab 27';
$roomLabel = $roomLabels[$roomType ?? ''] ?? '–';
$dayText = implode(', ', array_map(static fn (string $day): string => $dayLabels[$day] ?? $day, $days)) ?: '–';
$mealText = formatMealSummary($days, $mealsByDay);
$roommates = $roommates !== '' ? $roommates : '–';
$allergies = $allergies !== '' ? $allergies : '–';
$otherNotes = $otherNotes !== '' ? $otherNotes : '–';
$textBody = "Hallo {$name},\n\n"
. "deine Registrierung für das Chorwochenende wurde erfolgreich gespeichert.\n\n"
. "Zugang zur Änderung oder Löschung:\n"
. "E-Mail-Adresse: {$recipient}\n"
. "Passwort: {$participantPassword}\n\n"
. "Zusammenfassung:\n"
. "Altersgruppe: {$ageLabel}\n"
. "Gasttyp: {$guestLabel}\n"
. "Zimmerart: {$roomLabel}\n"
. "Anwesenheitstage: {$dayText}\n"
. "Mahlzeiten: {$mealText}\n"
. "Zimmerpartner-Wunsch: {$roommates}\n"
. "Allergien / Essensvorlieben: {$allergies}\n"
. "Sonstiges: {$otherNotes}\n\n"
. "Viele Grüße\n{$mailConfig['from_name']}";
$html = static fn (string $value): string => escapeHtml($value);
$htmlBody = '
'
. 'Hallo ' . $html($name) . ',
'
. 'deine Registrierung für das Chorwochenende wurde erfolgreich gespeichert.
'
. 'Deine Zugangsdaten
E-Mail-Adresse: ' . $html($recipient)
. '
Passwort: ' . $html($participantPassword) . '
'
. 'Zusammenfassung
'
. '- Altersgruppe: ' . $html($ageLabel) . '
'
. '- Gasttyp: ' . $html($guestLabel) . '
'
. '- Zimmerart: ' . $html($roomLabel) . '
'
. '- Anwesenheitstage: ' . $html($dayText) . '
'
. '- Mahlzeiten: ' . $html($mealText) . '
'
. '- Zimmerpartner-Wunsch: ' . $html($roommates) . '
'
. '- Allergien / Essensvorlieben: ' . $html($allergies) . '
'
. '- Sonstiges: ' . $html($otherNotes) . '
'
. 'Viele Grüße
' . $html($mailConfig['from_name']) . '
';
$boundary = '=_cat_' . bin2hex(random_bytes(12));
$message = 'From: ' . encodedHeader($mailConfig['from_name']) . ' <' . $mailConfig['from_address'] . ">\r\n"
. 'To: ' . $recipient . "\r\n"
. 'Subject: ' . encodedHeader('Deine Registrierung für das Chorwochenende') . "\r\n"
. "MIME-Version: 1.0\r\n"
. 'Content-Type: multipart/alternative; boundary="' . $boundary . "\"\r\n\r\n"
. '--' . $boundary . "\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n"
. $textBody . "\r\n\r\n"
. '--' . $boundary . "\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n"
. $htmlBody . "\r\n\r\n"
. '--' . $boundary . "--\r\n";
$context = stream_context_create([
'ssl' => [
'peer_name' => $mailConfig['host'],
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$socket = stream_socket_client(
'tcp://' . $mailConfig['host'] . ':' . $mailConfig['port'],
$errorNumber,
$errorMessage,
20,
STREAM_CLIENT_CONNECT,
$context
);
if ($socket === false) {
throw new RuntimeException('SMTP-Server nicht erreichbar.');
}
stream_set_timeout($socket, 20);
try {
$response = smtpResponse($socket);
if ((int) substr($response, 0, 3) !== 220) {
throw new RuntimeException('SMTP-Verbindung wurde abgelehnt.');
}
smtpCommand($socket, 'EHLO localhost', [250]);
smtpCommand($socket, 'STARTTLS', [220]);
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new RuntimeException('STARTTLS konnte nicht aktiviert werden.');
}
smtpCommand($socket, 'EHLO localhost', [250]);
smtpCommand($socket, 'AUTH LOGIN', [334]);
smtpCommand($socket, base64_encode($mailConfig['username']), [334]);
smtpCommand($socket, base64_encode($mailConfig['password']), [235]);
smtpCommand($socket, 'MAIL FROM:<' . $mailConfig['from_address'] . '>', [250]);
smtpCommand($socket, 'RCPT TO:<' . $recipient . '>', [250, 251]);
smtpCommand($socket, 'DATA', [354]);
$message = preg_replace('/^\./m', '..', $message);
fwrite($socket, $message . "\r\n.\r\n");
$response = smtpResponse($socket);
if ((int) substr($response, 0, 3) !== 250) {
throw new RuntimeException('E-Mail konnte nicht angenommen werden.');
}
fwrite($socket, "QUIT\r\n");
} finally {
fclose($socket);
}
}
function sendAdminNotificationEmail(
array $mailConfig,
string $recipient,
string $name,
string $participantEmail,
string $guestLabel,
string $ageLabel,
string $roomLabel,
string $dayText,
string $mealText,
string $roommates,
string $allergies,
string $otherNotes
): void {
$textBody = "Neue Registrierung für das Chorwochenende\n\n"
. "Name: {$name}\n"
. "E-Mail: {$participantEmail}\n"
. "Altersgruppe: {$ageLabel}\n"
. "Gasttyp: {$guestLabel}\n"
. "Zimmerart: {$roomLabel}\n"
. "Anwesenheitstage: {$dayText}\n"
. "Mahlzeiten: {$mealText}\n"
. "Zimmerpartner-Wunsch: {$roommates}\n"
. "Allergien / Essensvorlieben: {$allergies}\n"
. "Sonstiges: {$otherNotes}\n";
$subject = encodedHeader('Neue Registrierung – Chorwochenende');
$message = 'From: ' . encodedHeader($mailConfig['from_name']) . ' <' . $mailConfig['from_address'] . ">\r\n"
. 'To: ' . $recipient . "\r\n"
. 'Subject: ' . $subject . "\r\n"
. "MIME-Version: 1.0\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\n"
. "Content-Transfer-Encoding: 8bit\r\n\r\n"
. $textBody;
$context = stream_context_create([
'ssl' => [
'peer_name' => $mailConfig['host'],
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$socket = stream_socket_client(
'tcp://' . $mailConfig['host'] . ':' . $mailConfig['port'],
$errorNumber,
$errorMessage,
20,
STREAM_CLIENT_CONNECT,
$context
);
if ($socket === false) {
throw new RuntimeException('SMTP-Server nicht erreichbar.');
}
stream_set_timeout($socket, 20);
try {
$response = smtpResponse($socket);
if ((int) substr($response, 0, 3) !== 220) {
throw new RuntimeException('SMTP-Verbindung wurde abgelehnt.');
}
smtpCommand($socket, 'EHLO localhost', [250]);
smtpCommand($socket, 'STARTTLS', [220]);
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new RuntimeException('STARTTLS konnte nicht aktiviert werden.');
}
smtpCommand($socket, 'EHLO localhost', [250]);
smtpCommand($socket, 'AUTH LOGIN', [334]);
smtpCommand($socket, base64_encode($mailConfig['username']), [334]);
smtpCommand($socket, base64_encode($mailConfig['password']), [235]);
smtpCommand($socket, 'MAIL FROM:<' . $mailConfig['from_address'] . '>', [250]);
smtpCommand($socket, 'RCPT TO:<' . $recipient . '>', [250, 251]);
smtpCommand($socket, 'DATA', [354]);
fwrite($socket, preg_replace('/^\./m', '..', $message) . "\r\n.\r\n");
$response = smtpResponse($socket);
if ((int) substr($response, 0, 3) !== 250) {
throw new RuntimeException('E-Mail konnte nicht angenommen werden.');
}
fwrite($socket, "QUIT\r\n");
} finally {
fclose($socket);
}
}
$name = postString('name');
$email = postString('email');
$under27 = isset($_POST['under_27']) && $_POST['under_27'] === '1';
$guestType = postString('guest_type');
$roomType = postString('room_type');
$roommates = postString('roommates');
$earlyDeparture = postString('early_departure');
$allergies = postString('allergies');
$otherNotes = postString('other_notes');
$days = postStringList('days');
$mealsByDay = postMealSelectionsByDay();
$errors = [];
$allowedDays = ['1', '2', '3'];
$allowedEarlyDepartures = [
'sat_breakfast',
'sat_lunch',
'sat_coffee',
'sat_dinner',
'sun_breakfast',
];
$allowedMealsByDay = [
'1' => ['dinner'],
'2' => ['breakfast', 'lunch', 'coffee', 'dinner'],
'3' => ['breakfast', 'lunch'],
];
$days = array_values(array_unique($days));
foreach ($mealsByDay as $day => $dayMeals) {
$mealsByDay[$day] = array_values(array_unique($dayMeals));
}
if (count($days) > count($allowedDays) || array_diff($days, $allowedDays) !== []) {
$errors[] = 'Bitte nur gültige Anwesenheitstage auswählen.';
}
if (array_diff(array_keys($mealsByDay), $allowedDays) !== []) {
$errors[] = 'Bitte nur gültige Mahlzeitentage auswählen.';
}
foreach ($mealsByDay as $day => $dayMeals) {
$allowedMeals = $allowedMealsByDay[(string) $day] ?? [];
if (array_diff($dayMeals, $allowedMeals) !== []) {
$errors[] = 'Bitte nur gültige Mahlzeiten auswählen.';
}
if (!in_array((string) $day, $days, true) && $dayMeals !== []) {
$errors[] = 'Mahlzeiten dürfen nur für ausgewählte Anwesenheitstage angegeben werden.';
}
}
foreach ($days as $day) {
$mealsByDay[$day] ??= [];
}
if (mb_strlen($roommates) > 1000 || mb_strlen($allergies) > 1000) {
$errors[] = 'Zimmerwunsch und Allergieangaben dürfen höchstens 1000 Zeichen enthalten.';
}
if ($name === '' || mb_strlen($name) > 255) {
$errors[] = 'Bitte einen gültigen Namen eingeben.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 255) {
$errors[] = 'Bitte eine gültige E-Mail-Adresse eingeben.';
}
if ($otherNotes !== '' && mb_strlen($otherNotes) > 5000) {
$errors[] = 'Das Feld Sonstiges darf höchstens 5000 Zeichen enthalten.';
}
if (!in_array($guestType, ['day_guest', 'regular_guest'], true)) {
$errors[] = 'Bitte einen gültigen Gasttyp auswählen.';
}
if ($guestType === 'regular_guest' && !in_array($roomType, ['single', 'double', 'multi'], true)) {
$errors[] = 'Bitte eine gültige Zimmerart auswählen.';
}
if ($earlyDeparture !== '' && ($guestType !== 'regular_guest' || !in_array($earlyDeparture, $allowedEarlyDepartures, true))) {
$errors[] = 'Bitte einen gültigen Abreisezeitpunkt auswählen.';
}
if ($errors !== []) {
http_response_code(422);
exit(implode("\n", $errors));
}
$roommateEligible = $guestType === 'regular_guest' && in_array($roomType, ['double', 'multi'], true);
if (!$roommateEligible) {
$roommates = '';
}
$role = $guestType === 'day_guest' ? 'Tagesgast' : 'Dauergast';
$bookingType = $guestType === 'day_guest' ? 'Day' : 'Stay';
$accommodationType = $guestType === 'regular_guest' ? $roomType : null;
$roommateRequest = $roommateEligible && $roommates !== '' ? $roommates : null;
$earlyDeparture = $guestType === 'regular_guest' && in_array($earlyDeparture, $allowedEarlyDepartures, true)
? $earlyDeparture
: null;
$mealPreferences = null;
if ($guestType === 'day_guest') {
$mealPreferences = json_encode(
[
'days' => $days,
'meals_by_day' => $mealsByDay,
],
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
}
$participantId = sprintf(
'%s-%s-%s-%s-%s',
bin2hex(random_bytes(4)),
bin2hex(random_bytes(2)),
bin2hex(random_bytes(2)),
bin2hex(random_bytes(2)),
bin2hex(random_bytes(6))
);
$participantPassword = bin2hex(random_bytes(8));
$participantPasswordHash = password_hash($participantPassword, PASSWORD_DEFAULT);
try {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$dbConfig['host'],
$dbConfig['port'],
$dbConfig['database'],
$dbConfig['charset']
);
$pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$totalPrice = calculateBookingTotal($pdo, $under27, $guestType, $accommodationType, $mealsByDay);
$pdo->beginTransaction();
$participantStatement = $pdo->prepare(
'INSERT INTO participants (id, name, email, allergies, role, under_27, password_hash)
VALUES (:id, :name, :email, :allergies, :role, :under_27, :password_hash)'
);
$participantStatement->execute([
':id' => $participantId,
':name' => $name,
':email' => $email,
':allergies' => $allergies !== '' ? $allergies : null,
':role' => $role,
':under_27' => $under27 ? 1 : 0,
':password_hash' => $participantPasswordHash,
]);
$bookingStatement = $pdo->prepare(
'INSERT INTO bookings
(participant_id, type, accommodation_type, meal_preferences, roommate_requests, early_departure, other_notes, total_price)
VALUES
(:participant_id, :type, :accommodation_type, :meal_preferences, :roommate_requests, :early_departure, :other_notes, :total_price)'
);
$bookingStatement->execute([
':participant_id' => $participantId,
':type' => $bookingType,
':accommodation_type' => $accommodationType,
':meal_preferences' => $mealPreferences,
':roommate_requests' => $roommateRequest,
':early_departure' => $earlyDeparture,
':other_notes' => $otherNotes !== '' ? $otherNotes : null,
':total_price' => number_format($totalPrice, 2, '.', ''),
]);
$pdo->commit();
$mailError = '';
try {
sendRegistrationEmail(
$mailConfig,
$email,
$name,
$participantPassword,
$guestType,
$under27,
$roomType,
$days,
$mealsByDay,
$allergies,
$roommates,
$otherNotes
);
} catch (Throwable $exception) {
error_log('Registrierung gespeichert, E-Mail konnte nicht versendet werden: ' . $exception->getMessage());
$mailError = 'Die Registrierung wurde gespeichert, aber die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte bewahre die Zugangsdaten auf dieser Seite auf.
';
}
$roomLabels = ['single' => 'Einzelzimmer', 'double' => 'Doppelzimmer', 'multi' => 'Mehrbettzimmer'];
$dayLabels = ['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.'];
$mealLabels = [
'breakfast' => 'Frühstück',
'lunch' => 'Mittagessen',
'coffee' => 'Kaffee',
'drinks' => 'Getränkepauschale',
'dinner' => 'Abendessen',
'rehearsal_room' => 'Proberaumpauschale',
];
$guestLabel = $guestType === 'day_guest' ? 'Tagesgast' : 'Dauergast';
$ageLabel = $under27 ? 'Unter 27' : 'Ab 27';
$roomLabel = $roomLabels[$roomType] ?? '–';
$dayText = implode(', ', array_map(static fn (string $day): string => $dayLabels[$day] ?? $day, $days)) ?: '–';
$mealText = formatMealSummary($days, $mealsByDay);
$adminEmails = $pdo->query(
"SELECT email FROM admin_users WHERE is_active = 1 AND email <> ''"
)->fetchAll(PDO::FETCH_COLUMN);
foreach ($adminEmails as $adminEmail) {
if (!is_string($adminEmail) || !filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) {
continue;
}
try {
sendAdminNotificationEmail(
$mailConfig,
(string) $adminEmail,
$name,
$email,
$guestLabel,
$ageLabel,
$roomLabel,
$dayText,
$mealText,
$roommates !== '' ? $roommates : '–',
$allergies !== '' ? $allergies : '–',
$otherNotes !== '' ? $otherNotes : '–'
);
} catch (Throwable $exception) {
error_log('Admin-Benachrichtigung konnte nicht versendet werden: ' . $exception->getMessage());
}
}
http_response_code(201);
$safeEmail = escapeHtml($email);
$safePassword = escapeHtml($participantPassword);
exit(<<
Registrierung erfolgreich
Registrierung erfolgreich
{$mailError}
Bitte bewahre die folgenden Zugangsdaten auf. Du benötigst sie, um deine Registrierung später zu ändern oder zu löschen.
- E-Mail-Adresse
- {$safeEmail}
- Passwort
{$safePassword}
Registrierung ändern oder löschen
Zur Startseite
HTML);
} catch (Throwable $exception) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
error_log($exception->getMessage());
if ($exception instanceof PDOException && $exception->getCode() === '23000'
&& str_contains($exception->getMessage(), "for key 'email'")) {
http_response_code(409);
exit('Diese E-Mail-Adresse ist bereits registriert. Bitte nutze die Änderungs- oder Löschfunktion deiner bestehenden Registrierung.');
}
http_response_code(500);
exit('Die Registrierung konnte nicht gespeichert werden.');
}