Initial CAT application and deployment update

This commit is contained in:
2026-09-19 23:27:40 +00:00
commit 6684062400
22 changed files with 3690 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
Options -Indexes
<FilesMatch "\.(sql|log|ini|env|bak|backup|dist|swp)$">
Require all denied
</FilesMatch>
<FilesMatch "^(README.*|composer\.(json|lock)|package(-lock)?\.json)$">
Require all denied
</FilesMatch>
+21
View File
@@ -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.
+948
View File
@@ -0,0 +1,948 @@
<?php
declare(strict_types=1);
session_set_cookie_params([
'httponly' => 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'])) {
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin-Login</title>
<link rel="stylesheet" href="./css/admin.css?v=20260918-3">
</head>
<body class="admin-page">
<main class="login-card">
<p class="eyebrow">Chorwochenende</p>
<h1>Admin-Login</h1>
<?php if ($message !== ''): ?>
<p class="message error"><?= escape($message) ?></p>
<?php endif; ?>
<form method="post" autocomplete="off">
<input type="hidden" name="action" value="login">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" required autocomplete="username">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<button type="submit">Anmelden</button>
</form>
</main>
<script src="./scripts/security.js?v=20260918-3"></script>
</body>
</html><?php
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
requireCsrf();
$action = postString('action');
try {
if ($action === 'logout') {
$_SESSION = [];
setcookie(session_name(), '', [
'expires' => 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])
: '';
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Administration</title>
<link rel="stylesheet" href="./css/admin.css?v=20260918-3">
</head>
<body class="admin-page">
<header class="admin-header">
<div>
<p class="eyebrow">Chorwochenende</p>
<h1>Administration</h1>
</div>
<form method="post">
<input type="hidden" name="action" value="logout">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<button class="button-secondary" type="submit">Abmelden</button>
</form>
</header>
<main class="admin-content">
<?php if ($message !== ''): ?>
<p class="message"><?= escape($message) ?></p>
<?php endif; ?>
<section class="panel">
<div class="section-heading">
<div>
<p class="eyebrow">Anmeldungen</p>
<h2>Registrierungen</h2>
</div>
<div>
<span class="count"><?= count($bookings) ?></span>
<a class="button-secondary export-button" href="export.php">Excel-Export</a>
</div>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Altersgruppe</th>
<th>Bezahlt</th>
<th>Preis</th>
<th>Typ</th>
<th>Zimmer</th>
<th>Zimmerpartner-Wunsch</th>
<th>Frühzeitige Abreise</th>
<th>Mahlzeiten / Tage</th>
<th>Allergien</th>
<th>Sonstiges</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($bookings as $booking):
$mealData = $booking['meal_preferences'] !== null
? json_decode($booking['meal_preferences'], true)
: null;
$mealData = is_array($mealData) ? $mealData : [];
$days = 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 ($days as $day) {
$mealsByDay[(string) $day] = $mealData['meals'];
}
}
$mealText = mealSummary($days, $mealsByDay);
?>
<tr>
<td><?= escape($booking['name']) ?></td>
<td><a href="mailto:<?= escape($booking['email']) ?>"><?= escape($booking['email']) ?></a></td>
<td><?= (int) $booking['under_27'] === 1 ? 'Unter 27' : 'Ab 27' ?></td>
<td>
<form method="post" class="paid-form">
<input type="hidden" name="action" value="toggle_paid">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<input type="hidden" name="booking_id" value="<?= (int) $booking['booking_id'] ?>">
<input type="hidden" name="is_paid" value="0">
<input type="checkbox" name="is_paid" value="1" data-submit-on-change="true" <?= (int) $booking['is_paid'] === 1 ? 'checked' : '' ?> aria-label="Als bezahlt markieren">
</form>
</td>
<td>
<div><?= number_format((float) $booking['total_price'], 2, ',', '.') ?> €</div>
<small><?= $booking['manual_total_price'] !== null ? 'korrigiert' : 'automatisch' ?></small>
<form method="post" class="manual-price-form">
<input type="hidden" name="action" value="update_booking_price">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<input type="hidden" name="booking_id" value="<?= (int) $booking['booking_id'] ?>">
<label class="checkbox-label" for="correct_price_<?= (int) $booking['booking_id'] ?>">
<input type="checkbox" id="correct_price_<?= (int) $booking['booking_id'] ?>" name="correct_price" value="1" data-toggle-target="manual_total_price_<?= (int) $booking['booking_id'] ?>" <?= $booking['manual_total_price'] !== null ? 'checked' : '' ?>>
Preis korrigieren
</label>
<div class="manual-price-input" data-toggle-content="manual_total_price_<?= (int) $booking['booking_id'] ?>" <?= $booking['manual_total_price'] === null ? 'hidden' : '' ?>>
<label class="sr-only" for="manual_total_price_<?= (int) $booking['booking_id'] ?>">Preis korrigieren</label>
<input type="number" id="manual_total_price_<?= (int) $booking['booking_id'] ?>" name="manual_total_price" min="0" max="999999.99" step="0.01" placeholder="Neuer Gesamtpreis" value="<?= $booking['manual_total_price'] !== null ? escape((string) $booking['manual_total_price']) : '' ?>" <?= $booking['manual_total_price'] === null ? 'disabled' : '' ?>>
<button class="button-secondary" type="submit">Speichern</button>
</div>
</form>
</td>
<td><?= escape($booking['role']) ?></td>
<td><?= escape(accommodationLabel($booking['accommodation_type'])) ?></td>
<td><?= escape((string) ($booking['roommate_requests'] ?? '')) ?></td>
<td><?= escape($booking['early_departure'] !== null && $booking['early_departure'] !== '' ? earlyDepartureLabel($booking['early_departure']) : '') ?></td>
<td><?= escape($mealText !== '' ? $mealText : '') ?></td>
<td><?= escape((string) ($booking['allergies'] ?? '')) ?></td>
<td><?= escape((string) ($booking['other_notes'] ?? '')) ?></td>
<td>
<form method="post" data-confirm="Diese Registrierung wirklich löschen?">
<input type="hidden" name="action" value="delete_booking">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<input type="hidden" name="booking_id" value="<?= (int) $booking['booking_id'] ?>">
<button class="button-danger" type="submit">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if ($bookings === []): ?>
<tr><td colspan="13">Noch keine Registrierungen vorhanden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="panel">
<p class="eyebrow">Auswertung</p>
<h2>Zahlungserinnerung an Nichtzahler</h2>
<p>Angeschrieben werden alle angemeldeten Teilnehmer mit hinterlegter E-Mail-Adresse, deren Registrierung noch nicht als bezahlt markiert ist.</p>
<form method="post" class="stacked-form">
<input type="hidden" name="action" value="prepare_unpaid_email">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<label for="bulk_email_text">Nachrichtentext</label>
<textarea id="bulk_email_text" name="bulk_email_text" rows="8" maxlength="10000" required><?= is_array($bulkEmailDraft) ? escape((string) ($bulkEmailDraft['text'] ?? '')) : '' ?></textarea>
<small>Platzhalter: <code>{{name}}</code> für den Namen und <code>{{preis}}</code> für den jeweils offenen Preis.</small>
<button type="submit">Vorschau vorbereiten</button>
</form>
<?php if ($bulkEmailRecipients !== []): ?>
<div class="bulk-email-preview">
<h3>Versand bestätigen</h3>
<p><strong><?= count($bulkEmailRecipients) ?></strong> Empfänger gefunden. Beispiel für <?= escape((string) $bulkEmailRecipients[0]['name']) ?>:</p>
<pre><?= escape($bulkEmailPreview) ?></pre>
<form method="post" data-confirm="Die Zahlungserinnerung jetzt an alle <?= count($bulkEmailRecipients) ?> Empfänger senden?">
<input type="hidden" name="action" value="send_unpaid_email">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<button type="submit">Jetzt an alle Nichtzahler senden</button>
</form>
</div>
<?php endif; ?>
</section>
<section class="panel">
<p class="eyebrow">Auswertung</p>
<h2>Zimmerbelegung planen</h2>
<p>Berücksichtigt werden nur Übernachtungsgäste. Mehrbettzimmer werden mit maximal vier Schlafplätzen geplant.</p>
<form method="post" class="price-grid">
<input type="hidden" name="action" value="update_room_settings">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<div class="price-group">
<h3>Verfügbare Zimmer</h3>
<div class="price-item">
<label for="room_count_single">Einzelzimmer</label>
<input type="number" id="room_count_single" name="room_count_single" min="0" max="9999" step="1" value="<?= (int) $roomPlanningSettings['single'] ?>" required>
</div>
<div class="price-item">
<label for="room_count_double">Doppelzimmer</label>
<input type="number" id="room_count_double" name="room_count_double" min="0" max="9999" step="1" value="<?= (int) $roomPlanningSettings['double'] ?>" required>
</div>
<div class="price-item">
<label for="room_count_multi">Mehrbettzimmer (max. 4 Plätze)</label>
<input type="number" id="room_count_multi" name="room_count_multi" min="0" max="9999" step="1" value="<?= (int) $roomPlanningSettings['multi'] ?>" required>
</div>
</div>
<button type="submit">Zimmeranzahlen speichern</button>
</form>
<?php if ($roomPlan['warnings'] !== []): ?>
<div class="message error">
<strong>Planungshinweise</strong>
<ul>
<?php foreach ($roomPlan['warnings'] as $warning): ?>
<li><?= escape($warning) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="table-wrapper">
<table>
<thead><tr><th>Zimmer</th><th>Belegung</th><th>Kapazität</th></tr></thead>
<tbody>
<?php foreach ($roomPlan['rooms'] as $room): ?>
<tr>
<td><?= escape(accommodationLabel($room['type'])) ?> <?= (int) $room['number'] ?></td>
<td><?= escape(implode(', ', array_column($room['members'], 'name'))) ?></td>
<td><?= count($room['members']) ?> / <?= (int) $room['capacity'] ?></td>
</tr>
<?php endforeach; ?>
<?php if ($roomPlan['rooms'] === []): ?>
<tr><td colspan="3">Noch keine Zimmerplanung möglich. Bitte Zimmeranzahlen und Übernachtungsgäste prüfen.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="panel">
<h2>Preise</h2>
<form method="post" class="price-grid">
<input type="hidden" name="action" value="update_prices">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<?php foreach ($priceGroups as $groupLabel => $priceItems): ?>
<div class="price-group">
<h3><?= escape($groupLabel) ?></h3>
<?php foreach ($priceItems as $priceKey => $priceLabel): ?>
<div class="price-item">
<strong><?= escape($priceLabel) ?></strong>
<label for="price_<?= $priceKey ?>_under_27">Unter 27</label>
<input type="number" id="price_<?= $priceKey ?>_under_27" name="price_<?= $priceKey ?>_under_27" min="0" max="999999.99" step="0.01" value="<?= escape((string) ($pricingRows[$priceKey . '_under_27'] ?? '0.00')) ?>" required>
<label for="price_<?= $priceKey ?>_over_27">Ab 27</label>
<input type="number" id="price_<?= $priceKey ?>_over_27" name="price_<?= $priceKey ?>_over_27" min="0" max="999999.99" step="0.01" value="<?= escape((string) ($pricingRows[$priceKey . '_over_27'] ?? '0.00')) ?>" required>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<div class="price-group">
<h3>Pauschalen</h3>
<?php foreach ($flatPriceItems as $priceKey => $priceLabel): ?>
<div class="price-item">
<strong><?= escape($priceLabel) ?></strong>
<label for="price_<?= $priceKey ?>">Preis</label>
<input type="number" id="price_<?= $priceKey ?>" name="price_<?= $priceKey ?>" min="0" max="999999.99" step="0.01" value="<?= escape((string) ($pricingRows[$priceKey] ?? '0.00')) ?>" required>
</div>
<?php endforeach; ?>
</div>
<button type="submit">Preise speichern</button>
</form>
</section>
<div class="admin-grid">
<section class="panel">
<p class="eyebrow">Zugänge</p>
<h2>Admin-Benutzer</h2>
<div class="user-list">
<?php foreach ($admins as $admin): ?>
<div class="user-row">
<div><strong><?= escape($admin['username']) ?></strong><br><small><?= escape($admin['email']) ?></small></div>
<div class="user-actions">
<form method="post">
<input type="hidden" name="action" value="change_password">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<input type="hidden" name="admin_id" value="<?= (int) $admin['id'] ?>">
<input type="password" name="changed_password" minlength="12" placeholder="Neues Passwort" required>
<button class="button-secondary" type="submit">Ändern</button>
</form>
<?php if ((int) $admin['id'] !== (int) $_SESSION['admin_id']): ?>
<form method="post">
<input type="hidden" name="action" value="delete_user">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<input type="hidden" name="admin_id" value="<?= (int) $admin['id'] ?>">
<button class="button-danger" type="submit">Löschen</button>
</form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</section>
<section class="panel">
<p class="eyebrow">Zugänge</p>
<h2>Benutzer anlegen</h2>
<form method="post" class="stacked-form">
<input type="hidden" name="action" value="create_user">
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
<label for="new_username">Benutzername</label>
<input type="text" id="new_username" name="new_username" pattern="[A-Za-z0-9_.-]{3,80}" required>
<label for="new_email">E-Mail-Adresse</label>
<input type="email" id="new_email" name="new_email" maxlength="255" required>
<label for="new_password">Passwort</label>
<input type="password" id="new_password" name="new_password" minlength="12" required>
<button type="submit">Benutzer anlegen</button>
</form>
</section>
</div>
</main>
<script src="./scripts/security.js?v=20260918-3"></script>
</body>
</html>
+138
View File
@@ -0,0 +1,138 @@
<?php
session_set_cookie_params([
'httponly' => 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'");
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Gastregistrierung</title>
<link rel="stylesheet" href="./css/booking.css">
</head>
<body>
<h1>Gastregistrierung</h1>
<form action="process_registration.php" method="POST">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['registration_csrf'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
<!-- 1. Name und E-Mail des Gastes -->
<div class="form-group">
<label for="name">Name des Gastes:</label>
<input type="text" id="name" name="name" maxlength="255" required>
<label for="email">E-Mail-Adresse:</label>
<input type="email" id="email" name="email" maxlength="255" required>
<label class="checkbox-label" for="under_27">
<input type="checkbox" id="under_27" name="under_27" value="1">
Unter 27
</label>
</div>
<!-- 2. Gasttyp (Dropdown) -->
<div class="form-group">
<label for="guest_type">Gasttyp:</label>
<select id="guest_type" name="guest_type" required>
<option value="">Bitte auswählen</option>
<option value="day_guest">Tagesgast</option>
<option value="regular_guest">Dauergast</option>
</select>
</div>
<!-- 3. Bedingte Auswahl basierend auf Gasttyp -->
<div class="form-group" id="room_details">
<!-- Dauergast-Optionen (wird angezeigt, wenn regelmäßiger Gast gewählt wird) -->
<div id="regular_guest_options">
<label for="room_type">Zimmerart:</label>
<select id="room_type" name="room_type">
<option value="single">Einzelzimmer</option>
<option value="double">Doppelzimmer</option>
<option value="multi">Mehrbettzimmer</option>
</select>
<!-- Bedingte Felder für Doppel/Mehrbettzimmer -->
<div id="roommate_info" style="display: none;">
<label for="roommates">Mit wem möchten Sie das Zimmer teilen?</label>
<input type="text" id="roommates" name="roommates" placeholder="Name des Mitbewohners" autocomplete="off" list="roommate_suggestions">
<datalist id="roommate_suggestions"></datalist>
</div>
<label class="checkbox-label" for="early_departure_enabled">
<input type="checkbox" id="early_departure_enabled" name="early_departure_enabled" value="1">
Ich reise vorzeitig ab.
</label>
<div id="early_departure_options" style="display: none;">
<label>Abreisezeitpunkt:</label>
<div class="checkbox-group">
<label><input type="radio" name="early_departure" value="sat_breakfast"> Samstag nach dem Frühstück</label>
<label><input type="radio" name="early_departure" value="sat_lunch"> Samstag nach dem Mittagessen</label>
<label><input type="radio" name="early_departure" value="sat_coffee"> Samstag nach dem Kaffee</label>
<label><input type="radio" name="early_departure" value="sat_dinner"> Samstag nach dem Abendessen</label>
<label><input type="radio" name="early_departure" value="sun_breakfast"> Sonntag nach dem Frühstück</label>
</div>
</div>
</div>
<!-- Tagesgast-Optionen (wird angezeigt, wenn Tagesgast gewählt wird) -->
<div id="day_guest_options" style="display: none;">
<label>Anwesenheitstage (Checkboxen):</label>
<div class="checkbox-group">
<input type="checkbox" name="days[]" value="1"> Fr.
<input type="checkbox" name="days[]" value="2"> Sa.
<input type="checkbox" name="days[]" value="3"> So.
<!-- Fügen Sie hier weitere Tage hinzu -->
</div>
<label>Gewünschte Mahlzeiten je Anwesenheitstag:</label>
<?php $mealOptionsByDay = [
'1' => ['dinner' => 'Abendessen'],
'2' => ['breakfast' => 'Frühstück', 'lunch' => 'Mittagessen', 'coffee' => 'Kaffee', 'dinner' => 'Abendessen'],
'3' => ['breakfast' => 'Frühstück', 'lunch' => 'Mittagessen'],
]; ?>
<?php foreach (['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.'] as $dayValue => $dayLabel): ?>
<fieldset class="meal-day">
<legend><?= htmlspecialchars($dayLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></legend>
<div class="checkbox-group">
<?php foreach ($mealOptionsByDay[$dayValue] as $mealValue => $mealLabel): ?>
<label><input type="checkbox" name="meals[<?= $dayValue ?>][]" value="<?= $mealValue ?>"> <?= $mealLabel ?></label>
<?php endforeach; ?>
</div>
</fieldset>
<?php endforeach; ?>
<p class="form-help">Die Getränkepauschale und die Proberaumpauschale werden für Tagesgäste automatisch pro Anwesenheitstag berechnet.</p>
</div>
</div>
<!-- 4. Allergien und Essensvorlieben (für beide Gasttypen) -->
<div class="form-group">
<label>Allergien, Essensvorlieben oder religiöse Einschränkungen:</label>
<input type="text" name="allergies" placeholder="z.B. Vegetarier, Vegan, Glutenunverträglichkeit, etc.">
</div>
<!-- 5. Sonstige Hinweise -->
<div class="form-group">
<label for="other_notes">Sonstiges:</label>
<textarea id="other_notes" name="other_notes" rows="5" maxlength="5000" placeholder="Weitere Hinweise oder Wünsche"></textarea>
</div>
<button type="submit">Registrieren</button>
</form>
<script src="./scripts/functions.js?v=20260918-2"></script>
<script src="./scripts/security.js"></script>
</body>
</html>
+12
View File
@@ -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);
Binary file not shown.
+119
View File
@@ -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; }
}
+271
View File
@@ -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;
}
}
+107
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
+731
View File
@@ -0,0 +1,731 @@
<?php
declare(strict_types=1);
session_set_cookie_params([
'httponly' => 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'])) {
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registrierung ändern</title>
<link rel="stylesheet" href="./css/booking.css">
</head>
<body>
<main class="success-card">
<h1>Registrierung ändern</h1>
<p>Gib die E-Mail-Adresse und das Passwort ein, die du nach der Registrierung erhalten hast.</p>
<?php if ($message !== ''): ?>
<p class="message error"><?= escapeHtml($message) ?></p>
<?php endif; ?>
<form method="post" autocomplete="off">
<input type="hidden" name="action" value="login">
<div class="form-group">
<label for="email">E-Mail-Adresse:</label>
<input type="email" id="email" name="email" required autocomplete="username">
<label for="password">Passwort:</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit">Registrierung laden</button>
</form>
<p><a href="index.php">Zur Startseite</a></p>
</main>
</body>
</html><?php
exit;
}
$participantId = (string) $_SESSION['edit_participant_id'];
$bookingId = (int) $_SESSION['edit_booking_id'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
requireEditCsrf();
$action = postString('action');
try {
if ($action === 'logout') {
unset($_SESSION['edit_participant_id'], $_SESSION['edit_booking_id'], $_SESSION['edit_csrf']);
setcookie(session_name(), '', [
'expires' => 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();
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registrierung ändern</title>
<link rel="stylesheet" href="./css/booking.css">
</head>
<body>
<main class="success-card">
<h1>Registrierung ändern</h1>
<?php if ($message !== ''): ?>
<p class="message"><?= escapeHtml($message) ?></p>
<?php endif; ?>
<form method="post">
<input type="hidden" name="action" value="update_booking">
<input type="hidden" name="csrf_token" value="<?= escapeHtml($csrf) ?>">
<div class="form-group">
<label for="name">Name des Gastes:</label>
<input type="text" id="name" name="name" maxlength="255" value="<?= escapeHtml($booking['name']) ?>" required>
<label for="email">E-Mail-Adresse:</label>
<input type="email" id="email" name="email" maxlength="255" value="<?= escapeHtml($booking['email']) ?>" required>
<label class="checkbox-label" for="under_27">
<input type="checkbox" id="under_27" name="under_27" value="1" <?= (int) $booking['under_27'] === 1 ? 'checked' : '' ?>>
Unter 27
</label>
</div>
<div class="form-group">
<label for="guest_type">Gasttyp:</label>
<select id="guest_type" name="guest_type" required>
<option value="">Bitte auswählen</option>
<option value="day_guest" <?= $guestType === 'day_guest' ? 'selected' : '' ?>>Tagesgast</option>
<option value="regular_guest" <?= $guestType === 'regular_guest' ? 'selected' : '' ?>>Dauergast</option>
</select>
</div>
<div class="form-group" id="room_details">
<div id="regular_guest_options">
<label for="room_type">Zimmerart:</label>
<select id="room_type" name="room_type">
<option value="single" <?= $booking['accommodation_type'] === 'single' ? 'selected' : '' ?>>Einzelzimmer</option>
<option value="double" <?= $booking['accommodation_type'] === 'double' ? 'selected' : '' ?>>Doppelzimmer</option>
<option value="multi" <?= $booking['accommodation_type'] === 'multi' ? 'selected' : '' ?>>Mehrbettzimmer</option>
</select>
<div id="roommate_info" style="display: none;">
<label for="roommates">Mit wem möchtest du das Zimmer teilen?</label>
<input type="text" id="roommates" name="roommates" value="<?= escapeHtml((string) ($booking['roommate_requests'] ?? '')) ?>" placeholder="Name des Mitbewohners">
</div>
<label class="checkbox-label" for="early_departure_enabled">
<input type="checkbox" id="early_departure_enabled" name="early_departure_enabled" value="1" <?= !empty($booking['early_departure']) ? 'checked' : '' ?>>
Ich reise vorzeitig ab.
</label>
<div id="early_departure_options" style="display: none;">
<label>Abreisezeitpunkt:</label>
<?php $earlyDepartureOptions = [
'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',
]; ?>
<div class="checkbox-group">
<?php foreach ($earlyDepartureOptions as $value => $label): ?>
<label><input type="radio" name="early_departure" value="<?= $value ?>" <?= $booking['early_departure'] === $value ? 'checked' : '' ?>> <?= $label ?></label>
<?php endforeach; ?>
</div>
</div>
</div>
<div id="day_guest_options" style="display: none;">
<label>Anwesenheitstage:</label>
<div class="checkbox-group">
<?php foreach (['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.'] as $value => $label): ?>
<label><input type="checkbox" name="days[]" value="<?= $value ?>" <?= in_array($value, $mealData['days'] ?? [], true) ? 'checked' : '' ?>> <?= $label ?></label>
<?php endforeach; ?>
</div>
<label>Gewünschte Mahlzeiten je Anwesenheitstag:</label>
<?php $mealOptionsByDay = [
'1' => ['dinner' => 'Abendessen'],
'2' => ['breakfast' => 'Frühstück', 'lunch' => 'Mittagessen', 'coffee' => 'Kaffee', 'dinner' => 'Abendessen'],
'3' => ['breakfast' => 'Frühstück', 'lunch' => 'Mittagessen'],
]; ?>
<?php foreach (['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.'] as $dayValue => $dayLabel): ?>
<fieldset class="meal-day">
<legend><?= escapeHtml($dayLabel) ?></legend>
<div class="checkbox-group">
<?php foreach ($mealOptionsByDay[$dayValue] as $value => $label): ?>
<label><input type="checkbox" name="meals[<?= $dayValue ?>][]" value="<?= $value ?>" <?= in_array($value, $selectedMealsByDay[$dayValue] ?? [], true) ? 'checked' : '' ?>> <?= $label ?></label>
<?php endforeach; ?>
</div>
</fieldset>
<?php endforeach; ?>
<p class="form-help">Die Getränkepauschale und die Proberaumpauschale werden für Tagesgäste automatisch pro Anwesenheitstag berechnet.</p>
</div>
</div>
<div class="form-group">
<label for="allergies">Allergien, Essensvorlieben oder religiöse Einschränkungen:</label>
<input type="text" id="allergies" name="allergies" value="<?= escapeHtml((string) ($booking['allergies'] ?? '')) ?>" placeholder="z.B. Vegetarier, Vegan, Glutenunverträglichkeit, etc.">
</div>
<div class="form-group">
<label for="other_notes">Sonstiges:</label>
<textarea id="other_notes" name="other_notes" rows="5" maxlength="5000" placeholder="Weitere Hinweise oder Wünsche"><?= escapeHtml((string) ($booking['other_notes'] ?? '')) ?></textarea>
</div>
<button type="submit">Änderungen speichern</button>
</form>
<form method="post" data-confirm="Möchtest du die Buchung wirklich löschen?" style="margin-top: 16px;">
<input type="hidden" name="action" value="delete_booking">
<input type="hidden" name="csrf_token" value="<?= escapeHtml($csrf) ?>">
<button class="button-danger" type="submit">Gesamte Buchung löschen</button>
</form>
<p><a href="index.php">Zur Startseite</a></p>
</main>
<script src="./scripts/functions.js?v=20260918-2"></script>
<script src="./scripts/security.js"></script>
</body>
</html>
+167
View File
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
session_set_cookie_params([
'httponly' => 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);
+1
View File
@@ -0,0 +1 @@
Require all denied
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
function loadRoomPlanningSettings(PDO $pdo): array
{
$defaults = [
'single' => 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,
];
}
+36
View File
@@ -0,0 +1,36 @@
<?php
session_set_cookie_params([
'httponly' => 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'");
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chorwochenende</title>
<link rel="stylesheet" href="./css/index.css">
</head>
<body>
<main class="start-page">
<p class="eyebrow">Chorwochenende</p>
<h1>Willkommen</h1>
<p class="intro">Bitte wählen Sie den gewünschten Bereich aus.</p>
<nav class="main-actions" aria-label="Hauptnavigation">
<a class="action-button" href="booking.php">Neue Registrierung</a>
<a class="action-button" href="edit_registration.php">Registrierung ändern</a>
<a class="action-button action-button-secondary" href="admin.php">Administration</a>
</nav>
</main>
</body>
</html>
+662
View File
@@ -0,0 +1,662 @@
<?php
declare(strict_types=1);
session_set_cookie_params([
'httponly' => 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 = '<!doctype html><html lang="de"><body>'
. '<p>Hallo ' . $html($name) . ',</p>'
. '<p>deine Registrierung für das Chorwochenende wurde erfolgreich gespeichert.</p>'
. '<h2>Deine Zugangsdaten</h2><p><strong>E-Mail-Adresse:</strong> ' . $html($recipient)
. '<br><strong>Passwort:</strong> <code>' . $html($participantPassword) . '</code></p>'
. '<h2>Zusammenfassung</h2><ul>'
. '<li><strong>Altersgruppe:</strong> ' . $html($ageLabel) . '</li>'
. '<li><strong>Gasttyp:</strong> ' . $html($guestLabel) . '</li>'
. '<li><strong>Zimmerart:</strong> ' . $html($roomLabel) . '</li>'
. '<li><strong>Anwesenheitstage:</strong> ' . $html($dayText) . '</li>'
. '<li><strong>Mahlzeiten:</strong> ' . $html($mealText) . '</li>'
. '<li><strong>Zimmerpartner-Wunsch:</strong> ' . $html($roommates) . '</li>'
. '<li><strong>Allergien / Essensvorlieben:</strong> ' . $html($allergies) . '</li>'
. '<li><strong>Sonstiges:</strong> ' . $html($otherNotes) . '</li></ul>'
. '<p>Viele Grüße<br>' . $html($mailConfig['from_name']) . '</p></body></html>';
$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 = '<p class="message error">Die Registrierung wurde gespeichert, aber die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte bewahre die Zugangsdaten auf dieser Seite auf.</p>';
}
$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(<<<HTML
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registrierung erfolgreich</title>
<link rel="stylesheet" href="./css/booking.css">
</head>
<body>
<main class="success-card">
<h1>Registrierung erfolgreich</h1>
{$mailError}
<p>Bitte bewahre die folgenden Zugangsdaten auf. Du benötigst sie, um deine Registrierung später zu ändern oder zu löschen.</p>
<dl class="credentials">
<dt>E-Mail-Adresse</dt>
<dd>{$safeEmail}</dd>
<dt>Passwort</dt>
<dd><code>{$safePassword}</code></dd>
</dl>
<p><a href="edit_registration.php">Registrierung ändern oder löschen</a></p>
<p><a href="index.php">Zur Startseite</a></p>
</main>
</body>
</html>
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.');
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
session_set_cookie_params([
'httponly' => 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);
}
+103
View File
@@ -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();
});
+126
View File
@@ -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();
});
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
+30
View File
@@ -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();
}
});