This commit is contained in:
@@ -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
366
src/App/CalendarSync.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user