855 lines
40 KiB
PHP
Executable File
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;
|
|
}
|
|
}
|