Datenschutz & Cookies
-Stand: 4. August 2026
+Stand: 5. August 2026
Diese Hinweise erklären, welche personenbezogenen Daten beim Besuch und bei der Nutzung von Papa-Kind-Treff @@ -62,6 +62,12 @@ $clientCookie = $config->cookiePrefix() . 'client'; Soweit es sich um sensible oder besonders persönliche Profildaten handelt, werden diese innerhalb der Anwendung verschlüsselt gespeichert und verarbeitet.
++ Wenn du deine Events in einen privaten Kalender exportierst oder einen persönlichen Kalender-Feed abonnierst, + werden dabei ausschließlich deine eigenen Events und deine Event-Teilnahmen als Kalenderdaten bereitgestellt. + Der abonnierbare Feed ist über eine persönliche, nicht öffentliche URL abgesichert. Diese URL sollte vertraulich + behandelt und nicht an Dritte weitergegeben werden. +
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, soweit die Verarbeitung für die Durchführung des Nutzungsverhältnisses erforderlich ist. diff --git a/schema.sql b/schema.sql index 3c5a938..624ce8a 100755 --- a/schema.sql +++ b/schema.sql @@ -424,6 +424,17 @@ CREATE TABLE user_tokens ( INDEX idx_ut_type (type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE user_calendar_feeds ( + user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + token_encrypted TEXT NOT NULL, + token_lookup_hash CHAR(64) NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + rotated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at DATETIME NULL, + CONSTRAINT fk_user_calendar_feed_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_calendar_feeds_lookup (token_lookup_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Audit-Log für wichtige Aktionen CREATE TABLE audit_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php index 89baf44..a34a3ec 100755 --- a/src/App/AccountPages.php +++ b/src/App/AccountPages.php @@ -176,6 +176,7 @@ final class AccountPages $profileSettings = $pdo ? new ProfileSettings($pdo) : null; $systemSettings = $pdo ? new SystemSettings($pdo) : null; $listingCatalog = $pdo ? new ListingCatalog($pdo) : null; + $calendarSync = $pdo ? new CalendarSync($app) : null; $section = (string)($_GET['section'] ?? 'profile'); $canManageSystemSettings = $communityAccess ? $communityAccess->canManageApplications($userId) : false; $allowedSections = ['profile', 'children', 'events', 'places', 'community', 'settings']; @@ -189,6 +190,9 @@ final class AccountPages if ($listingCatalog) { $listingCatalog->ensureSchema(); } + if ($calendarSync) { + $calendarSync->ensureSchema(); + } if ($pdo) { self::ensureLegacyEventSchema($pdo); } @@ -785,6 +789,13 @@ final class AccountPages $section = 'profile'; } $avatarBuilder = AvatarManager::builderStyles($profile); + $calendarExportUrl = null; + $calendarFeedUrl = null; + if ($calendarSync) { + $calendarToken = $calendarSync->getOrCreateFeedToken($userId); + $calendarExportUrl = CalendarSync::buildAbsoluteUrl('/calendar/export'); + $calendarFeedUrl = CalendarSync::buildAbsoluteUrl('/calendar/feed?token=' . rawurlencode($calendarToken)); + } return compact( 'flash', @@ -813,6 +824,8 @@ final class AccountPages 'systemSettingsValues', 'listingCatalogStatus', 'avatarBuilder', + 'calendarExportUrl', + 'calendarFeedUrl', 'section', 'allowedSections' ); diff --git a/src/App/CalendarSync.php b/src/App/CalendarSync.php new file mode 100644 index 0000000..b21fbad --- /dev/null +++ b/src/App/CalendarSync.php @@ -0,0 +1,366 @@ +pdo()->exec( + 'CREATE TABLE IF NOT EXISTS user_calendar_feeds ( + user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + token_encrypted TEXT NOT NULL, + token_lookup_hash CHAR(64) NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + rotated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at DATETIME NULL, + CONSTRAINT fk_user_calendar_feed_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_calendar_feeds_lookup (token_lookup_hash) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + } + + public function getOrCreateFeedToken(int $userId): string + { + $existing = $this->getFeedToken($userId); + if ($existing !== null) { + return $existing; + } + + $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + $crypto = $this->crypto(); + $stmt = $this->pdo()->prepare( + 'INSERT INTO user_calendar_feeds (user_id, token_encrypted, token_lookup_hash, created_at, rotated_at) + VALUES (:userId, :tokenEncrypted, :tokenLookupHash, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + token_encrypted = VALUES(token_encrypted), + token_lookup_hash = VALUES(token_lookup_hash), + rotated_at = NOW()' + ); + $stmt->execute([ + 'userId' => $userId, + 'tokenEncrypted' => $crypto->encrypt($token), + 'tokenLookupHash' => hash('sha256', $token), + ]); + + return $token; + } + + public function getFeedToken(int $userId): ?string + { + $stmt = $this->pdo()->prepare('SELECT token_encrypted FROM user_calendar_feeds WHERE user_id = :userId LIMIT 1'); + $stmt->execute(['userId' => $userId]); + $encrypted = $stmt->fetchColumn(); + if (!is_string($encrypted) || trim($encrypted) === '') { + return null; + } + + $token = $this->crypto()->decrypt($encrypted); + return is_string($token) && trim($token) !== '' ? $token : null; + } + + public function findUserIdByFeedToken(string $token): ?int + { + $token = trim($token); + if ($token === '') { + return null; + } + + $stmt = $this->pdo()->prepare( + 'SELECT user_id + FROM user_calendar_feeds + WHERE token_lookup_hash = :tokenLookupHash + LIMIT 1' + ); + $stmt->execute(['tokenLookupHash' => hash('sha256', $token)]); + $userId = $stmt->fetchColumn(); + + return $userId !== false ? (int)$userId : null; + } + + public function touchFeedAccess(int $userId): void + { + $stmt = $this->pdo()->prepare( + 'UPDATE user_calendar_feeds + SET last_accessed_at = NOW() + WHERE user_id = :userId' + ); + $stmt->execute(['userId' => $userId]); + } + + public function listCalendarEventsForUser(int $userId): array + { + $stmt = $this->pdo()->prepare( + 'SELECT e.id, e.created_by, e.title, e.description, e.category_slug, e.street, e.zip, e.city, e.region, + e.starts_at, e.ends_at, e.visibility, e.status, e.allow_kids, + "owner" AS relation_type, + NULL AS participation_status, + NULL AS host_name + FROM events e + WHERE e.created_by = :userId + + UNION ALL + + SELECT e.id, e.created_by, e.title, e.description, e.category_slug, e.street, e.zip, e.city, e.region, + e.starts_at, e.ends_at, e.visibility, e.status, e.allow_kids, + "participant" AS relation_type, + ep.status AS participation_status, + COALESCE(up.display_name, "Mitglied") 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 + LEFT JOIN user_profiles up ON up.user_id = u.id + WHERE ep.user_id = :participantId + AND e.created_by <> :ownerId + AND ep.status <> "cancelled" + + ORDER BY starts_at ASC, id ASC' + ); + $stmt->execute([ + 'userId' => $userId, + 'participantId' => $userId, + 'ownerId' => $userId, + ]); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + } + + public function renderUserCalendarIcs(int $userId, string $calendarName = 'Papa-Kind-Treff Events'): string + { + return $this->renderCalendarIcs($calendarName, $this->listCalendarEventsForUser($userId)); + } + + public function renderCalendarIcs(string $calendarName, array $events): string + { + $lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Papa-Kind-Treff//Kalender//DE', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'X-WR-CALNAME:' . $this->escapeText($calendarName), + 'X-WR-TIMEZONE:Europe/Berlin', + ]; + + foreach ($events as $event) { + $lines = array_merge($lines, $this->buildEventLines($event)); + } + + $lines[] = 'END:VCALENDAR'; + + return $this->foldLines($lines); + } + + public static function buildAbsoluteUrl(string $path): string + { + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = (string)($_SERVER['HTTP_HOST'] ?? 'localhost'); + + return $scheme . '://' . $host . $path; + } + + private function buildEventLines(array $event): array + { + $lines = [ + 'BEGIN:VEVENT', + 'UID:' . $this->buildUid((int)($event['id'] ?? 0), (string)($event['relation_type'] ?? 'event')), + 'DTSTAMP:' . gmdate('Ymd\THis\Z'), + 'SUMMARY:' . $this->escapeText((string)($event['title'] ?? 'Papa-Kind-Treff Event')), + ]; + + $startAt = trim((string)($event['starts_at'] ?? '')); + $endAt = trim((string)($event['ends_at'] ?? '')); + $isAllDay = $this->isAllDayEvent($startAt, $endAt); + if ($isAllDay) { + $startDate = $this->toDateString($startAt); + if ($startDate !== null) { + $lines[] = 'DTSTART;VALUE=DATE:' . $startDate; + $lines[] = 'DTEND;VALUE=DATE:' . $this->incrementDateString($startDate); + } + } else { + $startDateTime = $this->toDateTimeString($startAt); + if ($startDateTime !== null) { + $lines[] = 'DTSTART;TZID=Europe/Berlin:' . $startDateTime; + } + + $endDateTime = $this->toDateTimeString($endAt); + if ($endDateTime !== null) { + $lines[] = 'DTEND;TZID=Europe/Berlin:' . $endDateTime; + } + } + + $description = $this->buildDescription($event); + if ($description !== '') { + $lines[] = 'DESCRIPTION:' . $this->escapeText($description); + } + + $location = $this->buildLocation($event); + if ($location !== '') { + $lines[] = 'LOCATION:' . $this->escapeText($location); + } + + if (($event['status'] ?? '') === 'cancelled') { + $lines[] = 'STATUS:CANCELLED'; + } else { + $lines[] = 'STATUS:CONFIRMED'; + } + + $lines[] = 'END:VEVENT'; + + return $lines; + } + + private function buildDescription(array $event): string + { + $parts = []; + + $description = trim((string)($event['description'] ?? '')); + if ($description !== '') { + $parts[] = $description; + } + + $meta = []; + if (($event['relation_type'] ?? '') === 'owner') { + $meta[] = 'Typ: Eigenes Event'; + } elseif (($event['relation_type'] ?? '') === 'participant') { + $meta[] = 'Typ: Teilnahme an fremdem Event'; + } + + if (!empty($event['host_name'])) { + $meta[] = 'Veranstalter: ' . trim((string)$event['host_name']); + } + if (!empty($event['participation_status'])) { + $meta[] = 'Teilnahmestatus: ' . trim((string)$event['participation_status']); + } + if (!empty($event['category_slug'])) { + $meta[] = 'Kategorie: ' . trim((string)$event['category_slug']); + } + $meta[] = !empty($event['allow_kids']) ? 'Mit Kindern: Ja' : 'Mit Kindern: Nein'; + $meta[] = 'Sichtbarkeit: ' . (($event['visibility'] ?? 'public') === 'members' ? 'Nur Mitglieder' : 'Öffentlich'); + if (($event['status'] ?? '') === 'cancelled') { + $meta[] = 'Status: Abgesagt'; + } + + if ($meta !== []) { + $parts[] = implode("\n", $meta); + } + + return trim(implode("\n\n", $parts)); + } + + private function buildLocation(array $event): string + { + $parts = array_filter([ + trim((string)($event['street'] ?? '')), + trim((string)implode(' ', array_filter([ + (string)($event['zip'] ?? ''), + (string)($event['city'] ?? ''), + ]))), + trim((string)($event['region'] ?? '')), + ]); + + return trim(implode(', ', $parts)); + } + + private function buildUid(int $eventId, string $relationType): string + { + $host = preg_replace('/[^a-z0-9.-]+/i', '-', (string)($_SERVER['HTTP_HOST'] ?? 'papa-kind-treff.local')) ?: 'papa-kind-treff.local'; + + return sprintf('event-%d-%s@%s', $eventId, $relationType, $host); + } + + private function isAllDayEvent(string $startAt, string $endAt): bool + { + if ($startAt === '') { + return false; + } + + $startTime = substr($startAt, 11, 8); + $endTime = $endAt !== '' ? substr($endAt, 11, 8) : ''; + + return $startTime === '' || $startTime === '00:00:00' || $startTime === '23:59:59' + ? ($endAt === '' || $endTime === '00:00:00' || $endTime === '23:59:59') + : false; + } + + private function toDateString(string $value): ?string + { + if ($value === '') { + return null; + } + + try { + return (new \DateTimeImmutable($value))->format('Ymd'); + } catch (\Throwable) { + return null; + } + } + + private function incrementDateString(string $dateString): string + { + $date = \DateTimeImmutable::createFromFormat('Ymd', $dateString); + if (!$date instanceof \DateTimeImmutable) { + return $dateString; + } + + return $date->modify('+1 day')->format('Ymd'); + } + + private function toDateTimeString(string $value): ?string + { + if ($value === '') { + return null; + } + + try { + return (new \DateTimeImmutable($value, new \DateTimeZone('Europe/Berlin')))->format('Ymd\THis'); + } catch (\Throwable) { + return null; + } + } + + private function escapeText(string $value): string + { + $value = str_replace(["\r\n", "\r"], "\n", trim($value)); + $value = str_replace('\\', '\\\\', $value); + $value = str_replace(';', '\;', $value); + $value = str_replace(',', '\,', $value); + + return str_replace("\n", '\n', $value); + } + + private function foldLines(array $lines): string + { + $output = []; + foreach ($lines as $line) { + $line = (string)$line; + while (strlen($line) > 75) { + $output[] = substr($line, 0, 75); + $line = ' ' . substr($line, 75); + } + $output[] = $line; + } + + return implode("\r\n", $output) . "\r\n"; + } + + private function pdo(): \PDO + { + $pdo = $this->app->pdo(); + if (!$pdo instanceof \PDO) { + throw new \RuntimeException('Datenbankverbindung nicht verfügbar.'); + } + + return $pdo; + } + + private function crypto(): Crypto + { + return new Crypto($this->app->config()); + } +}