last change
All checks were successful
Deploy / deploy (push) Successful in 56s

This commit is contained in:
2026-08-07 23:43:36 +02:00
parent c9b1839460
commit a102b2aa0c
15 changed files with 486 additions and 1 deletions

View File

@@ -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'
);

366
src/App/CalendarSync.php Normal file
View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace App;
final class CalendarSync
{
public function __construct(private App $app)
{
}
public function ensureSchema(): void
{
$this->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());
}
}