diff options
35 files changed, 549 insertions, 79 deletions
diff --git a/apps/settings/lib/Controller/LogSettingsController.php b/apps/settings/lib/Controller/LogSettingsController.php index aa5ac9b2cc9..7cf8d631c8e 100644 --- a/apps/settings/lib/Controller/LogSettingsController.php +++ b/apps/settings/lib/Controller/LogSettingsController.php @@ -27,9 +27,7 @@ class LogSettingsController extends Controller { /** * download logfile * - * @psalm-suppress MoreSpecificReturnType The value of Content-Disposition is not relevant - * @psalm-suppress LessSpecificReturnStatement The value of Content-Disposition is not relevant - * @return StreamResponse<Http::STATUS_OK, array{Content-Type: 'application/octet-stream', 'Content-Disposition': string}> + * @return StreamResponse<Http::STATUS_OK, array{Content-Type: 'application/octet-stream', 'Content-Disposition': 'attachment; filename="nextcloud.log"'}> * * 200: Logfile returned */ @@ -38,11 +36,13 @@ class LogSettingsController extends Controller { if (!$this->log instanceof Log) { throw new \UnexpectedValueException('Log file not available'); } - $resp = new StreamResponse($this->log->getLogPath()); - $resp->setHeaders([ - 'Content-Type' => 'application/octet-stream', - 'Content-Disposition' => 'attachment; filename="nextcloud.log"', - ]); - return $resp; + return new StreamResponse( + $this->log->getLogPath(), + Http::STATUS_OK, + [ + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename="nextcloud.log"', + ], + ); } } diff --git a/apps/settings/openapi-administration.json b/apps/settings/openapi-administration.json index 090ef865371..1f6eb0bc652 100644 --- a/apps/settings/openapi-administration.json +++ b/apps/settings/openapi-administration.json @@ -44,7 +44,10 @@ "headers": { "Content-Disposition": { "schema": { - "type": "string" + "type": "string", + "enum": [ + "attachment; filename=\"nextcloud.log\"" + ] } } }, diff --git a/apps/settings/openapi-full.json b/apps/settings/openapi-full.json index b5cbfda7096..e12598a2584 100644 --- a/apps/settings/openapi-full.json +++ b/apps/settings/openapi-full.json @@ -221,7 +221,10 @@ "headers": { "Content-Disposition": { "schema": { - "type": "string" + "type": "string", + "enum": [ + "attachment; filename=\"nextcloud.log\"" + ] } } }, diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index fd5d9e62ba6..200e2e75612 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -192,6 +192,7 @@ return array( 'OCP\\Calendar\\BackendTemporarilyUnavailableException' => $baseDir . '/lib/public/Calendar/BackendTemporarilyUnavailableException.php', 'OCP\\Calendar\\Exceptions\\CalendarException' => $baseDir . '/lib/public/Calendar/Exceptions/CalendarException.php', 'OCP\\Calendar\\ICalendar' => $baseDir . '/lib/public/Calendar/ICalendar.php', + 'OCP\\Calendar\\ICalendarEventBuilder' => $baseDir . '/lib/public/Calendar/ICalendarEventBuilder.php', 'OCP\\Calendar\\ICalendarIsShared' => $baseDir . '/lib/public/Calendar/ICalendarIsShared.php', 'OCP\\Calendar\\ICalendarIsWritable' => $baseDir . '/lib/public/Calendar/ICalendarIsWritable.php', 'OCP\\Calendar\\ICalendarProvider' => $baseDir . '/lib/public/Calendar/ICalendarProvider.php', @@ -1116,6 +1117,7 @@ return array( 'OC\\Broadcast\\Events\\BroadcastEvent' => $baseDir . '/lib/private/Broadcast/Events/BroadcastEvent.php', 'OC\\Cache\\CappedMemoryCache' => $baseDir . '/lib/private/Cache/CappedMemoryCache.php', 'OC\\Cache\\File' => $baseDir . '/lib/private/Cache/File.php', + 'OC\\Calendar\\CalendarEventBuilder' => $baseDir . '/lib/private/Calendar/CalendarEventBuilder.php', 'OC\\Calendar\\CalendarQuery' => $baseDir . '/lib/private/Calendar/CalendarQuery.php', 'OC\\Calendar\\Manager' => $baseDir . '/lib/private/Calendar/Manager.php', 'OC\\Calendar\\Resource\\Manager' => $baseDir . '/lib/private/Calendar/Resource/Manager.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 092b03d7641..bf9385c1741 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -233,6 +233,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Calendar\\BackendTemporarilyUnavailableException' => __DIR__ . '/../../..' . '/lib/public/Calendar/BackendTemporarilyUnavailableException.php', 'OCP\\Calendar\\Exceptions\\CalendarException' => __DIR__ . '/../../..' . '/lib/public/Calendar/Exceptions/CalendarException.php', 'OCP\\Calendar\\ICalendar' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendar.php', + 'OCP\\Calendar\\ICalendarEventBuilder' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarEventBuilder.php', 'OCP\\Calendar\\ICalendarIsShared' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarIsShared.php', 'OCP\\Calendar\\ICalendarIsWritable' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarIsWritable.php', 'OCP\\Calendar\\ICalendarProvider' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarProvider.php', @@ -1157,6 +1158,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Broadcast\\Events\\BroadcastEvent' => __DIR__ . '/../../..' . '/lib/private/Broadcast/Events/BroadcastEvent.php', 'OC\\Cache\\CappedMemoryCache' => __DIR__ . '/../../..' . '/lib/private/Cache/CappedMemoryCache.php', 'OC\\Cache\\File' => __DIR__ . '/../../..' . '/lib/private/Cache/File.php', + 'OC\\Calendar\\CalendarEventBuilder' => __DIR__ . '/../../..' . '/lib/private/Calendar/CalendarEventBuilder.php', 'OC\\Calendar\\CalendarQuery' => __DIR__ . '/../../..' . '/lib/private/Calendar/CalendarQuery.php', 'OC\\Calendar\\Manager' => __DIR__ . '/../../..' . '/lib/private/Calendar/Manager.php', 'OC\\Calendar\\Resource\\Manager' => __DIR__ . '/../../..' . '/lib/private/Calendar/Resource/Manager.php', diff --git a/lib/private/AppFramework/OCS/BaseResponse.php b/lib/private/AppFramework/OCS/BaseResponse.php index 3b0a28fe89c..cc7f7845760 100644 --- a/lib/private/AppFramework/OCS/BaseResponse.php +++ b/lib/private/AppFramework/OCS/BaseResponse.php @@ -11,10 +11,10 @@ use OCP\AppFramework\Http\Response; /** * @psalm-import-type DataResponseType from DataResponse - * @template S of int + * @template S of Http::STATUS_* * @template-covariant T of DataResponseType * @template H of array<string, mixed> - * @template-extends Response<int, array<string, mixed>> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> */ abstract class BaseResponse extends Response { /** @var array */ diff --git a/lib/private/AppFramework/OCS/V1Response.php b/lib/private/AppFramework/OCS/V1Response.php index c56aa9cf478..131ca22ff24 100644 --- a/lib/private/AppFramework/OCS/V1Response.php +++ b/lib/private/AppFramework/OCS/V1Response.php @@ -11,17 +11,17 @@ use OCP\AppFramework\OCSController; /** * @psalm-import-type DataResponseType from DataResponse - * @template S of int + * @template S of Http::STATUS_* * @template-covariant T of DataResponseType * @template H of array<string, mixed> - * @template-extends BaseResponse<int, DataResponseType, array<string, mixed>> + * @template-extends BaseResponse<Http::STATUS_*, DataResponseType, array<string, mixed>> */ class V1Response extends BaseResponse { /** * The V1 endpoint has very limited http status codes basically everything * is status 200 except 401 * - * @return int + * @return Http::STATUS_* */ public function getStatus() { $status = parent::getStatus(); diff --git a/lib/private/AppFramework/OCS/V2Response.php b/lib/private/AppFramework/OCS/V2Response.php index caa8302a673..47cf0f60200 100644 --- a/lib/private/AppFramework/OCS/V2Response.php +++ b/lib/private/AppFramework/OCS/V2Response.php @@ -11,17 +11,17 @@ use OCP\AppFramework\OCSController; /** * @psalm-import-type DataResponseType from DataResponse - * @template S of int + * @template S of Http::STATUS_* * @template-covariant T of DataResponseType * @template H of array<string, mixed> - * @template-extends BaseResponse<int, DataResponseType, array<string, mixed>> + * @template-extends BaseResponse<Http::STATUS_*, DataResponseType, array<string, mixed>> */ class V2Response extends BaseResponse { /** * The V2 endpoint just passes on status codes. * Of course we have to map the OCS specific codes to proper HTTP status codes * - * @return int + * @return Http::STATUS_* */ public function getStatus() { $status = parent::getStatus(); diff --git a/lib/private/Calendar/CalendarEventBuilder.php b/lib/private/Calendar/CalendarEventBuilder.php new file mode 100644 index 00000000000..9198d383ef9 --- /dev/null +++ b/lib/private/Calendar/CalendarEventBuilder.php @@ -0,0 +1,132 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Calendar; + +use DateTimeInterface; +use InvalidArgumentException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Calendar\ICalendarEventBuilder; +use OCP\Calendar\ICreateFromString; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; + +class CalendarEventBuilder implements ICalendarEventBuilder { + private ?DateTimeInterface $startDate = null; + private ?DateTimeInterface $endDate = null; + private ?string $summary = null; + private ?string $description = null; + private ?string $location = null; + private ?array $organizer = null; + private array $attendees = []; + + public function __construct( + private readonly string $uid, + private readonly ITimeFactory $timeFactory, + ) { + } + + public function setStartDate(DateTimeInterface $start): ICalendarEventBuilder { + $this->startDate = $start; + return $this; + } + + public function setEndDate(DateTimeInterface $end): ICalendarEventBuilder { + $this->endDate = $end; + return $this; + } + + public function setSummary(string $summary): ICalendarEventBuilder { + $this->summary = $summary; + return $this; + } + + public function setDescription(string $description): ICalendarEventBuilder { + $this->description = $description; + return $this; + } + + public function setLocation(string $location): ICalendarEventBuilder { + $this->location = $location; + return $this; + } + + public function setOrganizer(string $email, ?string $commonName = null): ICalendarEventBuilder { + $this->organizer = [$email, $commonName]; + return $this; + } + + public function addAttendee(string $email, ?string $commonName = null): ICalendarEventBuilder { + $this->attendees[] = [$email, $commonName]; + return $this; + } + + public function toIcs(): string { + if ($this->startDate === null) { + throw new InvalidArgumentException('Event is missing a start date'); + } + + if ($this->endDate === null) { + throw new InvalidArgumentException('Event is missing an end date'); + } + + if ($this->summary === null) { + throw new InvalidArgumentException('Event is missing a summary'); + } + + if ($this->organizer === null && $this->attendees !== []) { + throw new InvalidArgumentException('Event has attendees but is missing an organizer'); + } + + $vcalendar = new VCalendar(); + $props = [ + 'UID' => $this->uid, + 'DTSTAMP' => $this->timeFactory->now(), + 'SUMMARY' => $this->summary, + 'DTSTART' => $this->startDate, + 'DTEND' => $this->endDate, + ]; + if ($this->description !== null) { + $props['DESCRIPTION'] = $this->description; + } + if ($this->location !== null) { + $props['LOCATION'] = $this->location; + } + /** @var VEvent $vevent */ + $vevent = $vcalendar->add('VEVENT', $props); + if ($this->organizer !== null) { + self::addAttendeeToVEvent($vevent, 'ORGANIZER', $this->organizer); + } + foreach ($this->attendees as $attendee) { + self::addAttendeeToVEvent($vevent, 'ATTENDEE', $attendee); + } + return $vcalendar->serialize(); + } + + public function createInCalendar(ICreateFromString $calendar): string { + $fileName = $this->uid . '.ics'; + $calendar->createFromString($fileName, $this->toIcs()); + return $fileName; + } + + /** + * @param array{0: string, 1: ?string} $tuple A tuple of [$email, $commonName] where $commonName may be null. + */ + private static function addAttendeeToVEvent(VEvent $vevent, string $name, array $tuple): void { + [$email, $cn] = $tuple; + if (!str_starts_with($email, 'mailto:')) { + $email = "mailto:$email"; + } + $params = []; + if ($cn !== null) { + $params['CN'] = $cn; + } + $vevent->add($name, $email, $params); + } +} diff --git a/lib/private/Calendar/Manager.php b/lib/private/Calendar/Manager.php index ba2124a5c23..3469193a364 100644 --- a/lib/private/Calendar/Manager.php +++ b/lib/private/Calendar/Manager.php @@ -12,6 +12,7 @@ use OC\AppFramework\Bootstrap\Coordinator; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Calendar\Exceptions\CalendarException; use OCP\Calendar\ICalendar; +use OCP\Calendar\ICalendarEventBuilder; use OCP\Calendar\ICalendarIsShared; use OCP\Calendar\ICalendarIsWritable; use OCP\Calendar\ICalendarProvider; @@ -19,6 +20,7 @@ use OCP\Calendar\ICalendarQuery; use OCP\Calendar\ICreateFromString; use OCP\Calendar\IHandleImipMessage; use OCP\Calendar\IManager; +use OCP\Security\ISecureRandom; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Sabre\VObject\Component\VCalendar; @@ -45,6 +47,7 @@ class Manager implements IManager { private ContainerInterface $container, private LoggerInterface $logger, private ITimeFactory $timeFactory, + private ISecureRandom $random, ) { } @@ -216,21 +219,21 @@ class Manager implements IManager { string $recipient, string $calendarData, ): bool { - + $userCalendars = $this->getCalendarsForPrincipal($principalUri); if (empty($userCalendars)) { $this->logger->warning('iMip message could not be processed because user has no calendars'); return false; } - + /** @var VCalendar $vObject|null */ $calendarObject = Reader::read($calendarData); - + if (!isset($calendarObject->METHOD) || $calendarObject->METHOD->getValue() !== 'REQUEST') { $this->logger->warning('iMip message contains an incorrect or invalid method'); return false; } - + if (!isset($calendarObject->VEVENT)) { $this->logger->warning('iMip message contains no event'); return false; @@ -242,12 +245,12 @@ class Manager implements IManager { $this->logger->warning('iMip message event dose not contains a UID'); return false; } - + if (!isset($eventObject->ATTENDEE)) { $this->logger->warning('iMip message event dose not contains any attendees'); return false; } - + foreach ($eventObject->ATTENDEE as $entry) { $address = trim(str_replace('mailto:', '', $entry->getValue())); if ($address === $recipient) { @@ -259,17 +262,17 @@ class Manager implements IManager { $this->logger->warning('iMip message event does not contain a attendee that matches the recipient'); return false; } - + foreach ($userCalendars as $calendar) { - + if (!$calendar instanceof ICalendarIsWritable && !$calendar instanceof ICalendarIsShared) { continue; } - + if ($calendar->isDeleted() || !$calendar->isWritable() || $calendar->isShared()) { continue; } - + if (!empty($calendar->search($recipient, ['ATTENDEE'], ['uid' => $eventObject->UID->getValue()]))) { try { if ($calendar instanceof IHandleImipMessage) { @@ -282,7 +285,7 @@ class Manager implements IManager { } } } - + $this->logger->warning('iMip message event could not be processed because the no corresponding event was found in any calendar'); return false; } @@ -464,4 +467,9 @@ class Manager implements IManager { return false; } } + + public function createEventBuilder(): ICalendarEventBuilder { + $uid = $this->random->generate(32, ISecureRandom::CHAR_ALPHANUMERIC); + return new CalendarEventBuilder($uid, $this->timeFactory); + } } diff --git a/lib/public/AppFramework/Http/DataDisplayResponse.php b/lib/public/AppFramework/Http/DataDisplayResponse.php index 889c57a7901..e1ded910328 100644 --- a/lib/public/AppFramework/Http/DataDisplayResponse.php +++ b/lib/public/AppFramework/Http/DataDisplayResponse.php @@ -13,9 +13,9 @@ use OCP\AppFramework\Http; * Class DataDisplayResponse * * @since 8.1.0 - * @template S of int + * @template S of Http::STATUS_* * @template H of array<string, mixed> - * @template-extends Response<int, array<string, mixed>> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> */ class DataDisplayResponse extends Response { /** diff --git a/lib/public/AppFramework/Http/DataDownloadResponse.php b/lib/public/AppFramework/Http/DataDownloadResponse.php index 80100137c48..ee6bcf0d0c5 100644 --- a/lib/public/AppFramework/Http/DataDownloadResponse.php +++ b/lib/public/AppFramework/Http/DataDownloadResponse.php @@ -13,10 +13,10 @@ use OCP\AppFramework\Http; * Class DataDownloadResponse * * @since 8.0.0 - * @template S of int + * @template S of Http::STATUS_* * @template C of string * @template H of array<string, mixed> - * @template-extends DownloadResponse<int, string, array<string, mixed>> + * @template-extends DownloadResponse<Http::STATUS_*, string, array<string, mixed>> */ class DataDownloadResponse extends DownloadResponse { /** diff --git a/lib/public/AppFramework/Http/DataResponse.php b/lib/public/AppFramework/Http/DataResponse.php index 2ebb66f9e73..2b54ce848ef 100644 --- a/lib/public/AppFramework/Http/DataResponse.php +++ b/lib/public/AppFramework/Http/DataResponse.php @@ -14,10 +14,10 @@ use OCP\AppFramework\Http; * for responders to transform * @since 8.0.0 * @psalm-type DataResponseType = array|int|float|string|bool|object|null|\stdClass|\JsonSerializable - * @template S of int + * @template S of Http::STATUS_* * @template-covariant T of DataResponseType * @template H of array<string, mixed> - * @template-extends Response<int, array<string, mixed>> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> */ class DataResponse extends Response { /** diff --git a/lib/public/AppFramework/Http/DownloadResponse.php b/lib/public/AppFramework/Http/DownloadResponse.php index 058b3070297..190de022d36 100644 --- a/lib/public/AppFramework/Http/DownloadResponse.php +++ b/lib/public/AppFramework/Http/DownloadResponse.php @@ -12,10 +12,10 @@ use OCP\AppFramework\Http; /** * Prompts the user to download the a file * @since 7.0.0 - * @template S of int + * @template S of Http::STATUS_* * @template C of string * @template H of array<string, mixed> - * @template-extends Response<int, array<string, mixed>> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> */ class DownloadResponse extends Response { /** diff --git a/lib/public/AppFramework/Http/FileDisplayResponse.php b/lib/public/AppFramework/Http/FileDisplayResponse.php index 0cc51f7c59f..fda160eafc5 100644 --- a/lib/public/AppFramework/Http/FileDisplayResponse.php +++ b/lib/public/AppFramework/Http/FileDisplayResponse.php @@ -13,9 +13,9 @@ use OCP\Files\SimpleFS\ISimpleFile; * Class FileDisplayResponse * * @since 11.0.0 - * @template S of int + * @template S of Http::STATUS_* * @template H of array<string, mixed> - * @template-extends Response<int, array<string, mixed>> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> */ class FileDisplayResponse extends Response implements ICallbackResponse { /** @var File|ISimpleFile */ diff --git a/lib/public/AppFramework/Http/JSONResponse.php b/lib/public/AppFramework/Http/JSONResponse.php index 1d2564afab0..efcf79d5e87 100644 --- a/lib/public/AppFramework/Http/JSONResponse.php +++ b/lib/public/AppFramework/Http/JSONResponse.php @@ -12,10 +12,10 @@ use OCP\AppFramework\Http; /** * A renderer for JSON calls * @since 6.0.0 - * @template S of int + * @template S of Http::STATUS_* * @template-covariant T of null|string|int|float|bool|array|\stdClass|\JsonSerializable * @template H of array<string, mixed> - * @template-extends Response<int, array<string, mixed>> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> */ class JSONResponse extends Response { /** diff --git a/lib/public/AppFramework/Http/NotFoundResponse.php b/lib/public/AppFramework/Http/NotFoundResponse.php index 9ebefe69be1..137d1a26655 100644 --- a/lib/public/AppFramework/Http/NotFoundResponse.php +++ b/lib/public/AppFramework/Http/NotFoundResponse.php @@ -12,9 +12,9 @@ use OCP\AppFramework\Http; /** * A generic 404 response showing an 404 error page as well to the end-user * @since 8.1.0 - * @template S of int + * @template S of Http::STATUS_* * @template H of array<string, mixed> - * @template-extends TemplateResponse<int, array<string, mixed>> + * @template-extends TemplateResponse<Http::STATUS_*, array<string, mixed>> */ class NotFoundResponse extends TemplateResponse { /** diff --git a/lib/public/AppFramework/Http/RedirectResponse.php b/lib/public/AppFramework/Http/RedirectResponse.php index 41fc4d83856..74847205976 100644 --- a/lib/public/AppFramework/Http/RedirectResponse.php +++ b/lib/public/AppFramework/Http/RedirectResponse.php @@ -12,9 +12,9 @@ use OCP\AppFramework\Http; /** * Redirects to a different URL * @since 7.0.0 - * @template S of int + * @template S of Http::STATUS_* * @template H of array<string, mixed> - * @template-extends Response<int, array<string, mixed>> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> */ class RedirectResponse extends Response { private $redirectURL; diff --git a/lib/public/AppFramework/Http/RedirectToDefaultAppResponse.php b/lib/public/AppFramework/Http/RedirectToDefaultAppResponse.php index 3e2fcf6f6c7..1681b39ce50 100644 --- a/lib/public/AppFramework/Http/RedirectToDefaultAppResponse.php +++ b/lib/public/AppFramework/Http/RedirectToDefaultAppResponse.php @@ -16,9 +16,9 @@ use OCP\IURLGenerator; * * @since 16.0.0 * @deprecated 23.0.0 Use RedirectResponse() with IURLGenerator::linkToDefaultPageUrl() instead - * @template S of int + * @template S of Http::STATUS_* * @template H of array<string, mixed> - * @template-extends RedirectResponse<int, array<string, mixed>> + * @template-extends RedirectResponse<Http::STATUS_*, array<string, mixed>> */ class RedirectToDefaultAppResponse extends RedirectResponse { /** diff --git a/lib/public/AppFramework/Http/Response.php b/lib/public/AppFramework/Http/Response.php index d1860402359..6fc3d4b98ea 100644 --- a/lib/public/AppFramework/Http/Response.php +++ b/lib/public/AppFramework/Http/Response.php @@ -18,7 +18,7 @@ use Psr\Log\LoggerInterface; * * It handles headers, HTTP status code, last modified and ETag. * @since 6.0.0 - * @template S of int + * @template S of Http::STATUS_* * @template H of array<string, mixed> */ class Response { diff --git a/lib/public/AppFramework/Http/StandaloneTemplateResponse.php b/lib/public/AppFramework/Http/StandaloneTemplateResponse.php index f729bd772fb..244a6b80f9f 100644 --- a/lib/public/AppFramework/Http/StandaloneTemplateResponse.php +++ b/lib/public/AppFramework/Http/StandaloneTemplateResponse.php @@ -7,6 +7,8 @@ declare(strict_types=1); */ namespace OCP\AppFramework\Http; +use OCP\AppFramework\Http; + /** * A template response that does not emit the loadAdditionalScripts events. * @@ -14,9 +16,9 @@ namespace OCP\AppFramework\Http; * full nextcloud UI. Like the 2FA page, or the grant page in the login flow. * * @since 16.0.0 - * @template S of int + * @template S of Http::STATUS_* * @template H of array<string, mixed> - * @template-extends TemplateResponse<int, array<string, mixed>> + * @template-extends TemplateResponse<Http::STATUS_*, array<string, mixed>> */ class StandaloneTemplateResponse extends TemplateResponse { } diff --git a/lib/public/AppFramework/Http/StreamResponse.php b/lib/public/AppFramework/Http/StreamResponse.php index 1039e20e5c5..d0e6e3e148a 100644 --- a/lib/public/AppFramework/Http/StreamResponse.php +++ b/lib/public/AppFramework/Http/StreamResponse.php @@ -13,9 +13,9 @@ use OCP\AppFramework\Http; * Class StreamResponse * * @since 8.1.0 - * @template S of int + * @template S of Http::STATUS_* * @template H of array<string, mixed> - * @template-extends Response<int, array<string, mixed>> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> */ class StreamResponse extends Response implements ICallbackResponse { /** @var string */ diff --git a/lib/public/AppFramework/Http/Template/PublicTemplateResponse.php b/lib/public/AppFramework/Http/Template/PublicTemplateResponse.php index 1000f4db549..ef5d2f67f7e 100644 --- a/lib/public/AppFramework/Http/Template/PublicTemplateResponse.php +++ b/lib/public/AppFramework/Http/Template/PublicTemplateResponse.php @@ -15,8 +15,8 @@ use OCP\IInitialStateService; * * @since 14.0.0 * @template H of array<string, mixed> - * @template S of int - * @template-extends TemplateResponse<int, array<string, mixed>> + * @template S of Http::STATUS_* + * @template-extends TemplateResponse<Http::STATUS_*, array<string, mixed>> */ class PublicTemplateResponse extends TemplateResponse { private $headerTitle = ''; diff --git a/lib/public/AppFramework/Http/TemplateResponse.php b/lib/public/AppFramework/Http/TemplateResponse.php index 2c7567c080b..55b9f2b06af 100644 --- a/lib/public/AppFramework/Http/TemplateResponse.php +++ b/lib/public/AppFramework/Http/TemplateResponse.php @@ -13,9 +13,9 @@ use OCP\AppFramework\Http; * Response for a normal template * @since 6.0.0 * - * @template S of int + * @template S of Http::STATUS_* * @template H of array<string, mixed> - * @template-extends Response<int, array<string, mixed>> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> */ class TemplateResponse extends Response { /** diff --git a/lib/public/AppFramework/Http/TextPlainResponse.php b/lib/public/AppFramework/Http/TextPlainResponse.php index e7c728c37ab..9dfa2c5544d 100644 --- a/lib/public/AppFramework/Http/TextPlainResponse.php +++ b/lib/public/AppFramework/Http/TextPlainResponse.php @@ -12,9 +12,9 @@ use OCP\AppFramework\Http; /** * A renderer for text responses * @since 22.0.0 - * @template S of int + * @template S of Http::STATUS_* * @template H of array<string, mixed> - * @template-extends Response<int, array<string, mixed>> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> */ class TextPlainResponse extends Response { /** @var string */ diff --git a/lib/public/AppFramework/Http/TooManyRequestsResponse.php b/lib/public/AppFramework/Http/TooManyRequestsResponse.php index b7b0a98c9e1..6b2ef5b1b90 100644 --- a/lib/public/AppFramework/Http/TooManyRequestsResponse.php +++ b/lib/public/AppFramework/Http/TooManyRequestsResponse.php @@ -13,9 +13,9 @@ use OCP\Template; /** * A generic 429 response showing an 404 error page as well to the end-user * @since 19.0.0 - * @template S of int + * @template S of Http::STATUS_* * @template H of array<string, mixed> - * @template-extends Response<int, array<string, mixed>> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> */ class TooManyRequestsResponse extends Response { /** diff --git a/lib/public/AppFramework/Http/ZipResponse.php b/lib/public/AppFramework/Http/ZipResponse.php index 3b9e251d332..a552eb1294f 100644 --- a/lib/public/AppFramework/Http/ZipResponse.php +++ b/lib/public/AppFramework/Http/ZipResponse.php @@ -15,9 +15,9 @@ use OCP\IRequest; * Public library to send several files in one zip archive. * * @since 15.0.0 - * @template S of int + * @template S of Http::STATUS_* * @template H of array<string, mixed> - * @template-extends Response<int, array<string, mixed>> + * @template-extends Response<Http::STATUS_*, array<string, mixed>> */ class ZipResponse extends Response implements ICallbackResponse { /** @var array{internalName: string, resource: resource, size: int, time: int}[] Files to be added to the zip response */ diff --git a/lib/public/Calendar/ICalendarEventBuilder.php b/lib/public/Calendar/ICalendarEventBuilder.php new file mode 100644 index 00000000000..8afc817a61e --- /dev/null +++ b/lib/public/Calendar/ICalendarEventBuilder.php @@ -0,0 +1,110 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Calendar; + +use DateTimeInterface; +use InvalidArgumentException; +use OCP\Calendar\Exceptions\CalendarException; + +/** + * The calendar event builder can be used to conveniently build a calendar event and then serialize + * it to a ICS string. The ICS string can be submitted to calendar instances implementing the + * {@see \OCP\Calendar\ICreateFromString} interface. + * + * Also note this class can not be injected directly with dependency injection. + * Instead, inject {@see \OCP\Calendar\IManager} and use + * {@see \OCP\Calendar\IManager::createEventBuilder()} afterwards. + * + * All setters return self to allow chaining method calls. + * + * @since 31.0.0 + */ +interface ICalendarEventBuilder { + /** + * Set the start date, time and time zone. + * This property is required! + * + * @since 31.0.0 + */ + public function setStartDate(DateTimeInterface $start): self; + + /** + * Set the end date, time and time zone. + * This property is required! + * + * @since 31.0.0 + */ + public function setEndDate(DateTimeInterface $end): self; + + /** + * Set the event summary or title. + * This property is required! + * + * @since 31.0.0 + */ + public function setSummary(string $summary): self; + + /** + * Set the event description. + * + * @since 31.0.0 + */ + public function setDescription(string $description): self; + + /** + * Set the event location. It can either be a physical address or a URL. + * + * @since 31.0.0 + */ + public function setLocation(string $location): self; + + /** + * Set the event organizer. + * This property is required if attendees are added! + * + * The "mailto:" prefix is optional and will be added automatically if it is missing. + * + * @since 31.0.0 + */ + public function setOrganizer(string $email, ?string $commonName = null): self; + + /** + * Add a new attendee to the event. + * Adding at least one attendee requires also setting the organizer! + * + * The "mailto:" prefix is optional and will be added automatically if it is missing. + * + * @since 31.0.0 + */ + public function addAttendee(string $email, ?string $commonName = null): self; + + /** + * Serialize the built event to an ICS string if all required properties set. + * + * @since 31.0.0 + * + * @return string The serialized ICS string + * + * @throws InvalidArgumentException If required properties were not set + */ + public function toIcs(): string; + + /** + * Create the event in the given calendar. + * + * @since 31.0.0 + * + * @return string The filename of the created event + * + * @throws InvalidArgumentException If required properties were not set + * @throws CalendarException If writing the event to the calendar fails + */ + public function createInCalendar(ICreateFromString $calendar): string; +} diff --git a/lib/public/Calendar/IManager.php b/lib/public/Calendar/IManager.php index bb3808f133c..8056d57d859 100644 --- a/lib/public/Calendar/IManager.php +++ b/lib/public/Calendar/IManager.php @@ -157,4 +157,12 @@ interface IManager { * @since 25.0.0 */ public function handleIMipCancel(string $principalUri, string $sender, ?string $replyTo, string $recipient, string $calendarData): bool; + + /** + * Create a new event builder instance. Please have a look at its documentation and the + * \OCP\Calendar\ICreateFromString interface on how to use it. + * + * @since 31.0.0 + */ + public function createEventBuilder(): ICalendarEventBuilder; } diff --git a/tests/data/ics/event-builder-complete.ics b/tests/data/ics/event-builder-complete.ics new file mode 100644 index 00000000000..d96a3137356 --- /dev/null +++ b/tests/data/ics/event-builder-complete.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Sabre//Sabre VObject 4.5.6//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:event-uid-123
+DTSTAMP:20250105T000000Z
+SUMMARY:My event
+DTSTART:20250105T170958Z
+DTEND:20250105T171958Z
+DESCRIPTION:Foo bar baz
+ORGANIZER:mailto:organizer@domain.tld
+ATTENDEE:mailto:attendee1@domain.tld
+ATTENDEE:mailto:attendee2@domain.tld
+END:VEVENT
+END:VCALENDAR
diff --git a/tests/data/ics/event-builder-complete.ics.license b/tests/data/ics/event-builder-complete.ics.license new file mode 100644 index 00000000000..f7f52efa96f --- /dev/null +++ b/tests/data/ics/event-builder-complete.ics.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+SPDX-License-Identifier: AGPL-3.0-or-later
diff --git a/tests/data/ics/event-builder-without-attendees.ics b/tests/data/ics/event-builder-without-attendees.ics new file mode 100644 index 00000000000..95c4c44fa1f --- /dev/null +++ b/tests/data/ics/event-builder-without-attendees.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Sabre//Sabre VObject 4.5.6//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:event-uid-123
+DTSTAMP:20250105T000000Z
+SUMMARY:My event
+DTSTART:20250105T170958Z
+DTEND:20250105T171958Z
+DESCRIPTION:Foo bar baz
+END:VEVENT
+END:VCALENDAR
diff --git a/tests/data/ics/event-builder-without-attendees.ics.license b/tests/data/ics/event-builder-without-attendees.ics.license new file mode 100644 index 00000000000..f7f52efa96f --- /dev/null +++ b/tests/data/ics/event-builder-without-attendees.ics.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+SPDX-License-Identifier: AGPL-3.0-or-later
diff --git a/tests/lib/Calendar/CalendarEventBuilderTest.php b/tests/lib/Calendar/CalendarEventBuilderTest.php new file mode 100644 index 00000000000..48684d56093 --- /dev/null +++ b/tests/lib/Calendar/CalendarEventBuilderTest.php @@ -0,0 +1,146 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\Calendar; + +use DateTimeImmutable; +use InvalidArgumentException; +use OC\Calendar\CalendarEventBuilder; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Calendar\ICreateFromString; +use PHPUnit\Framework\MockObject\MockObject; +use Test\TestCase; + +class CalendarEventBuilderTest extends TestCase { + private CalendarEventBuilder $calendarEventBuilder; + private ITimeFactory&MockObject $timeFactory; + + protected function setUp(): void { + parent::setUp(); + + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->timeFactory->method('now') + ->willReturn(new DateTimeImmutable('20250105T000000Z')); + + $this->calendarEventBuilder = new CalendarEventBuilder( + 'event-uid-123', + $this->timeFactory, + ); + } + + public function testToIcs(): void { + $this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); + $this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); + $this->calendarEventBuilder->setSummary('My event'); + $this->calendarEventBuilder->setDescription('Foo bar baz'); + $this->calendarEventBuilder->setOrganizer('mailto:organizer@domain.tld'); + $this->calendarEventBuilder->addAttendee('mailto:attendee1@domain.tld'); + $this->calendarEventBuilder->addAttendee('mailto:attendee2@domain.tld'); + + $expected = file_get_contents(\OC::$SERVERROOT . '/tests/data/ics/event-builder-complete.ics'); + $actual = $this->calendarEventBuilder->toIcs(); + $this->assertEquals($expected, $actual); + } + + public function testToIcsWithoutOrganizerAndAttendees(): void { + $this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); + $this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); + $this->calendarEventBuilder->setSummary('My event'); + $this->calendarEventBuilder->setDescription('Foo bar baz'); + + $expected = file_get_contents(\OC::$SERVERROOT . '/tests/data/ics/event-builder-without-attendees.ics'); + $actual = $this->calendarEventBuilder->toIcs(); + $this->assertEquals($expected, $actual); + } + + public function testToIcsWithoutMailtoPrefix(): void { + $this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); + $this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); + $this->calendarEventBuilder->setSummary('My event'); + $this->calendarEventBuilder->setDescription('Foo bar baz'); + $this->calendarEventBuilder->setOrganizer('organizer@domain.tld'); + $this->calendarEventBuilder->addAttendee('attendee1@domain.tld'); + $this->calendarEventBuilder->addAttendee('attendee2@domain.tld'); + + $expected = file_get_contents(\OC::$SERVERROOT . '/tests/data/ics/event-builder-complete.ics'); + $actual = $this->calendarEventBuilder->toIcs(); + $this->assertEquals($expected, $actual); + } + + public function testCreateInCalendar(): void { + $this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); + $this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); + $this->calendarEventBuilder->setSummary('My event'); + $this->calendarEventBuilder->setDescription('Foo bar baz'); + $this->calendarEventBuilder->setOrganizer('organizer@domain.tld'); + $this->calendarEventBuilder->addAttendee('attendee1@domain.tld'); + $this->calendarEventBuilder->addAttendee('mailto:attendee2@domain.tld'); + + $expectedIcs = file_get_contents(\OC::$SERVERROOT . '/tests/data/ics/event-builder-complete.ics'); + $calendar = $this->createMock(ICreateFromString::class); + $calendar->expects(self::once()) + ->method('createFromString') + ->with('event-uid-123.ics', $expectedIcs); + + $actual = $this->calendarEventBuilder->createInCalendar($calendar); + $this->assertEquals('event-uid-123.ics', $actual); + } + + public function testToIcsWithoutStartDate(): void { + $this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); + $this->calendarEventBuilder->setSummary('My event'); + $this->calendarEventBuilder->setDescription('Foo bar baz'); + $this->calendarEventBuilder->setOrganizer('organizer@domain.tld'); + $this->calendarEventBuilder->addAttendee('attendee1@domain.tld'); + $this->calendarEventBuilder->addAttendee('mailto:attendee2@domain.tld'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/start date/i'); + $this->calendarEventBuilder->toIcs(); + } + + public function testToIcsWithoutEndDate(): void { + $this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); + $this->calendarEventBuilder->setSummary('My event'); + $this->calendarEventBuilder->setDescription('Foo bar baz'); + $this->calendarEventBuilder->setOrganizer('organizer@domain.tld'); + $this->calendarEventBuilder->addAttendee('attendee1@domain.tld'); + $this->calendarEventBuilder->addAttendee('mailto:attendee2@domain.tld'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/end date/i'); + $this->calendarEventBuilder->toIcs(); + } + + public function testToIcsWithoutSummary(): void { + $this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); + $this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); + $this->calendarEventBuilder->setDescription('Foo bar baz'); + $this->calendarEventBuilder->setOrganizer('organizer@domain.tld'); + $this->calendarEventBuilder->addAttendee('attendee1@domain.tld'); + $this->calendarEventBuilder->addAttendee('mailto:attendee2@domain.tld'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/summary/i'); + $this->calendarEventBuilder->toIcs(); + } + + public function testToIcsWithoutOrganizerWithAttendees(): void { + $this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); + $this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); + $this->calendarEventBuilder->setSummary('My event'); + $this->calendarEventBuilder->setDescription('Foo bar baz'); + $this->calendarEventBuilder->addAttendee('attendee1@domain.tld'); + $this->calendarEventBuilder->addAttendee('mailto:attendee2@domain.tld'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/organizer/i'); + $this->calendarEventBuilder->toIcs(); + } +} diff --git a/tests/lib/Calendar/ManagerTest.php b/tests/lib/Calendar/ManagerTest.php index f0ca278f352..a7aeed98046 100644 --- a/tests/lib/Calendar/ManagerTest.php +++ b/tests/lib/Calendar/ManagerTest.php @@ -14,6 +14,7 @@ use OCP\Calendar\ICalendarIsShared; use OCP\Calendar\ICalendarIsWritable; use OCP\Calendar\ICreateFromString; use OCP\Calendar\IHandleImipMessage; +use OCP\Security\ISecureRandom; use PHPUnit\Framework\MockObject\MockObject; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; @@ -44,6 +45,9 @@ class ManagerTest extends TestCase { /** @var ITimeFactory&MockObject */ private $time; + /** @var ISecureRandom&MockObject */ + private ISecureRandom $secureRandom; + private VCalendar $vCalendar1a; protected function setUp(): void { @@ -53,12 +57,14 @@ class ManagerTest extends TestCase { $this->container = $this->createMock(ContainerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->time = $this->createMock(ITimeFactory::class); + $this->secureRandom = $this->createMock(ISecureRandom::class); $this->manager = new Manager( $this->coordinator, $this->container, $this->logger, $this->time, + $this->secureRandom, ); // construct calendar with a 1 hour event and same start/end time zones @@ -260,7 +266,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->onlyMethods(['getCalendarsForPrincipal']) ->getMock(); @@ -291,7 +298,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->onlyMethods(['getCalendarsForPrincipal']) ->getMock(); @@ -321,7 +329,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->onlyMethods(['getCalendarsForPrincipal']) ->getMock(); @@ -352,7 +361,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->onlyMethods(['getCalendarsForPrincipal']) ->getMock(); @@ -384,7 +394,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->onlyMethods(['getCalendarsForPrincipal']) ->getMock(); @@ -416,7 +427,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->onlyMethods(['getCalendarsForPrincipal']) ->getMock(); @@ -448,7 +460,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->onlyMethods(['getCalendarsForPrincipal']) ->getMock(); @@ -491,7 +504,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->onlyMethods(['getCalendarsForPrincipal']) ->getMock(); @@ -534,7 +548,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->onlyMethods(['getCalendarsForPrincipal']) ->getMock(); @@ -612,7 +627,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->setMethods([ 'getCalendarsForPrincipal' @@ -643,7 +659,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->setMethods([ 'getCalendarsForPrincipal' @@ -680,7 +697,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->setMethods([ 'getCalendarsForPrincipal' @@ -767,7 +785,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->setMethods([ 'getCalendarsForPrincipal' @@ -800,7 +819,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->setMethods([ 'getCalendarsForPrincipal' @@ -837,7 +857,8 @@ class ManagerTest extends TestCase { $this->coordinator, $this->container, $this->logger, - $this->time + $this->time, + $this->secureRandom, ]) ->setMethods([ 'getCalendarsForPrincipal' @@ -866,7 +887,7 @@ class ManagerTest extends TestCase { $result = $manager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $calendarData->serialize()); $this->assertTrue($result); } - + private function getVCalendarReply(): Document { $data = <<<EOF BEGIN:VCALENDAR |