PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); } function bookingSession(): void { if (session_status() === PHP_SESSION_ACTIVE) { return; } session_name('sufw_booking'); session_set_cookie_params([ 'httponly' => true, 'samesite' => 'Lax', 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), ]); session_start(); } function csrfToken(): string { bookingSession(); if (!isset($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } return $_SESSION['csrf_token']; } function ensureBookingRateLimit(): void { bookingSession(); $now = time(); $recent = array_values(array_filter($_SESSION['booking_requests'] ?? [], static fn ($timestamp): bool => is_int($timestamp) && $timestamp > $now - 900)); if (count($recent) >= 10) { respond(429, ['error' => 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.']); } $recent[] = $now; $_SESSION['booking_requests'] = $recent; } function readJsonBody(): array { $contentLength = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0); if ($contentLength > 32768) { respond(413, ['error' => 'Die Anfrage ist zu groß.']); } try { $payload = json_decode((string) file_get_contents('php://input'), true, 32, JSON_THROW_ON_ERROR); } catch (JsonException) { respond(400, ['error' => 'Die Anfrage konnte nicht verarbeitet werden.']); } if (!is_array($payload)) { respond(400, ['error' => 'Die Anfrage konnte nicht verarbeitet werden.']); } return $payload; } function stringField(array $payload, string $name, int $maxLength): string { $value = trim((string) ($payload[$name] ?? '')); if ($value === '' || mb_strlen($value) > $maxLength) { throw new InvalidArgumentException($name); } return $value; } function dateField(array $payload, string $name): ?string { $value = trim((string) ($payload[$name] ?? '')); if ($value === '') { return null; } $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); return $date && $date->format('Y-m-d') === $value ? $value : null; } function registrationId(): string { $hex = bin2hex(random_bytes(16)); return sprintf('%s-%s-%s-%s-%s', substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4), substr($hex, 16, 4), substr($hex, 20)); } function normaliseEvent(array $event): array { $event['id'] = (string) $event['id']; $event['capacity'] = (int) $event['capacity']; $event['confirmedCount'] = (int) $event['confirmedCount']; $event['availableCapacity'] = max(0, $event['capacity'] - $event['confirmedCount']); $event['status'] = $event['availableCapacity'] > 0 ? 'available' : 'full'; $event['introductionRequired'] = (bool) $event['introductionRequired']; unset($event['confirmedCount']); return $event; } function listEvents(PDO $database): never { $project = (string) ($_GET['project'] ?? ''); $from = (string) ($_GET['from'] ?? ''); $to = (string) ($_GET['to'] ?? ''); if (!in_array($project, ['perspektivwerk', 'produktionsschule'], true) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { respond(400, ['error' => 'Ungültige Kalenderanfrage.']); } $fromDate = DateTimeImmutable::createFromFormat('!Y-m-d', $from); $toDate = DateTimeImmutable::createFromFormat('!Y-m-d', $to); if (!$fromDate || !$toDate || $fromDate >= $toDate || $fromDate->diff($toDate)->days > 93) { respond(400, ['error' => 'Ungültiger Kalenderzeitraum.']); } $statement = $database->prepare(<<<'SQL' SELECT e.id, e.project, e.title, e.starts_at AS startsAt, e.ends_at AS endsAt, e.capacity, e.format, e.introduction_required AS introductionRequired, e.contact_name AS contactName, COUNT(r.id) AS confirmedCount FROM sufw_booking_events e LEFT JOIN sufw_booking_registrations r ON r.event_id = e.id AND r.status = 'confirmed' WHERE e.project = :project AND e.is_active = 1 AND e.starts_at < :toDate AND e.ends_at >= :fromDate GROUP BY e.id ORDER BY e.starts_at, e.title SQL); $statement->execute(['project' => $project, 'fromDate' => "$from 00:00:00", 'toDate' => "$to 00:00:00"]); respond(200, ['csrfToken' => csrfToken(), 'events' => array_map('normaliseEvent', $statement->fetchAll())]); } function createRegistration(PDO $database): never { ensureBookingRateLimit(); $providedToken = (string) ($_SERVER['HTTP_X_SUFW_BOOKING_CSRF'] ?? ''); if (!hash_equals(csrfToken(), $providedToken)) { respond(403, ['error' => 'Die Sitzung ist abgelaufen. Bitte laden Sie den Kalender neu.']); } $payload = readJsonBody(); $project = (string) ($payload['project'] ?? ''); $eventId = (string) ($payload['eventId'] ?? ''); $privacyAccepted = ($payload['privacyAccepted'] ?? false) === true; try { $name = stringField($payload, 'name', 160); $email = mb_strtolower(stringField($payload, 'email', 254)); $phone = stringField($payload, 'phone', 64); } catch (InvalidArgumentException) { respond(422, ['error' => 'Bitte prüfen Sie Ihre Pflichtangaben.']); } if (!in_array($project, ['perspektivwerk', 'produktionsschule'], true) || !preg_match('/^[a-f0-9-]{36}$/', $eventId) || !filter_var($email, FILTER_VALIDATE_EMAIL) || !preg_match('/^[0-9+()\-\/\s]{6,64}$/', $phone) || !$privacyAccepted) { respond(422, ['error' => 'Bitte prüfen Sie Ihre Pflichtangaben.']); } $introductionAttended = ($payload['introductionAttended'] ?? false) === true; $introductionDate = dateField($payload, 'introductionDate'); try { $database->beginTransaction(); $eventStatement = $database->prepare('SELECT id, introduction_required, capacity FROM sufw_booking_events WHERE id = :id AND project = :project AND is_active = 1 FOR UPDATE'); $eventStatement->execute(['id' => $eventId, 'project' => $project]); $event = $eventStatement->fetch(); if (!$event) { $database->rollBack(); respond(404, ['error' => 'Dieser Termin ist nicht mehr verfügbar.']); } if ((bool) $event['introduction_required'] && (!$introductionAttended || $introductionDate === null)) { $database->rollBack(); respond(422, ['error' => 'Bitte bestätigen Sie die Einführungsveranstaltung und geben Sie das Datum an.']); } $countStatement = $database->prepare("SELECT COUNT(*) FROM sufw_booking_registrations WHERE event_id = :eventId AND status = 'confirmed'"); $countStatement->execute(['eventId' => $eventId]); if ((int) $countStatement->fetchColumn() >= (int) $event['capacity']) { $database->rollBack(); respond(409, ['error' => 'Dieser Termin ist inzwischen ausgebucht. Bitte wählen Sie einen anderen Termin.']); } $duplicateStatement = $database->prepare("SELECT id FROM sufw_booking_registrations WHERE event_id = :eventId AND status = 'confirmed' AND email = :email AND name = :name LIMIT 1"); $duplicateStatement->execute(['eventId' => $eventId, 'email' => $email, 'name' => $name]); if ($duplicateStatement->fetch()) { $database->rollBack(); respond(409, ['error' => 'Für diese Person liegt bereits eine bestätigte Anmeldung vor.']); } $insert = $database->prepare(<<<'SQL' INSERT INTO sufw_booking_registrations (id, event_id, status, name, email, phone, privacy_accepted_at, introduction_attended, introduction_date) VALUES (:id, :eventId, 'confirmed', :name, :email, :phone, UTC_TIMESTAMP(), :introductionAttended, :introductionDate) SQL); $insert->execute([ 'id' => registrationId(), 'eventId' => $eventId, 'name' => $name, 'email' => $email, 'phone' => $phone, 'introductionAttended' => $introductionAttended ? 1 : 0, 'introductionDate' => $introductionAttended ? $introductionDate : null, ]); $database->commit(); } catch (Throwable $exception) { if ($database->inTransaction()) { $database->rollBack(); } error_log('SUFW booking API: '.$exception->getMessage()); respond(500, ['error' => 'Die Anmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es später erneut.']); } respond(201, ['message' => 'Ihre Anmeldung wurde verbindlich gespeichert.']); } try { $database = bookingDatabase(); if ($_SERVER['REQUEST_METHOD'] === 'GET') { listEvents($database); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { createRegistration($database); } header('Allow: GET, POST'); respond(405, ['error' => 'Diese Methode wird nicht unterstützt.']); } catch (Throwable $exception) { error_log('SUFW booking API: '.$exception->getMessage()); respond(500, ['error' => 'Der Kalender ist vorübergehend nicht verfügbar.']); }