flash()->get(); $isLoggedIn = isset($_SESSION['user_id']); $error = ''; $displayName = ''; $email = ''; if ($isLoggedIn) { redirect('/dashboard'); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $displayName = trim((string)($_POST['display_name'] ?? '')); $email = trim((string)($_POST['email'] ?? '')); $password = (string)($_POST['password'] ?? ''); $password2 = (string)($_POST['password_confirm'] ?? ''); if ($password !== $password2) { $error = 'Passwörter stimmen nicht überein.'; } elseif (strlen($password) < 8) { $error = 'Passwort muss mindestens 8 Zeichen haben.'; } else { try { $auth = new Auth($app); $userId = $auth->register($displayName, $email, $password); $code = $auth->createVerifyCode($userId, $email); $mailer = new Mailer($app); $mailer->sendTemplate('registration_confirm', $email, [ 'code' => $code, 'display_name' => $displayName, ]); $_SESSION['verify_email'] = $email; $app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.'); redirect('/verify'); } catch (\Throwable $e) { $error = $e->getMessage(); } } } return compact('flash', 'error', 'displayName', 'email'); } public static function login(App $app): array { $flash = $app->flash()->get(); $isLoggedIn = isset($_SESSION['user_id']); $error = ''; $emailPrefill = ''; if ($isLoggedIn) { redirect('/dashboard'); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $email = trim((string)($_POST['email'] ?? '')); $emailPrefill = $email; $password = (string)($_POST['password'] ?? ''); try { $auth = new Auth($app); $res = $auth->login($email, $password); if ($res['status'] === 'pending') { $code = $auth->createVerifyCode($res['id'], $email); $mailer = new Mailer($app); $mailer->sendTemplate('registration_confirm', $email, [ 'code' => $code, 'display_name' => $email, ]); $_SESSION['verify_email'] = $email; $app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.'); redirect('/verify'); } $_SESSION['user_id'] = $res['id']; $app->flash()->set('success', 'Erfolgreich angemeldet.'); redirect('/dashboard'); } catch (\Throwable $e) { $error = $e->getMessage(); } } return compact('flash', 'error', 'emailPrefill', 'isLoggedIn'); } public static function verify(App $app): array { $flash = $app->flash()->get(); $error = ''; $info = ''; $email = $_SESSION['verify_email'] ?? ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = $_POST['action'] ?? 'verify'; $email = trim((string)($_POST['email'] ?? '')); $code = strtoupper(trim((string)($_POST['code'] ?? ''))); $auth = new Auth($app); $mailer = new Mailer($app); if ($action === 'resend') { try { $row = $auth->findUserMetaByEmail($email); if (!$row) { throw new \RuntimeException('E-Mail nicht gefunden.'); } $userId = (int)$row['id']; $storedEmail = $auth->getEmailByUserId($userId); $codeNew = $auth->createVerifyCode($userId, $storedEmail); $mailer->sendTemplate('registration_resend_code', $storedEmail, [ 'code' => $codeNew, 'display_name' => $row['display_name'] ?? '', ]); $info = 'Neuer Code wurde versendet.'; $_SESSION['verify_email'] = $storedEmail; } catch (\Throwable $e) { $error = $e->getMessage(); } } else { try { $userId = $auth->verifyCode($email, $code); $_SESSION['user_id'] = $userId; unset($_SESSION['verify_email']); $storedEmail = $auth->getEmailByUserId($userId); $meta = $auth->findUserMetaByEmail($storedEmail); $mailer->sendTemplate('registration_welcome', $storedEmail, [ 'display_name' => $meta['display_name'] ?? $storedEmail, ]); $app->flash()->set('success', 'Registrierung bestätigt. Willkommen!'); redirect('/dashboard'); } catch (\Throwable $e) { $error = $e->getMessage(); } } } return compact('flash', 'error', 'info', 'email'); } public static function dashboard(App $app): array { if (!isset($_SESSION['user_id'])) { redirect('/login'); } $pdo = $app->pdo(); $flash = $app->flash()->get(); $userId = (int)$_SESSION['user_id']; $error = ''; $info = ''; $crypto = null; try { $crypto = new Crypto($app->config()); } catch (\Throwable) {} $communityCfg = dirname(__DIR__, 2) . '/config/community.php'; $communityConfig = file_exists($communityCfg) ? require $communityCfg : []; $community = $pdo ? new Community($pdo, $communityConfig) : null; $communityAccess = $pdo ? new CommunityAccess($pdo, $communityConfig) : null; $communityMigration = $pdo ? new CommunityMigration($pdo) : null; $profileSettings = $pdo ? new ProfileSettings($pdo) : null; $systemSettings = $pdo ? new SystemSettings($pdo) : null; $listingCatalog = $pdo ? new ListingCatalog($pdo) : null; $section = (string)($_GET['section'] ?? 'profile'); $canManageSystemSettings = $communityAccess ? $communityAccess->canManageApplications($userId) : false; $allowedSections = ['profile', 'children', 'events', 'places', 'community', 'settings']; if ($canManageSystemSettings) { $allowedSections[] = 'system'; } if ($systemSettings) { $systemSettings->ensureSchema(); } if ($listingCatalog) { $listingCatalog->ensureSchema(); } if ($pdo) { self::ensureLegacyEventSchema($pdo); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = $_POST['action'] ?? ''; try { if ($action === 'profile') { $crypto = self::requireCrypto($crypto, 'Profil'); $email = trim((string)($_POST['email'] ?? '')); $languages = $_POST['languages'] ?? ''; if (is_array($languages)) { $languages = implode(', ', array_map('trim', $languages)); } if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new \RuntimeException('Bitte gib eine gueltige E-Mail-Adresse an.'); } $firstNameEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['first_name'])); $lastNameEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['last_name'])); $streetEnc = self::encryptOptionalProfileField($crypto, trim((string)($_POST['street'] ?? ''))); $phoneEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['contact_phone'])); $professionEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['profession'])); $languagesEnc = self::encryptOptionalProfileField($crypto, trim((string)$languages)); $aboutEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['about'])); $locationPreference = (string)($_POST['location_tracking_preference'] ?? 'prompt'); $street = trim((string)($_POST['street'] ?? '')); $zip = trim((string)($_POST['zip'] ?? '')); $city = trim((string)($_POST['city'] ?? '')); $region = trim((string)($_POST['region'] ?? '')); $lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null; $lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null; $hasAddress = $street !== '' || $zip !== '' || $city !== '' || $region !== ''; if ($hasAddress) { $resolvedAddress = null; if ($lat !== null && $lng !== null) { $resolvedAddress = self::reverseGeocodeAddress($lat, $lng); } if ($resolvedAddress === null) { $searchResults = self::geocodeAddressCandidates($street, $zip, $city, $region, 5); if (!$searchResults) { throw new \RuntimeException('Die Profiladresse konnte nicht validiert werden. Bitte wähle eine Adresse aus der Suche oder über den Browser-Standort.'); } $resolvedAddress = $searchResults[0]; } $street = $resolvedAddress['street'] !== '' ? $resolvedAddress['street'] : $street; $zip = $resolvedAddress['zip'] !== '' ? $resolvedAddress['zip'] : $zip; $city = $resolvedAddress['city'] !== '' ? $resolvedAddress['city'] : $city; $region = $resolvedAddress['region'] !== '' ? $resolvedAddress['region'] : $region; $lat = $resolvedAddress['lat']; $lng = $resolvedAddress['lng']; } else { $lat = null; $lng = null; $region = ''; } $streetEnc = self::encryptOptionalProfileField($crypto, $street); if ($profileSettings) { $profileSettings->ensureSchema(); } $stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, location_tracking_preference=:locationPref, updated_at=NOW() WHERE user_id=:id'); $stmt?->execute([ 'name' => trim((string)$_POST['display_name']), 'fname' => $firstNameEnc, 'lname' => $lastNameEnc, 'street' => $streetEnc, 'zip' => $zip, 'city' => $city, 'region' => $region, 'lat' => $lat, 'lng' => $lng, 'prof' => $professionEnc, 'langs' => $languagesEnc, 'about' => $aboutEnc, 'phone' => $phoneEnc, 'locationPref' => in_array($locationPreference, ['disabled', 'prompt', 'enabled'], true) ? $locationPreference : 'prompt', 'id' => $userId, ]); if ($pdo) { $emailStore = new UserEmailStore($pdo); $emailStore->ensureSchema(); $emailStore->updateUserEmail($userId, $email); } $info = 'Profil gespeichert.'; } elseif ($action === 'avatar_update') { if ($profileSettings) { $profileSettings->updateAvatar($userId, $_POST); } $info = 'Profilbild gespeichert.'; } elseif ($action === 'settings_location') { $locationPreference = (string)($_POST['location_tracking_preference'] ?? 'prompt'); if ($profileSettings) { $profileSettings->updateLocationTrackingPreference( $userId, in_array($locationPreference, ['disabled', 'prompt', 'enabled'], true) ? $locationPreference : 'prompt' ); } $info = 'Einstellungen gespeichert.'; } elseif ($action === 'system_settings_update') { if (!$canManageSystemSettings || !$systemSettings) { throw new \RuntimeException('Keine Berechtigung für die System-Einstellungen.'); } $siteMaintenanceMessage = trim((string)($_POST['site_maintenance_message'] ?? '')); if ($siteMaintenanceMessage === '') { $siteMaintenanceMessage = 'Papa-Kind-Treff ist gerade kurz in Wartung. Bitte versuche es in Kürze erneut.'; } $placeDataProvider = (string)($_POST['place_data_provider'] ?? 'osm'); if (!in_array($placeDataProvider, ['osm', 'osm_google_optional'], true)) { $placeDataProvider = 'osm'; } $systemSettings->updateMany([ 'google_places_enabled' => isset($_POST['google_places_enabled']) ? '1' : '0', 'forum_maintenance_mode' => isset($_POST['forum_maintenance_mode']) ? '1' : '0', 'site_maintenance_mode' => isset($_POST['site_maintenance_mode']) ? '1' : '0', 'site_maintenance_message' => $siteMaintenanceMessage, 'place_data_provider' => $placeDataProvider, ], $userId); $info = 'System-Einstellungen gespeichert.'; } elseif ($action === 'category_merge') { if (!$canManageSystemSettings || !$listingCatalog) { throw new \RuntimeException('Keine Berechtigung für die Kategorien-Verwaltung.'); } $listingCatalog->mergeCategories( (string)($_POST['source_category_slug'] ?? ''), (string)($_POST['target_category_slug'] ?? '') ); $info = 'Kategorie zusammengeführt.'; } elseif ($action === 'child_add' || $action === 'child_update') { $crypto = self::requireCrypto($crypto, 'Kinder'); $childId = (int)($_POST['child_id'] ?? 0); $firstName = trim((string)($_POST['first_name'] ?? '')); $gender = (string)($_POST['gender'] ?? 'unknown'); $birthdate = trim((string)($_POST['birthdate'] ?? '')); $ageYearsRaw = trim((string)($_POST['age_years'] ?? '')); $ageYears = $ageYearsRaw !== '' ? max(0, (int)$ageYearsRaw) : null; $note = trim((string)($_POST['note'] ?? '')); if ($firstName === '') { throw new \RuntimeException('Bitte gib einen Vornamen an.'); } if (!in_array($gender, ['male', 'female', 'diverse', 'unknown'], true)) { $gender = 'unknown'; } if ($birthdate !== '') { $calculatedAge = self::calculateAgeFromBirthdate($birthdate); if ($calculatedAge === null) { throw new \RuntimeException('Bitte gib ein gueltiges Geburtsdatum an.'); } $ageYears = $calculatedAge; } $firstNameEnc = $crypto->encrypt($firstName); $noteEnc = $note !== '' ? $crypto->encrypt($note) : ''; if ($action === 'child_add') { $stmt = $pdo?->prepare('INSERT INTO children (user_id, gender, birthdate, age_years, encrypted_first_name, note, created_at, updated_at) VALUES (:uid, :gender, :birthdate, :age, :name, :note, NOW(), NOW())'); $stmt?->execute([ 'uid' => $userId, 'gender' => $gender, 'birthdate' => $birthdate !== '' ? $birthdate : null, 'age' => $ageYears, 'name' => $firstNameEnc, 'note' => $noteEnc, ]); $info = 'Kind hinzugefügt.'; } else { $stmt = $pdo?->prepare('UPDATE children SET gender = :gender, birthdate = :birthdate, age_years = :age, encrypted_first_name = :name, note = :note, updated_at = NOW() WHERE id = :childId AND user_id = :uid'); $stmt?->execute([ 'uid' => $userId, 'childId' => $childId, 'gender' => $gender, 'birthdate' => $birthdate !== '' ? $birthdate : null, 'age' => $ageYears, 'name' => $firstNameEnc, 'note' => $noteEnc, ]); if ($stmt && $stmt->rowCount() < 1) { throw new \RuntimeException('Kind nicht gefunden.'); } $info = 'Kind gespeichert.'; } } elseif ($action === 'child_delete') { $childId = (int)($_POST['child_id'] ?? 0); $stmt = $pdo?->prepare('DELETE FROM children WHERE id = :childId AND user_id = :uid'); $stmt?->execute([ 'childId' => $childId, 'uid' => $userId, ]); if ($stmt && $stmt->rowCount() < 1) { throw new \RuntimeException('Kind nicht gefunden.'); } $info = 'Kind gelöscht.'; } elseif ($action === 'event_add' || $action === 'event_update') { $entryKind = (string)($_POST['entry_kind'] ?? 'event'); if (in_array($entryKind, ['place', 'editorial_event'], true)) { if (!$listingCatalog) { throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.'); } $payload = $_POST; $existingListingId = $action === 'event_update' ? (int)($_POST['listing_id'] ?? 0) : 0; $existingListing = $existingListingId > 0 ? $listingCatalog->getDashboardEntry($userId, $existingListingId) : null; $street = trim((string)($_POST['street'] ?? '')); $zip = trim((string)($_POST['zip'] ?? '')); $city = trim((string)($_POST['city'] ?? '')); $region = trim((string)($_POST['region'] ?? '')); $lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null; $lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null; $hasAddress = $street !== '' || $zip !== '' || $city !== '' || $region !== ''; if ($hasAddress) { $resolvedAddress = null; if ($lat !== null && $lng !== null) { $resolvedAddress = self::reverseGeocodeAddress($lat, $lng); } if ($resolvedAddress === null) { $searchResults = self::geocodeAddressCandidates($street, $zip, $city, $region, 5); if (!$searchResults) { throw new \RuntimeException('Die Adresse konnte nicht validiert werden. Bitte wähle einen passenden Adress-Treffer.'); } $resolvedAddress = $searchResults[0]; } $payload['street'] = $resolvedAddress['street'] !== '' ? $resolvedAddress['street'] : $street; $payload['zip'] = $resolvedAddress['zip'] !== '' ? $resolvedAddress['zip'] : $zip; $payload['city'] = $resolvedAddress['city'] !== '' ? $resolvedAddress['city'] : $city; $payload['region'] = $resolvedAddress['region'] !== '' ? $resolvedAddress['region'] : $region; $payload['lat'] = $resolvedAddress['lat']; $payload['lng'] = $resolvedAddress['lng']; } $payload['image_path'] = self::storeUploadedImage($_FILES['image_file'] ?? null, is_array($existingListing) ? (string)($existingListing['image_path'] ?? '') : ''); $payload['teaser'] = self::generateTeaser( trim((string)($payload['description'] ?? '')), trim((string)($payload['title'] ?? '')) ); $listingCatalog->saveDashboardEntry( $userId, $payload, $action === 'event_update' ? (int)($_POST['listing_id'] ?? 0) : null ); $info = $entryKind === 'place' ? 'Ort gespeichert.' : 'Veranstaltung gespeichert.'; } else { $existingEvent = null; if ($action === 'event_update') { $existingEventId = (int)($_POST['event_id'] ?? 0); if ($existingEventId > 0) { $stmt = $pdo?->prepare('SELECT * FROM events WHERE id = :id AND created_by = :uid LIMIT 1'); $stmt?->execute(['id' => $existingEventId, 'uid' => $userId]); $existingEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null; } } $street = trim((string)($_POST['street'] ?? '')); $zip = trim((string)($_POST['zip'] ?? '')); $city = trim((string)($_POST['city'] ?? '')); $region = trim((string)($_POST['region'] ?? '')); $lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null; $lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null; $hasAddress = $street !== '' || $zip !== '' || $city !== '' || $region !== ''; if ($hasAddress) { $resolvedAddress = null; if ($lat !== null && $lng !== null) { $resolvedAddress = self::reverseGeocodeAddress($lat, $lng); } if ($resolvedAddress === null) { $searchResults = self::geocodeAddressCandidates($street, $zip, $city, $region, 5); if (!$searchResults) { throw new \RuntimeException('Die Adresse konnte nicht validiert werden. Bitte wähle einen passenden Adress-Treffer.'); } $resolvedAddress = $searchResults[0]; } $street = $resolvedAddress['street'] !== '' ? $resolvedAddress['street'] : $street; $zip = $resolvedAddress['zip'] !== '' ? $resolvedAddress['zip'] : $zip; $city = $resolvedAddress['city'] !== '' ? $resolvedAddress['city'] : $city; $region = $resolvedAddress['region'] !== '' ? $resolvedAddress['region'] : $region; $lat = $resolvedAddress['lat']; $lng = $resolvedAddress['lng']; } $imagePath = self::storeUploadedImage($_FILES['image_file'] ?? null, is_array($existingEvent) ? (string)($existingEvent['image_path'] ?? '') : ''); $allowKids = ((string)($_POST['with_children'] ?? 'yes')) === 'yes' ? 1 : 0; $capacity = isset($_POST['max_participants']) && $_POST['max_participants'] !== '' ? (int)$_POST['max_participants'] : null; $categoryInput = trim((string)($_POST['category_input'] ?? $_POST['category_slug'] ?? '')); $categorySlug = ''; if ($categoryInput !== '' && $listingCatalog) { $category = $listingCatalog->ensureCategoryForInput($categoryInput, 'event'); $categorySlug = (string)($category['slug'] ?? ''); } $eventStartValue = self::normalizeDateForStorage((string)($_POST['starts_at'] ?? ''), true); if ($action === 'event_add') { $stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, category_slug, image_path, location_label, street, zip, city, region, lat, lng, starts_at, max_participants, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :categorySlug, :imagePath, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :capacity, :allow, :vis, :status, NOW(), NOW())'); $stmt?->execute([ 'uid' => $userId, 'title' => trim((string)$_POST['title']), 'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])), 'descr' => trim((string)$_POST['description']), 'categorySlug' => $categorySlug !== '' ? $categorySlug : null, 'imagePath' => $imagePath !== '' ? $imagePath : null, 'loc' => null, 'street' => $street ?: null, 'zip' => $zip, 'city' => $city, 'region' => $region, 'lat' => $lat, 'lng' => $lng, 'start' => $eventStartValue, 'capacity' => $capacity, 'allow' => $allowKids, 'vis' => $_POST['visibility'] ?? 'public', 'status' => 'published', ]); $info = 'Event gespeichert.'; try { $cfgPath = dirname(__DIR__, 2) . '/config/community.php'; $communityCfg = file_exists($cfgPath) ? require $cfgPath : []; $community = new Community($pdo, $communityCfg); $community->addPoints($userId, 'event', 'create', ['event_id' => $pdo?->lastInsertId()]); } catch (\Throwable) { } } else { $eventId = (int)($_POST['event_id'] ?? 0); $stmt = $pdo?->prepare('UPDATE events SET title=:title, teaser_public=:teaser, description=:descr, category_slug=:categorySlug, image_path=:imagePath, location_label=:loc, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, max_participants=:capacity, allow_kids=:allow, visibility=:vis, updated_at=NOW() WHERE id=:id AND created_by=:uid'); $stmt?->execute([ 'id' => $eventId, 'uid' => $userId, 'title' => trim((string)$_POST['title']), 'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])), 'descr' => trim((string)$_POST['description']), 'categorySlug' => $categorySlug !== '' ? $categorySlug : null, 'imagePath' => $imagePath !== '' ? $imagePath : null, 'loc' => null, 'street' => $street ?: null, 'zip' => $zip, 'city' => $city, 'region' => $region, 'lat' => $lat, 'lng' => $lng, 'start' => $eventStartValue, 'capacity' => $capacity, 'allow' => $allowKids, 'vis' => $_POST['visibility'] ?? 'public', ]); $info = 'Event aktualisiert.'; } } } elseif ($action === 'event_delete') { $eventId = (int)($_POST['event_id'] ?? 0); $stmt = $pdo?->prepare('SELECT id, created_by, status, image_path, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = events.id) AS participant_count FROM events WHERE id = :id LIMIT 1'); $stmt?->execute(['id' => $eventId]); $ev = $stmt?->fetch(\PDO::FETCH_ASSOC); if (!$ev || (int)$ev['created_by'] !== $userId) { throw new \RuntimeException('Event nicht gefunden.'); } if ((int)$ev['participant_count'] > 0) { throw new \RuntimeException('Event hat Anmeldungen und kann nicht gelöscht werden.'); } self::deleteStoredImage((string)($ev['image_path'] ?? '')); $pdo?->prepare('DELETE FROM events WHERE id = :id')->execute(['id' => $eventId]); $info = 'Event gelöscht.'; } elseif ($action === 'event_cancel') { $eventId = (int)($_POST['event_id'] ?? 0); $stmt = $pdo?->prepare('SELECT id, created_by FROM events WHERE id = :id LIMIT 1'); $stmt?->execute(['id' => $eventId]); $ev = $stmt?->fetch(\PDO::FETCH_ASSOC); if (!$ev || (int)$ev['created_by'] !== $userId) { throw new \RuntimeException('Event nicht gefunden.'); } $pdo?->prepare('UPDATE events SET status = :st, updated_at = NOW() WHERE id = :id')->execute([ 'st' => 'cancelled', 'id' => $eventId, ]); $info = 'Event wurde abgesagt.'; } elseif ($action === 'listing_delete') { $listingId = (int)($_POST['listing_id'] ?? 0); if (!$listingCatalog) { throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.'); } $entry = $listingCatalog->getDashboardEntry($userId, $listingId); if (is_array($entry)) { self::deleteStoredImage((string)($entry['image_path'] ?? '')); } $listingCatalog->deleteDashboardEntry($userId, $listingId); $info = 'Eintrag gelöscht.'; } elseif ($action === 'community_admin_apply') { if (!$community || !$communityAccess) { throw new \RuntimeException('Community-Funktionen sind aktuell nicht verfügbar.'); } $points = $community->computePoints($userId); if (!$communityAccess->canApplyForForumAdmin($userId, $points)) { throw new \RuntimeException('Du kannst dich aktuell nicht als Forum-Admin bewerben.'); } $communityAccess->submitApplication($userId, (string)($_POST['motivation'] ?? '')); $info = 'Deine Bewerbung wurde eingereicht.'; } } catch (\Throwable $e) { $error = $e->getMessage(); } } // Daten laden $profile = [ 'display_name' => '', 'first_name' => '', 'last_name' => '', 'zip' => '', 'city' => '', 'region' => '', 'lat' => null, 'lng' => null, 'street' => '', 'profession' => '', 'languages' => '', 'about' => '', 'email' => '', 'contact_phone' => '', 'location_tracking_preference' => 'prompt', 'avatar_style' => AvatarManager::defaultStyle(), 'avatar_seed' => '', 'avatar_config_json' => '', 'avatar_preset' => 'papa-kind-treff', 'avatar_lorelei_eyes_variant' => '', 'avatar_lorelei_eyebrows_variant' => '', 'avatar_lorelei_mouth_variant' => '', 'avatar_lorelei_glasses_variant' => '', 'avatar_lorelei_hair_variant' => '', 'avatar_lorelei_beard_variant' => '', 'avatar_lorelei_earrings_variant' => '', ]; if ($profileSettings) { $profileSettings->ensureSchema(); } $avatarColumns = implode(', ', array_map( static fn(string $column): string => 'p.' . $column, AvatarManager::allProfileColumns() )); $stmt = $pdo?->prepare("SELECT u.status, p.display_name, p.first_name, p.last_name, p.street, p.zip, p.city, p.region, p.lat, p.lng, p.profession, p.languages, p.about, p.contact_phone, p.location_tracking_preference, $avatarColumns FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1"); $stmt?->execute(['id' => $userId]); $row = $stmt?->fetch(\PDO::FETCH_ASSOC); if ($row) { $profile = array_merge($profile, array_filter($row, fn($v) => $v !== null)); foreach (self::ENCRYPTED_PROFILE_FIELDS as $field) { $profile[$field] = self::decodeStoredProfileField( $profile[$field] ?? '', $crypto, $pdo, 'user_profiles', $field, 'user_id', $userId ); } } $profile['email'] = (new Auth($app))->getEmailByUserId($userId); $profile = AvatarManager::normalizeProfile($profile, $userId); $editChildId = isset($_GET['edit_child']) ? (int)$_GET['edit_child'] : 0; $editChild = null; $children = []; $stmt = $pdo?->prepare('SELECT id, encrypted_first_name AS first_name, note, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC'); $stmt?->execute(['id' => $userId]); $childrenRaw = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; foreach ($childrenRaw as $c) { $c['first_name'] = self::decodeStoredProfileField( $c['first_name'] ?? '', $crypto, $pdo, 'children', 'encrypted_first_name', 'id', (int)$c['id'] ); $c['note'] = self::decodeStoredProfileField( $c['note'] ?? '', $crypto, $pdo, 'children', 'note', 'id', (int)$c['id'] ); if (!empty($c['birthdate'])) { $calculatedAge = self::calculateAgeFromBirthdate((string)$c['birthdate']); if ($calculatedAge !== null) { $storedAge = isset($c['age_years']) && $c['age_years'] !== null ? (int)$c['age_years'] : null; $c['age_years'] = $calculatedAge; if ($storedAge !== $calculatedAge) { $updateChildAgeStmt = $updateChildAgeStmt ?? $pdo?->prepare('UPDATE children SET age_years = :age, updated_at = NOW() WHERE id = :id AND user_id = :uid'); $updateChildAgeStmt?->execute([ 'age' => $calculatedAge, 'id' => (int)$c['id'], 'uid' => $userId, ]); } } } $children[] = $c; if ($editChildId > 0 && (int)$c['id'] === $editChildId) { $editChild = $c; } } $eventsUpcoming = []; $eventsPast = []; $eventsJoinedUpcoming = []; $eventsJoinedPast = []; $editEvent = null; $otherListings = []; $editListing = null; $listingCategories = $listingCatalog ? $listingCatalog->listCategories(['general', 'place', 'food', 'event', 'family']) : []; $categoryReviewItems = $listingCatalog && $canManageSystemSettings ? $listingCatalog->listCategoryReviewItems() : []; $placeSuggestions = $listingCatalog ? $listingCatalog->listPlaceSuggestions(60) : []; $stmt = $pdo?->prepare( 'SELECT e.id, e.title, e.teaser_public, e.description, e.category_slug, e.image_path, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.max_participants, e.allow_kids, e.visibility, e.status, e.lat, e.lng, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count FROM events e WHERE e.created_by = :id AND e.starts_at >= NOW() ORDER BY e.starts_at ASC' ); $stmt?->execute(['id' => $userId]); $eventsUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; $stmt = $pdo?->prepare( 'SELECT e.id, e.title, e.teaser_public, e.image_path, e.starts_at, e.city, e.visibility, e.status, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count FROM events e WHERE e.created_by = :id AND e.starts_at < NOW() ORDER BY e.starts_at DESC' ); $stmt?->execute(['id' => $userId]); $eventsPast = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; $stmt = $pdo?->prepare( 'SELECT e.id, e.title, e.teaser_public, e.image_path, e.starts_at, e.city, e.visibility, e.status, ep.status AS participation_status, u.display_name AS host_name FROM event_participants ep INNER JOIN events e ON e.id = ep.event_id INNER JOIN users u ON u.id = e.created_by WHERE ep.user_id = :id AND e.created_by <> :id AND e.starts_at >= NOW() ORDER BY e.starts_at ASC' ); $stmt?->execute(['id' => $userId]); $eventsJoinedUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; $stmt = $pdo?->prepare( 'SELECT e.id, e.title, e.teaser_public, e.image_path, e.starts_at, e.city, e.visibility, e.status, ep.status AS participation_status, u.display_name AS host_name FROM event_participants ep INNER JOIN events e ON e.id = ep.event_id INNER JOIN users u ON u.id = e.created_by WHERE ep.user_id = :id AND e.created_by <> :id AND e.starts_at < NOW() ORDER BY e.starts_at DESC' ); $stmt?->execute(['id' => $userId]); $eventsJoinedPast = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; if (isset($_GET['edit_event'])) { $editId = (int)$_GET['edit_event']; $stmt = $pdo?->prepare('SELECT * FROM events WHERE id = :id AND created_by = :uid AND starts_at >= NOW() LIMIT 1'); $stmt?->execute(['id' => $editId, 'uid' => $userId]); $editEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null; } if ($listingCatalog) { $otherListings = $listingCatalog->listDashboardEntries($userId); if (isset($_GET['edit_listing'])) { $editListingId = (int)$_GET['edit_listing']; if ($editListingId > 0) { $editListing = $listingCatalog->getDashboardEntry($userId, $editListingId); } } } $communityPoints = $community ? $community->computePoints($userId) : 0.0; $communityLevel = $community ? $community->membershipLevel($communityPoints) : ['label' => '', 'icon' => '']; $communityRoles = $communityAccess ? $communityAccess->getUserRoles($userId) : []; $communityApplication = $communityAccess ? $communityAccess->getLatestApplication($userId) : null; $communityCanApply = $communityAccess ? $communityAccess->canApplyForForumAdmin($userId, $communityPoints) : false; $communityRestrictions = $communityAccess ? $communityAccess->getRestrictionState($userId) : [ 'thread_create_blocked' => false, 'reply_blocked' => false, 'reason' => null, ]; $systemSettingsValues = $systemSettings ? $systemSettings->getAll() : []; $listingCatalogStatus = $listingCatalog ? $listingCatalog->status() : ['complete' => false, 'missing' => [], 'tables' => []]; if (!in_array($section, $allowedSections, true)) { $section = 'profile'; } $avatarBuilder = AvatarManager::builderStyles($profile); return compact( 'flash', 'info', 'error', 'profile', 'children', 'editChild', 'eventsUpcoming', 'eventsPast', 'eventsJoinedUpcoming', 'eventsJoinedPast', 'editEvent', 'otherListings', 'editListing', 'listingCategories', 'categoryReviewItems', 'placeSuggestions', 'communityPoints', 'communityLevel', 'communityRoles', 'communityApplication', 'communityCanApply', 'communityRestrictions', 'canManageSystemSettings', 'systemSettingsValues', 'listingCatalogStatus', 'avatarBuilder', 'section', 'allowedSections' ); } private static function calculateAgeFromBirthdate(string $birthdate): ?int { try { $birthDateValue = new \DateTimeImmutable($birthdate); } catch (\Throwable) { return null; } $today = new \DateTimeImmutable('today'); if ($birthDateValue > $today) { return null; } return $birthDateValue->diff($today)->y; } private static function generateTeaser(string $description, string $title = ''): string { $source = trim($description) !== '' ? trim($description) : trim($title); if ($source === '') { return ''; } $collapsed = preg_replace('/\s+/u', ' ', $source) ?: $source; $collapsed = trim($collapsed); if (mb_strlen($collapsed) <= 140) { return $collapsed; } return rtrim(mb_substr($collapsed, 0, 137)) . '...'; } private static function requireCrypto(?Crypto $crypto, string $context): Crypto { if ($crypto instanceof Crypto) { return $crypto; } throw new \RuntimeException($context . '-Daten können derzeit nicht sicher verarbeitet werden. Bitte später erneut versuchen.'); } private static function encryptOptionalProfileField(Crypto $crypto, string $value): string { return $value !== '' ? $crypto->encrypt($value) : ''; } private static function decodeStoredProfileField( mixed $value, ?Crypto $crypto, ?\PDO $pdo, string $table, string $column, string $idColumn, int $id ): string { $rawValue = (string)$value; if ($rawValue === '') { return ''; } if (Crypto::looksEncrypted($rawValue)) { return $crypto ? ($crypto->decrypt($rawValue) ?: '') : ''; } if ($crypto && $pdo && $id > 0) { $stmt = $pdo->prepare(sprintf('UPDATE %s SET %s = :value, updated_at = NOW() WHERE %s = :id', $table, $column, $idColumn)); $stmt->execute([ 'value' => $crypto->encrypt($rawValue), 'id' => $id, ]); } return $rawValue; } private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array { $candidates = self::geocodeAddressCandidates($street, $zip, $city, $region, 1); if (!$candidates) { return [null, null, null]; } $best = $candidates[0]; return [$best['lat'], $best['lng'], $best['region'] !== '' ? $best['region'] : null]; } private static function geocodeAddressCandidates(?string $street, ?string $zip, ?string $city, ?string $region, int $limit = 5): array { $parts = array_filter([ $street ?: null, $zip ?: null, $city ?: null, $region ?: null, ]); if (!$parts) { return [null, null, null]; } $query = implode(', ', $parts); $url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([ 'format' => 'jsonv2', 'limit' => max(1, min(10, $limit)), 'addressdetails' => 1, 'q' => $query, ]); $resp = self::fetchGeoJson($url); if ($resp === null) { return []; } $json = json_decode($resp, true); if (!is_array($json)) { return []; } $results = []; foreach ($json as $item) { if (!is_array($item) || empty($item['lat']) || empty($item['lon'])) { continue; } $results[] = self::normalizeGeoAddressResult($item); } return $results; } private static function ensureLegacyEventSchema(\PDO $pdo): void { $columns = [ 'category_slug' => 'ALTER TABLE events ADD COLUMN category_slug VARCHAR(120) NULL AFTER description', 'image_path' => 'ALTER TABLE events ADD COLUMN image_path VARCHAR(255) NULL AFTER category_slug', ]; foreach ($columns as $column => $sql) { $stmt = $pdo->prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = "events" AND column_name = :column LIMIT 1'); $stmt->execute(['column' => $column]); if (!$stmt->fetchColumn()) { $pdo->exec($sql); } } } private static function storeUploadedImage(mixed $file, string $currentPath = ''): string { if (!is_array($file) || (int)($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { return $currentPath; } if ((int)($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { throw new \RuntimeException('Der Bild-Upload konnte nicht verarbeitet werden.'); } $tmpName = (string)($file['tmp_name'] ?? ''); if ($tmpName === '' || !is_uploaded_file($tmpName)) { throw new \RuntimeException('Die hochgeladene Datei ist ungültig.'); } if ((int)($file['size'] ?? 0) > 5 * 1024 * 1024) { throw new \RuntimeException('Bitte ein Bild mit maximal 5 MB hochladen.'); } $imageInfo = @getimagesize($tmpName); if (!is_array($imageInfo) || empty($imageInfo['mime'])) { throw new \RuntimeException('Bitte eine gültige Bilddatei hochladen.'); } $mime = (string)$imageInfo['mime']; $extension = match ($mime) { 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', default => '', }; if ($extension === '') { throw new \RuntimeException('Erlaubt sind JPG, PNG oder WebP.'); } $uploadDir = dirname(__DIR__, 2) . '/public/uploads/listings'; if (!is_dir($uploadDir) && !mkdir($uploadDir, 0775, true) && !is_dir($uploadDir)) { throw new \RuntimeException('Der Upload-Ordner konnte nicht erstellt werden.'); } $filename = 'listing-' . date('Ymd-His') . '-' . bin2hex(random_bytes(6)) . '.' . $extension; $targetPath = $uploadDir . '/' . $filename; if (!move_uploaded_file($tmpName, $targetPath)) { throw new \RuntimeException('Das Bild konnte nicht gespeichert werden.'); } self::deleteStoredImage($currentPath); return '/uploads/listings/' . $filename; } private static function deleteStoredImage(string $path): void { $path = trim($path); if ($path === '' || !str_starts_with($path, '/uploads/listings/')) { return; } $absolutePath = dirname(__DIR__, 2) . '/public' . $path; if (is_file($absolutePath)) { @unlink($absolutePath); } } private static function slugifyValue(string $value): string { $value = mb_strtolower(trim($value)); $value = strtr($value, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']); $value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?: ''; return trim($value, '-'); } private static function normalizeDateForStorage(string $value, bool $endOfDay = false): ?string { $value = trim($value); if ($value === '') { return null; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) { return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00'); } return $value; } private static function reverseGeocodeAddress(float $lat, float $lng): ?array { $url = 'https://nominatim.openstreetmap.org/reverse?' . http_build_query([ 'format' => 'jsonv2', 'addressdetails' => 1, 'lat' => $lat, 'lon' => $lng, ]); $resp = self::fetchGeoJson($url); if ($resp === null) { return null; } $json = json_decode($resp, true); if (!is_array($json) || empty($json['lat']) || empty($json['lon'])) { return null; } return self::normalizeGeoAddressResult($json); } private static function normalizeGeoAddressResult(array $item): array { $addr = is_array($item['address'] ?? null) ? $item['address'] : []; $street = trim(implode(' ', array_filter([ (string)($addr['road'] ?? ''), (string)($addr['house_number'] ?? ''), ]))); $city = (string)($addr['city'] ?? $addr['town'] ?? $addr['village'] ?? ''); $region = (string)($addr['city_district'] ?? $addr['suburb'] ?? $addr['state'] ?? $addr['county'] ?? $addr['region'] ?? $addr['state_district'] ?? ''); return [ 'label' => trim((string)($item['display_name'] ?? '')), 'street' => $street, 'zip' => (string)($addr['postcode'] ?? ''), 'city' => $city, 'region' => $region, 'lat' => round((float)$item['lat'], 7), 'lng' => round((float)$item['lon'], 7), ]; } private static function fetchGeoJson(string $url): ?string { $ctx = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => "User-Agent: papa-kind-treff/1.0\r\nAccept-Language: de\r\n", 'timeout' => 6, ], ]); $resp = @file_get_contents($url, false, $ctx); return $resp === false ? null : $resp; } }