Files
papa-kind-treff.info/src/App/AccountPages.php
Lars Gebhardt-Kusche 1a58ec91de
All checks were successful
Deploy / deploy (push) Successful in 55s
yxcyxc
2026-08-04 01:20:59 +02:00

855 lines
40 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace App;
use App\Avatar\AvatarManager;
use App\Avatar\Lorelei;
final class AccountPages
{
private const ENCRYPTED_PROFILE_FIELDS = [
'first_name',
'last_name',
'street',
'contact_phone',
'profession',
'languages',
'about',
];
public static function register(App $app): array
{
$flash = $app->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', 'community', 'settings'];
if ($canManageSystemSettings) {
$allowedSections[] = 'system';
}
if ($systemSettings) {
$systemSettings->ensureSchema();
}
if ($listingCatalog) {
$listingCatalog->ensureSchema();
}
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 === '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.');
}
$listingCatalog->saveDashboardEntry(
$userId,
$_POST,
$action === 'event_update' ? (int)($_POST['listing_id'] ?? 0) : null
);
$info = $entryKind === 'place' ? 'Ort gespeichert.' : 'Veranstaltung gespeichert.';
} else {
$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;
$needsGeocode = ($lat === null || $lng === null || $region === '');
if ($needsGeocode) {
[$geoLat, $geoLng, $geoRegion] = self::geocodeAddress($street, $zip, $city, $region);
if ($lat === null) { $lat = $geoLat; }
if ($lng === null) { $lng = $geoLng; }
if ($region === '' && $geoRegion) { $region = $geoRegion; }
}
if ($action === 'event_add') {
$stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, location_label, street, zip, city, region, lat, lng, starts_at, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :allow, :vis, :status, NOW(), NOW())');
$stmt?->execute([
'uid' => $userId,
'title' => trim((string)$_POST['title']),
'teaser' => trim((string)$_POST['teaser']),
'descr' => trim((string)$_POST['description']),
'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null,
'zip' => $zip,
'city' => $city,
'region' => $region,
'lat' => $lat,
'lng' => $lng,
'start' => $_POST['starts_at'] ?? null,
'allow' => isset($_POST['allow_kids']) ? 0 : 1,
'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, location_label=:loc, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, 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' => trim((string)$_POST['teaser']),
'descr' => trim((string)$_POST['description']),
'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null,
'zip' => $zip,
'city' => $city,
'region' => $region,
'lat' => $lat,
'lng' => $lng,
'start' => $_POST['starts_at'] ?? null,
'allow' => isset($_POST['allow_kids']) ? 0 : 1,
'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, (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.');
}
$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.');
}
$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 = [];
$editEvent = null;
$otherListings = [];
$editListing = null;
$listingCategories = $listingCatalog ? $listingCatalog->listCategories(['place', 'food', 'event', 'family']) : [];
$stmt = $pdo?->prepare(
'SELECT e.id, e.title, e.teaser_public, e.description, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, 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.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) ?: [];
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',
'editEvent',
'otherListings',
'editListing',
'listingCategories',
'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 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 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;
}
}