calendarManager = $calendarManager; $this->userSession = $userSession; $this->urlGenerator = $urlGenerator; $this->dateTimeZone = $dateTimeZone; } /** * Show event confirmation. * * The route itself requires an authenticated Nextcloud user. * If the user is not authenticated, Nextcloud's normal * authentication flow takes over before the controller executes. * * NoCSRFRequired: Page will be called by link from external application * browser does not send request token, yet. */ #[NoAdminRequired] #[NoCSRFRequired] public function confirm( string $summary = '', string $description = '', string $start = '', string $end = '', string $organizer = '', string $location = '', string $url = '' ): TemplateResponse { $data = $this->validateEventData( $summary, $description, $start, $end, $organizer, $location, $url ); return $this->renderConfirm($data); } /** * Create the calendar event. * * CSRF remains active: the confirmation page form * sends the request token along. */ #[NoAdminRequired] public function create( string $summary = '', string $description = '', string $start = '', string $end = '', string $organizer = '', string $location = '', string $url = '' ): RedirectResponse|TemplateResponse { $data = $this->validateEventData( $summary, $description, $start, $end, $organizer, $location, $url ); $user = $this->userSession->getUser(); if ($user === null) { throw new \RuntimeException('No authenticated Nextcloud user.'); } $calendar = $this->findWritableCalendar($user->getUID()); /* * Friendly error instead of a hard server error page. */ if ($calendar === null) { return $this->renderConfirm( $data, 'No writable calendar named "' . self::CALENDAR_NAME . '" was found in your account. Create it in the ' . 'Nextcloud Calendar app, then try again.' ); } $this->createEvent($calendar, $data); /* * Open the Nextcloud Calendar application. */ $calendarUrl = $this->urlGenerator->linkToRoute( 'calendar.view.index' ); return new RedirectResponse($calendarUrl); } /** * The user's "Personal" calendar. * * getCalendarsForPrincipal() returns ICalendar objects. * Only calendars implementing ICreateFromString are writable. */ private function findWritableCalendar(string $userId): ?ICreateFromString { /* * IManager calendars are queried via the principal URI. */ $principalUri = 'principals/users/' . $userId; $calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri); foreach ($calendars as $calendar) { if (!$calendar instanceof ICreateFromString) { continue; } if ($calendar->getDisplayName() === self::CALENDAR_NAME) { return $calendar; } } return null; } /** * Render the confirmation page, optionally with an error message. */ private function renderConfirm(array $data, string $error = ''): TemplateResponse { return new TemplateResponse( Application::APP_ID, 'confirm', [ 'summary' => $data['summary'], 'description' => $data['description'], 'start' => $data['start']->format(self::DATETIME_LOCAL), 'end' => $data['end']->format(self::DATETIME_LOCAL), 'organizer' => $data['organizer'], 'location' => $data['location'], 'url' => $data['url'], 'error' => $error, 'cancelUrl' => $this->urlGenerator->getAbsoluteURL('/'), 'createUrl' => $this->urlGenerator->linkToRoute( Application::APP_ID . '.calendar.create' ), ] ); } /** * Create the event through the platform event builder, * which handles escaping and ICS serialization. */ private function createEvent(ICreateFromString $calendar, array $data): void { $builder = $this->calendarManager->createEventBuilder(); $builder->setStartDate($data['start']); $builder->setEndDate($data['end']); $builder->setSummary($data['summary']); $description = $this->mergeUrlIntoDescription($data); if ($description !== '') { $builder->setDescription($description); } if ($data['location'] !== '') { $builder->setLocation($data['location']); } if ($data['organizer'] !== '') { $builder->setOrganizer($data['organizer']); } /* * ICalendarEventBuilder supports no alarms, so the VALARM * is injected into the serialized ICS before writing it. */ $vobject = Reader::read($builder->toIcs()); $vobject->VEVENT->add('VALARM', [ 'ACTION' => 'DISPLAY', 'DESCRIPTION' => 'Reminder', 'TRIGGER' => self::REMINDER_TRIGGER, ]); $fileName = (string) $vobject->VEVENT->UID . '.ics'; $calendar->createFromString($fileName, $vobject->serialize()); } /** * The event builder has no URL property, so a URL that * differs from the location is kept in the description. */ private function mergeUrlIntoDescription(array $data): string { $urlOnly = $data['url'] !== '' && $data['url'] !== $data['location']; if (!$urlOnly) { return $data['description']; } $prefix = $data['description'] === '' ? '' : $data['description'] . "\n\n"; return $prefix . $data['url']; } /** * Validate and normalize all event input. */ private function validateEventData( string $summary, string $description, string $start, string $end, string $organizer, string $location, string $url ): array { $summary = trim($summary); $description = trim($description); $start = trim($start); $end = trim($end); $organizer = trim($organizer); $location = trim($location); $url = trim($url); if ($summary === '') { throw new \InvalidArgumentException( 'Meeting title is required.' ); } if (mb_strlen($summary) > 255) { throw new \InvalidArgumentException( 'Meeting title is too long.' ); } if ($description !== '' && mb_strlen($description) > 10000) { throw new \InvalidArgumentException( 'Meeting description is too long.' ); } $startDate = $this->parseLocalDateTime($start); $endDate = $this->parseLocalDateTime($end); if ($endDate <= $startDate) { throw new \InvalidArgumentException( 'End time must be after start time.' ); } if ($location !== '') { $this->assertHttpsUrl($location, 'Location must be an HTTPS URL.'); } if ($url !== '') { $this->assertHttpsUrl($url, 'Meeting URL must be an HTTPS URL.'); } if ($organizer !== '' && !filter_var( $organizer, FILTER_VALIDATE_EMAIL )) { throw new \InvalidArgumentException( 'Invalid organizer email address.' ); } return [ 'summary' => $summary, 'description' => $description, 'start' => $startDate, 'end' => $endDate, 'organizer' => $organizer, 'location' => $location, 'url' => $url, ]; } private function assertHttpsUrl(string $value, string $message): void { $parts = parse_url($value); $valid = $parts !== false && isset($parts['scheme']) && strtolower($parts['scheme']) === 'https'; if (!$valid) { throw new \InvalidArgumentException($message); } } /** * Parse datetime-local values. * * datetime-local carries no timezone, so it is interpreted * in the user's Nextcloud timezone. It falls back to the * server default timezone if the user has none set. */ private function parseLocalDateTime(string $value): \DateTimeImmutable { $timeZone = $this->dateTimeZone->getTimeZone(); $date = \DateTimeImmutable::createFromFormat( self::DATETIME_LOCAL, $value, $timeZone ); if ($date === false) { $date = \DateTimeImmutable::createFromFormat( 'Y-m-d\TH:i:s', $value, $timeZone ); } if ($date === false) { throw new \InvalidArgumentException( 'Invalid date/time.' ); } return $date; } }