aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dav/lib/Controller
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dav/lib/Controller')
-rw-r--r--apps/dav/lib/Controller/BirthdayCalendarController.php77
-rw-r--r--apps/dav/lib/Controller/DirectController.php99
-rw-r--r--apps/dav/lib/Controller/ExampleContentController.php98
-rw-r--r--apps/dav/lib/Controller/InvitationResponseController.php204
-rw-r--r--apps/dav/lib/Controller/OutOfOfficeController.php189
-rw-r--r--apps/dav/lib/Controller/UpcomingEventsController.php57
6 files changed, 724 insertions, 0 deletions
diff --git a/apps/dav/lib/Controller/BirthdayCalendarController.php b/apps/dav/lib/Controller/BirthdayCalendarController.php
new file mode 100644
index 00000000000..f6bfb229a9c
--- /dev/null
+++ b/apps/dav/lib/Controller/BirthdayCalendarController.php
@@ -0,0 +1,77 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\Controller;
+
+use OCA\DAV\BackgroundJob\GenerateBirthdayCalendarBackgroundJob;
+use OCA\DAV\CalDAV\CalDavBackend;
+use OCA\DAV\Settings\CalDAVSettings;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\AppFramework\Http\Response;
+use OCP\BackgroundJob\IJobList;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserManager;
+
+class BirthdayCalendarController extends Controller {
+
+ /**
+ * BirthdayCalendar constructor.
+ *
+ * @param string $appName
+ * @param IRequest $request
+ * @param IDBConnection $db
+ * @param IConfig $config
+ * @param IJobList $jobList
+ * @param IUserManager $userManager
+ * @param CalDavBackend $caldavBackend
+ */
+ public function __construct(
+ $appName,
+ IRequest $request,
+ protected IDBConnection $db,
+ protected IConfig $config,
+ protected IJobList $jobList,
+ protected IUserManager $userManager,
+ protected CalDavBackend $caldavBackend,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * @return Response
+ */
+ #[AuthorizedAdminSetting(settings: CalDAVSettings::class)]
+ public function enable() {
+ $this->config->setAppValue($this->appName, 'generateBirthdayCalendar', 'yes');
+
+ // add background job for each user
+ $this->userManager->callForSeenUsers(function (IUser $user): void {
+ $this->jobList->add(GenerateBirthdayCalendarBackgroundJob::class, [
+ 'userId' => $user->getUID(),
+ ]);
+ });
+
+ return new JSONResponse([]);
+ }
+
+ /**
+ * @return Response
+ */
+ #[AuthorizedAdminSetting(settings: CalDAVSettings::class)]
+ public function disable() {
+ $this->config->setAppValue($this->appName, 'generateBirthdayCalendar', 'no');
+
+ $this->jobList->remove(GenerateBirthdayCalendarBackgroundJob::class);
+ $this->caldavBackend->deleteAllBirthdayCalendars();
+
+ return new JSONResponse([]);
+ }
+}
diff --git a/apps/dav/lib/Controller/DirectController.php b/apps/dav/lib/Controller/DirectController.php
new file mode 100644
index 00000000000..ea209168123
--- /dev/null
+++ b/apps/dav/lib/Controller/DirectController.php
@@ -0,0 +1,99 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\Controller;
+
+use OCA\DAV\Db\Direct;
+use OCA\DAV\Db\DirectMapper;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCS\OCSBadRequestException;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\AppFramework\OCS\OCSNotFoundException;
+use OCP\AppFramework\OCSController;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Files\Events\BeforeDirectFileDownloadEvent;
+use OCP\Files\File;
+use OCP\Files\IRootFolder;
+use OCP\IRequest;
+use OCP\IURLGenerator;
+use OCP\Security\ISecureRandom;
+
+class DirectController extends OCSController {
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IRootFolder $rootFolder,
+ private string $userId,
+ private DirectMapper $mapper,
+ private ISecureRandom $random,
+ private ITimeFactory $timeFactory,
+ private IURLGenerator $urlGenerator,
+ private IEventDispatcher $eventDispatcher,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Get a direct link to a file
+ *
+ * @param int $fileId ID of the file
+ * @param int $expirationTime Duration until the link expires
+ * @return DataResponse<Http::STATUS_OK, array{url: string}, array{}>
+ * @throws OCSNotFoundException File not found
+ * @throws OCSBadRequestException Getting direct link is not possible
+ * @throws OCSForbiddenException Missing permissions to get direct link
+ *
+ * 200: Direct link returned
+ */
+ #[NoAdminRequired]
+ public function getUrl(int $fileId, int $expirationTime = 60 * 60 * 8): DataResponse {
+ $userFolder = $this->rootFolder->getUserFolder($this->userId);
+
+ $file = $userFolder->getFirstNodeById($fileId);
+
+ if (!$file) {
+ throw new OCSNotFoundException();
+ }
+
+ if ($expirationTime <= 0 || $expirationTime > (60 * 60 * 24)) {
+ throw new OCSBadRequestException('Expiration time should be greater than 0 and less than or equal to ' . (60 * 60 * 24));
+ }
+
+ if (!($file instanceof File)) {
+ throw new OCSBadRequestException('Direct download only works for files');
+ }
+
+ $event = new BeforeDirectFileDownloadEvent($userFolder->getRelativePath($file->getPath()));
+ $this->eventDispatcher->dispatchTyped($event);
+
+ if ($event->isSuccessful() === false) {
+ throw new OCSForbiddenException('Permission denied to download file');
+ }
+
+ //TODO: at some point we should use the directdownlaod function of storages
+ $direct = new Direct();
+ $direct->setUserId($this->userId);
+ $direct->setFileId($fileId);
+
+ $token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC);
+ $direct->setToken($token);
+ $direct->setExpiration($this->timeFactory->getTime() + $expirationTime);
+
+ $this->mapper->insert($direct);
+
+ $url = $this->urlGenerator->getAbsoluteURL('remote.php/direct/' . $token);
+
+ return new DataResponse([
+ 'url' => $url,
+ ]);
+ }
+}
diff --git a/apps/dav/lib/Controller/ExampleContentController.php b/apps/dav/lib/Controller/ExampleContentController.php
new file mode 100644
index 00000000000..e20ee4b7f49
--- /dev/null
+++ b/apps/dav/lib/Controller/ExampleContentController.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\Controller;
+
+use OCA\DAV\AppInfo\Application;
+use OCA\DAV\Service\ExampleContactService;
+use OCA\DAV\Service\ExampleEventService;
+use OCP\AppFramework\ApiController;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\FrontpageRoute;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\DataDownloadResponse;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use Psr\Log\LoggerInterface;
+
+class ExampleContentController extends ApiController {
+ public function __construct(
+ IRequest $request,
+ private readonly LoggerInterface $logger,
+ private readonly ExampleEventService $exampleEventService,
+ private readonly ExampleContactService $exampleContactService,
+ ) {
+ parent::__construct(Application::APP_ID, $request);
+ }
+
+ #[FrontpageRoute(verb: 'PUT', url: '/api/defaultcontact/config')]
+ public function setEnableDefaultContact(bool $allow): JSONResponse {
+ if ($allow && !$this->exampleContactService->defaultContactExists()) {
+ try {
+ $this->exampleContactService->setCard();
+ } catch (\Exception $e) {
+ $this->logger->error('Could not create default contact', ['exception' => $e]);
+ return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+ $this->exampleContactService->setDefaultContactEnabled($allow);
+ return new JSONResponse([], Http::STATUS_OK);
+ }
+
+ #[NoCSRFRequired]
+ #[FrontpageRoute(verb: 'GET', url: '/api/defaultcontact/contact')]
+ public function getDefaultContact(): DataDownloadResponse {
+ $cardData = $this->exampleContactService->getCard()
+ ?? file_get_contents(__DIR__ . '/../ExampleContentFiles/exampleContact.vcf');
+ return new DataDownloadResponse($cardData, 'example_contact.vcf', 'text/vcard');
+ }
+
+ #[FrontpageRoute(verb: 'PUT', url: '/api/defaultcontact/contact')]
+ public function setDefaultContact(?string $contactData = null) {
+ if (!$this->exampleContactService->isDefaultContactEnabled()) {
+ return new JSONResponse([], Http::STATUS_FORBIDDEN);
+ }
+ $this->exampleContactService->setCard($contactData);
+ return new JSONResponse([], Http::STATUS_OK);
+ }
+
+ #[FrontpageRoute(verb: 'POST', url: '/api/exampleEvent/enable')]
+ public function setCreateExampleEvent(bool $enable): JSONResponse {
+ $this->exampleEventService->setCreateExampleEvent($enable);
+ return new JsonResponse([]);
+ }
+
+ #[FrontpageRoute(verb: 'GET', url: '/api/exampleEvent/event')]
+ #[NoCSRFRequired]
+ public function downloadExampleEvent(): DataDownloadResponse {
+ $exampleEvent = $this->exampleEventService->getExampleEvent();
+ return new DataDownloadResponse(
+ $exampleEvent->getIcs(),
+ 'example_event.ics',
+ 'text/calendar',
+ );
+ }
+
+ #[FrontpageRoute(verb: 'POST', url: '/api/exampleEvent/event')]
+ public function uploadExampleEvent(string $ics): JSONResponse {
+ if (!$this->exampleEventService->shouldCreateExampleEvent()) {
+ return new JSONResponse([], Http::STATUS_FORBIDDEN);
+ }
+
+ $this->exampleEventService->saveCustomExampleEvent($ics);
+ return new JsonResponse([]);
+ }
+
+ #[FrontpageRoute(verb: 'DELETE', url: '/api/exampleEvent/event')]
+ public function deleteExampleEvent(): JSONResponse {
+ $this->exampleEventService->deleteCustomExampleEvent();
+ return new JsonResponse([]);
+ }
+
+}
diff --git a/apps/dav/lib/Controller/InvitationResponseController.php b/apps/dav/lib/Controller/InvitationResponseController.php
new file mode 100644
index 00000000000..19eb4097b45
--- /dev/null
+++ b/apps/dav/lib/Controller/InvitationResponseController.php
@@ -0,0 +1,204 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\Controller;
+
+use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\Attribute\PublicPage;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\IDBConnection;
+use OCP\IRequest;
+use Sabre\VObject\ITip\Message;
+use Sabre\VObject\Reader;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class InvitationResponseController extends Controller {
+
+ /**
+ * InvitationResponseController constructor.
+ *
+ * @param string $appName
+ * @param IRequest $request
+ * @param IDBConnection $db
+ * @param ITimeFactory $timeFactory
+ * @param InvitationResponseServer $responseServer
+ */
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IDBConnection $db,
+ private ITimeFactory $timeFactory,
+ private InvitationResponseServer $responseServer,
+ ) {
+ parent::__construct($appName, $request);
+ // Don't run `$server->exec()`, because we just need access to the
+ // fully initialized schedule plugin, but we don't want Sabre/DAV
+ // to actually handle and reply to the request
+ }
+
+ /**
+ * @param string $token
+ * @return TemplateResponse
+ */
+ #[PublicPage]
+ #[NoCSRFRequired]
+ public function accept(string $token):TemplateResponse {
+ $row = $this->getTokenInformation($token);
+ if (!$row) {
+ return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest');
+ }
+
+ $iTipMessage = $this->buildITipResponse($row, 'ACCEPTED');
+ $this->responseServer->handleITipMessage($iTipMessage);
+ if ($iTipMessage->getScheduleStatus() === '1.2') {
+ return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest');
+ }
+
+ return new TemplateResponse($this->appName, 'schedule-response-error', [
+ 'organizer' => $row['organizer'],
+ ], 'guest');
+ }
+
+ /**
+ * @param string $token
+ * @return TemplateResponse
+ */
+ #[PublicPage]
+ #[NoCSRFRequired]
+ public function decline(string $token):TemplateResponse {
+ $row = $this->getTokenInformation($token);
+ if (!$row) {
+ return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest');
+ }
+
+ $iTipMessage = $this->buildITipResponse($row, 'DECLINED');
+ $this->responseServer->handleITipMessage($iTipMessage);
+
+ if ($iTipMessage->getScheduleStatus() === '1.2') {
+ return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest');
+ }
+
+ return new TemplateResponse($this->appName, 'schedule-response-error', [
+ 'organizer' => $row['organizer'],
+ ], 'guest');
+ }
+
+ /**
+ * @param string $token
+ * @return TemplateResponse
+ */
+ #[PublicPage]
+ #[NoCSRFRequired]
+ public function options(string $token):TemplateResponse {
+ return new TemplateResponse($this->appName, 'schedule-response-options', [
+ 'token' => $token
+ ], 'guest');
+ }
+
+ /**
+ * @param string $token
+ *
+ * @return TemplateResponse
+ */
+ #[PublicPage]
+ #[NoCSRFRequired]
+ public function processMoreOptionsResult(string $token):TemplateResponse {
+ $partstat = $this->request->getParam('partStat');
+
+ $row = $this->getTokenInformation($token);
+ if (!$row || !\in_array($partstat, ['ACCEPTED', 'DECLINED', 'TENTATIVE'])) {
+ return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest');
+ }
+
+ $iTipMessage = $this->buildITipResponse($row, $partstat);
+ $this->responseServer->handleITipMessage($iTipMessage);
+ if ($iTipMessage->getScheduleStatus() === '1.2') {
+ return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest');
+ }
+
+ return new TemplateResponse($this->appName, 'schedule-response-error', [
+ 'organizer' => $row['organizer'],
+ ], 'guest');
+ }
+
+ /**
+ * @param string $token
+ * @return array|null
+ */
+ private function getTokenInformation(string $token) {
+ $query = $this->db->getQueryBuilder();
+ $query->select('*')
+ ->from('calendar_invitations')
+ ->where($query->expr()->eq('token', $query->createNamedParameter($token)));
+ $stmt = $query->executeQuery();
+ $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+ $stmt->closeCursor();
+
+ if (!$row) {
+ return null;
+ }
+
+ $currentTime = $this->timeFactory->getTime();
+ if (((int)$row['expiration']) < $currentTime) {
+ return null;
+ }
+
+ return $row;
+ }
+
+ /**
+ * @param array $row
+ * @param string $partStat participation status of attendee - SEE RFC 5545
+ * @param int|null $guests
+ * @param string|null $comment
+ * @return Message
+ */
+ private function buildITipResponse(array $row, string $partStat):Message {
+ $iTipMessage = new Message();
+ $iTipMessage->uid = $row['uid'];
+ $iTipMessage->component = 'VEVENT';
+ $iTipMessage->method = 'REPLY';
+ $iTipMessage->sequence = $row['sequence'];
+ $iTipMessage->sender = $row['attendee'];
+
+ if ($this->responseServer->isExternalAttendee($row['attendee'])) {
+ $iTipMessage->recipient = $row['organizer'];
+ } else {
+ $iTipMessage->recipient = $row['attendee'];
+ }
+
+ $message = <<<EOF
+BEGIN:VCALENDAR
+PRODID:-//Nextcloud/Nextcloud CalDAV Server//EN
+METHOD:REPLY
+VERSION:2.0
+BEGIN:VEVENT
+ATTENDEE;PARTSTAT=%s:%s
+ORGANIZER:%s
+UID:%s
+SEQUENCE:%s
+REQUEST-STATUS:2.0;Success
+%sEND:VEVENT
+END:VCALENDAR
+EOF;
+
+ $vObject = Reader::read(vsprintf($message, [
+ $partStat, $row['attendee'], $row['organizer'],
+ $row['uid'], $row['sequence'] ?? 0, $row['recurrenceid'] ?? ''
+ ]));
+ $vEvent = $vObject->{'VEVENT'};
+ $vEvent->DTSTAMP = date('Ymd\\THis\\Z', $this->timeFactory->getTime());
+ $iTipMessage->message = $vObject;
+
+ return $iTipMessage;
+ }
+}
diff --git a/apps/dav/lib/Controller/OutOfOfficeController.php b/apps/dav/lib/Controller/OutOfOfficeController.php
new file mode 100644
index 00000000000..d3516d092e8
--- /dev/null
+++ b/apps/dav/lib/Controller/OutOfOfficeController.php
@@ -0,0 +1,189 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\Controller;
+
+use DateTimeImmutable;
+use OCA\DAV\ResponseDefinitions;
+use OCA\DAV\Service\AbsenceService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCSController;
+use OCP\IRequest;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use OCP\User\IAvailabilityCoordinator;
+use function mb_strlen;
+
+/**
+ * @psalm-import-type DAVOutOfOfficeData from ResponseDefinitions
+ * @psalm-import-type DAVCurrentOutOfOfficeData from ResponseDefinitions
+ */
+class OutOfOfficeController extends OCSController {
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IUserManager $userManager,
+ private ?IUserSession $userSession,
+ private AbsenceService $absenceService,
+ private IAvailabilityCoordinator $coordinator,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Get the currently configured out-of-office data of a user
+ *
+ * @param string $userId The user id to get out-of-office data for.
+ * @return DataResponse<Http::STATUS_OK, DAVCurrentOutOfOfficeData, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>
+ *
+ * 200: Out-of-office data
+ * 404: No out-of-office data was found
+ */
+ #[NoAdminRequired]
+ public function getCurrentOutOfOfficeData(string $userId): DataResponse {
+ $user = $this->userManager->get($userId);
+ if ($user === null) {
+ return new DataResponse(null, Http::STATUS_NOT_FOUND);
+ }
+ try {
+ $data = $this->absenceService->getCurrentAbsence($user);
+ if ($data === null) {
+ return new DataResponse(null, Http::STATUS_NOT_FOUND);
+ }
+ } catch (DoesNotExistException) {
+ return new DataResponse(null, Http::STATUS_NOT_FOUND);
+ }
+
+ return new DataResponse($data->jsonSerialize());
+ }
+
+ /**
+ * Get the configured out-of-office data of a user.
+ *
+ * @param string $userId The user id to get out-of-office data for.
+ * @return DataResponse<Http::STATUS_OK, DAVOutOfOfficeData, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>
+ *
+ * 200: Out-of-office data
+ * 404: No out-of-office data was found
+ */
+ #[NoAdminRequired]
+ public function getOutOfOffice(string $userId): DataResponse {
+ try {
+ $data = $this->absenceService->getAbsence($userId);
+ if ($data === null) {
+ return new DataResponse(null, Http::STATUS_NOT_FOUND);
+ }
+ } catch (DoesNotExistException) {
+ return new DataResponse(null, Http::STATUS_NOT_FOUND);
+ }
+
+ return new DataResponse([
+ 'id' => $data->getId(),
+ 'userId' => $data->getUserId(),
+ 'firstDay' => $data->getFirstDay(),
+ 'lastDay' => $data->getLastDay(),
+ 'status' => $data->getStatus(),
+ 'message' => $data->getMessage(),
+ 'replacementUserId' => $data->getReplacementUserId(),
+ 'replacementUserDisplayName' => $data->getReplacementUserDisplayName(),
+ ]);
+ }
+
+ /**
+ * Set out-of-office absence
+ *
+ * @param string $firstDay First day of the absence in format `YYYY-MM-DD`
+ * @param string $lastDay Last day of the absence in format `YYYY-MM-DD`
+ * @param string $status Short text that is set as user status during the absence
+ * @param string $message Longer multiline message that is shown to others during the absence
+ * @param ?string $replacementUserId User id of the replacement user
+ * @return DataResponse<Http::STATUS_OK, DAVOutOfOfficeData, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'firstDay'|'statusLength'}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, null, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>
+ *
+ * 200: Absence data
+ * 400: When validation fails, e.g. data range error or the first day is not before the last day
+ * 401: When the user is not logged in
+ * 404: When the replacementUserId was provided but replacement user was not found
+ */
+ #[NoAdminRequired]
+ public function setOutOfOffice(
+ string $firstDay,
+ string $lastDay,
+ string $status,
+ string $message,
+ ?string $replacementUserId,
+ ): DataResponse {
+ $user = $this->userSession?->getUser();
+ if ($user === null) {
+ return new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+ }
+ if (mb_strlen($status) > 100) {
+ return new DataResponse(['error' => 'statusLength'], Http::STATUS_BAD_REQUEST);
+ }
+
+ $replacementUser = null;
+ if ($replacementUserId !== null) {
+ $replacementUser = $this->userManager->get($replacementUserId);
+ if ($replacementUser === null) {
+ return new DataResponse(null, Http::STATUS_NOT_FOUND);
+ }
+ }
+
+ $parsedFirstDay = new DateTimeImmutable($firstDay);
+ $parsedLastDay = new DateTimeImmutable($lastDay);
+ if ($parsedFirstDay->getTimestamp() > $parsedLastDay->getTimestamp()) {
+ return new DataResponse(['error' => 'firstDay'], Http::STATUS_BAD_REQUEST);
+ }
+
+ $data = $this->absenceService->createOrUpdateAbsence(
+ $user,
+ $firstDay,
+ $lastDay,
+ $status,
+ $message,
+ $replacementUserId,
+ $replacementUser?->getDisplayName()
+ );
+ $this->coordinator->clearCache($user->getUID());
+
+ return new DataResponse([
+ 'id' => $data->getId(),
+ 'userId' => $data->getUserId(),
+ 'firstDay' => $data->getFirstDay(),
+ 'lastDay' => $data->getLastDay(),
+ 'status' => $data->getStatus(),
+ 'message' => $data->getMessage(),
+ 'replacementUserId' => $data->getReplacementUserId(),
+ 'replacementUserDisplayName' => $data->getReplacementUserDisplayName(),
+ ]);
+ }
+
+ /**
+ * Clear the out-of-office
+ *
+ * @return DataResponse<Http::STATUS_OK|Http::STATUS_UNAUTHORIZED, null, array{}>
+ *
+ * 200: When the absence was cleared successfully
+ * 401: When the user is not logged in
+ */
+ #[NoAdminRequired]
+ public function clearOutOfOffice(): DataResponse {
+ $user = $this->userSession?->getUser();
+ if ($user === null) {
+ return new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->absenceService->clearAbsence($user);
+ $this->coordinator->clearCache($user->getUID());
+ return new DataResponse(null);
+ }
+}
diff --git a/apps/dav/lib/Controller/UpcomingEventsController.php b/apps/dav/lib/Controller/UpcomingEventsController.php
new file mode 100644
index 00000000000..a5d54f44754
--- /dev/null
+++ b/apps/dav/lib/Controller/UpcomingEventsController.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\Controller;
+
+use OCA\DAV\AppInfo\Application;
+use OCA\DAV\CalDAV\UpcomingEvent;
+use OCA\DAV\CalDAV\UpcomingEventsService;
+use OCA\DAV\ResponseDefinitions;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCSController;
+use OCP\IRequest;
+
+/**
+ * @psalm-import-type DAVUpcomingEvent from ResponseDefinitions
+ */
+class UpcomingEventsController extends OCSController {
+ public function __construct(
+ IRequest $request,
+ private ?string $userId,
+ private UpcomingEventsService $service,
+ ) {
+ parent::__construct(Application::APP_ID, $request);
+ }
+
+ /**
+ * Get information about upcoming events
+ *
+ * @param string|null $location location/URL to filter by
+ * @return DataResponse<Http::STATUS_OK, array{events: list<DAVUpcomingEvent>}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, null, array{}>
+ *
+ * 200: Upcoming events
+ * 401: When not authenticated
+ */
+ #[NoAdminRequired]
+ public function getEvents(?string $location = null): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+ }
+
+ return new DataResponse([
+ 'events' => array_values(array_map(fn (UpcomingEvent $e) => $e->jsonSerialize(), $this->service->getEvents(
+ $this->userId,
+ $location,
+ ))),
+ ]);
+ }
+
+}