diff options
Diffstat (limited to 'apps/dav/lib')
144 files changed, 2223 insertions, 573 deletions
diff --git a/apps/dav/lib/AppInfo/Application.php b/apps/dav/lib/AppInfo/Application.php index edf7dd1214f..9d5921a1e83 100644 --- a/apps/dav/lib/AppInfo/Application.php +++ b/apps/dav/lib/AppInfo/Application.php @@ -20,7 +20,6 @@ use OCA\DAV\CalDAV\Reminder\NotificationProviderManager; use OCA\DAV\CalDAV\Reminder\Notifier; use OCA\DAV\Capabilities; use OCA\DAV\CardDAV\ContactsManager; -use OCA\DAV\CardDAV\PhotoCache; use OCA\DAV\CardDAV\SyncService; use OCA\DAV\Events\AddressBookCreatedEvent; use OCA\DAV\Events\AddressBookDeletedEvent; @@ -82,7 +81,6 @@ use OCP\Config\BeforePreferenceSetEvent; use OCP\Contacts\IManager as IContactsManager; use OCP\DB\Events\AddMissingIndicesEvent; use OCP\Federation\Events\TrustedServerRemovedEvent; -use OCP\Files\AppData\IAppDataFactory; use OCP\IUserSession; use OCP\Server; use OCP\Settings\Events\DeclarativeSettingsGetValueEvent; @@ -112,12 +110,6 @@ class Application extends App implements IBootstrap { public function register(IRegistrationContext $context): void { $context->registerServiceAlias('CardDAVSyncService', SyncService::class); - $context->registerService(PhotoCache::class, function (ContainerInterface $c) { - return new PhotoCache( - $c->get(IAppDataFactory::class)->get('dav-photocache'), - $c->get(LoggerInterface::class) - ); - }); $context->registerService(AppCalendarPlugin::class, function (ContainerInterface $c) { return new AppCalendarPlugin( $c->get(ICalendarManager::class), diff --git a/apps/dav/lib/Avatars/RootCollection.php b/apps/dav/lib/Avatars/RootCollection.php index ec88c65793f..033dcaf7a5c 100644 --- a/apps/dav/lib/Avatars/RootCollection.php +++ b/apps/dav/lib/Avatars/RootCollection.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2017 ownCloud GmbH diff --git a/apps/dav/lib/BackgroundJob/CleanupOrphanedChildrenJob.php b/apps/dav/lib/BackgroundJob/CleanupOrphanedChildrenJob.php new file mode 100644 index 00000000000..8a5e34381a7 --- /dev/null +++ b/apps/dav/lib/BackgroundJob/CleanupOrphanedChildrenJob.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\BackgroundJob; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\QueuedJob; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; + +class CleanupOrphanedChildrenJob extends QueuedJob { + public const ARGUMENT_CHILD_TABLE = 'childTable'; + public const ARGUMENT_PARENT_TABLE = 'parentTable'; + public const ARGUMENT_PARENT_ID = 'parentId'; + public const ARGUMENT_LOG_MESSAGE = 'logMessage'; + + private const BATCH_SIZE = 1000; + + public function __construct( + ITimeFactory $time, + private readonly IDBConnection $connection, + private readonly LoggerInterface $logger, + private readonly IJobList $jobList, + ) { + parent::__construct($time); + } + + protected function run($argument): void { + $childTable = $argument[self::ARGUMENT_CHILD_TABLE]; + $parentTable = $argument[self::ARGUMENT_PARENT_TABLE]; + $parentId = $argument[self::ARGUMENT_PARENT_ID]; + $logMessage = $argument[self::ARGUMENT_LOG_MESSAGE]; + + $orphanCount = $this->cleanUpOrphans($childTable, $parentTable, $parentId); + $this->logger->debug(sprintf($logMessage, $orphanCount)); + + // Requeue if there might be more orphans + if ($orphanCount >= self::BATCH_SIZE) { + $this->jobList->add(self::class, $argument); + } + } + + private function cleanUpOrphans( + string $childTable, + string $parentTable, + string $parentId, + ): int { + // We can't merge both queries into a single one here as DELETEing from a table while + // SELECTing it in a sub query is not supported by Oracle DB. + // Ref https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/delete.html#idm46006185488144 + + $selectQb = $this->connection->getQueryBuilder(); + + $selectQb->select('c.id') + ->from($childTable, 'c') + ->leftJoin('c', $parentTable, 'p', $selectQb->expr()->eq('c.' . $parentId, 'p.id')) + ->where($selectQb->expr()->isNull('p.id')) + ->setMaxResults(self::BATCH_SIZE); + + if (\in_array($parentTable, ['calendars', 'calendarsubscriptions'], true)) { + $calendarType = $parentTable === 'calendarsubscriptions' ? CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION : CalDavBackend::CALENDAR_TYPE_CALENDAR; + $selectQb->andWhere($selectQb->expr()->eq('c.calendartype', $selectQb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + } + + $result = $selectQb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + if (empty($rows)) { + return 0; + } + + $orphanItems = array_map(static fn ($row) => $row['id'], $rows); + $deleteQb = $this->connection->getQueryBuilder(); + $deleteQb->delete($childTable) + ->where($deleteQb->expr()->in('id', $deleteQb->createNamedParameter($orphanItems, IQueryBuilder::PARAM_INT_ARRAY))); + $deleteQb->executeStatement(); + + return count($orphanItems); + } +} diff --git a/apps/dav/lib/BulkUpload/BulkUploadPlugin.php b/apps/dav/lib/BulkUpload/BulkUploadPlugin.php index 137bad34bf5..d4faf3764e1 100644 --- a/apps/dav/lib/BulkUpload/BulkUploadPlugin.php +++ b/apps/dav/lib/BulkUpload/BulkUploadPlugin.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-only diff --git a/apps/dav/lib/BulkUpload/MultipartRequestParser.php b/apps/dav/lib/BulkUpload/MultipartRequestParser.php index 96a90f82cde..50f8cff76ba 100644 --- a/apps/dav/lib/BulkUpload/MultipartRequestParser.php +++ b/apps/dav/lib/BulkUpload/MultipartRequestParser.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-only @@ -57,7 +58,13 @@ class MultipartRequestParser { */ private function parseBoundaryFromHeaders(string $contentType): string { try { + if (!str_contains($contentType, ';')) { + throw new \InvalidArgumentException('No semicolon in header'); + } [$mimeType, $boundary] = explode(';', $contentType); + if (!str_contains($boundary, '=')) { + throw new \InvalidArgumentException('No equal in boundary header'); + } [$boundaryKey, $boundaryValue] = explode('=', $boundary); } catch (\Exception $e) { throw new BadRequest('Error while parsing boundary in Content-Type header.', Http::STATUS_BAD_REQUEST, $e); diff --git a/apps/dav/lib/CalDAV/Activity/Backend.php b/apps/dav/lib/CalDAV/Activity/Backend.php index 5ae71f4ecc6..f0c49e6e28c 100644 --- a/apps/dav/lib/CalDAV/Activity/Backend.php +++ b/apps/dav/lib/CalDAV/Activity/Backend.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php b/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php index 7411202044d..78579ee84b7 100644 --- a/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Activity/Filter/Todo.php b/apps/dav/lib/CalDAV/Activity/Filter/Todo.php index 6bc7bd4b308..b001f90c28d 100644 --- a/apps/dav/lib/CalDAV/Activity/Filter/Todo.php +++ b/apps/dav/lib/CalDAV/Activity/Filter/Todo.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Base.php b/apps/dav/lib/CalDAV/Activity/Provider/Base.php index 9a75acb878c..558abe0ca1a 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Base.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Base.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -40,8 +41,8 @@ abstract class Base implements IProvider { * @return array */ protected function generateCalendarParameter($data, IL10N $l) { - if ($data['uri'] === CalDavBackend::PERSONAL_CALENDAR_URI && - $data['name'] === CalDavBackend::PERSONAL_CALENDAR_NAME) { + if ($data['uri'] === CalDavBackend::PERSONAL_CALENDAR_URI + && $data['name'] === CalDavBackend::PERSONAL_CALENDAR_NAME) { return [ 'type' => 'calendar', 'id' => (string)$data['id'], diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php b/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php index 898eb2b5cb6..8c93ddae431 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Event.php b/apps/dav/lib/CalDAV/Activity/Provider/Event.php index 41b610542df..87551d7840b 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Event.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Event.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -78,14 +79,9 @@ class Event extends Base { // as seen from the affected user. $objectId = base64_encode($this->url->getWebroot() . '/remote.php/dav/calendars/' . $affectedUser . '/' . $calendarUri . '_shared_by_' . $linkData['owner'] . '/' . $linkData['object_uri']); } - $link = [ - 'view' => 'dayGridMonth', - 'timeRange' => 'now', - 'mode' => 'sidebar', + $params['link'] = $this->url->linkToRouteAbsolute('calendar.view.indexdirect.edit', [ 'objectId' => $objectId, - 'recurrenceId' => 'next' - ]; - $params['link'] = $this->url->linkToRouteAbsolute('calendar.view.indexview.timerange.edit', $link); + ]); } catch (\Exception $error) { // Do nothing } diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Todo.php b/apps/dav/lib/CalDAV/Activity/Provider/Todo.php index 1e817663439..fc0625ec970 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Todo.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Todo.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php b/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php index a201213f784..0ad86a919bc 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Event.php b/apps/dav/lib/CalDAV/Activity/Setting/Event.php index ea049738251..ea9476d6f08 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/Event.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/Event.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Todo.php b/apps/dav/lib/CalDAV/Activity/Setting/Todo.php index 7ac3b1e0f76..ed8377b0ffa 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/Todo.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/Todo.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php b/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php index cd0b58c53d2..681709cdb6f 100644 --- a/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php +++ b/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/BirthdayService.php b/apps/dav/lib/CalDAV/BirthdayService.php index e1e46316d74..680b228766f 100644 --- a/apps/dav/lib/CalDAV/BirthdayService.php +++ b/apps/dav/lib/CalDAV/BirthdayService.php @@ -295,8 +295,8 @@ class BirthdayService { } return ( - $newCalendarData->VEVENT->DTSTART->getValue() !== $existingBirthday->VEVENT->DTSTART->getValue() || - $newCalendarData->VEVENT->SUMMARY->getValue() !== $existingBirthday->VEVENT->SUMMARY->getValue() + $newCalendarData->VEVENT->DTSTART->getValue() !== $existingBirthday->VEVENT->DTSTART->getValue() + || $newCalendarData->VEVENT->SUMMARY->getValue() !== $existingBirthday->VEVENT->SUMMARY->getValue() ); } diff --git a/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php b/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php index 4d25f5bb501..cc1bab6d4fc 100644 --- a/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php +++ b/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php @@ -9,11 +9,12 @@ declare(strict_types=1); namespace OCA\DAV\CalDAV; use OCP\Calendar\ICalendar; +use OCP\Calendar\ICalendarIsEnabled; use OCP\Calendar\ICalendarIsShared; use OCP\Calendar\ICalendarIsWritable; use OCP\Constants; -class CachedSubscriptionImpl implements ICalendar, ICalendarIsShared, ICalendarIsWritable { +class CachedSubscriptionImpl implements ICalendar, ICalendarIsEnabled, ICalendarIsShared, ICalendarIsWritable { public function __construct( private CachedSubscription $calendar, @@ -54,16 +55,6 @@ class CachedSubscriptionImpl implements ICalendar, ICalendarIsShared, ICalendarI return $this->calendarInfo['{http://apple.com/ns/ical/}calendar-color']; } - /** - * @param string $pattern which should match within the $searchProperties - * @param array $searchProperties defines the properties within the query pattern should match - * @param array $options - optional parameters: - * ['timerange' => ['start' => new DateTime(...), 'end' => new DateTime(...)]] - * @param int|null $limit - limit number of search results - * @param int|null $offset - offset for paging of search results - * @return array an array of events/journals/todos which are arrays of key-value-pairs - * @since 13.0.0 - */ public function search(string $pattern, array $searchProperties = [], array $options = [], $limit = null, $offset = null): array { return $this->backend->search($this->calendarInfo, $pattern, $searchProperties, $options, $limit, $offset); } @@ -86,6 +77,13 @@ class CachedSubscriptionImpl implements ICalendar, ICalendarIsShared, ICalendarI return $result; } + /** + * @since 32.0.0 + */ + public function isEnabled(): bool { + return $this->calendarInfo['{http://owncloud.org/ns}calendar-enabled'] ?? true; + } + public function isWritable(): bool { return false; } diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 81e999cc356..27750913105 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. @@ -9,6 +10,7 @@ namespace OCA\DAV\CalDAV; use DateTime; use DateTimeImmutable; use DateTimeInterface; +use Generator; use OCA\DAV\AppInfo\Application; use OCA\DAV\CalDAV\Sharing\Backend; use OCA\DAV\Connector\Sabre\Principal; @@ -28,6 +30,7 @@ use OCA\DAV\Events\SubscriptionCreatedEvent; use OCA\DAV\Events\SubscriptionDeletedEvent; use OCA\DAV\Events\SubscriptionUpdatedEvent; use OCP\AppFramework\Db\TTransactional; +use OCP\Calendar\CalendarExportOptions; use OCP\Calendar\Events\CalendarObjectCreatedEvent; use OCP\Calendar\Events\CalendarObjectDeletedEvent; use OCP\Calendar\Events\CalendarObjectMovedEvent; @@ -88,6 +91,19 @@ use function time; * Code is heavily inspired by https://github.com/fruux/sabre-dav/blob/master/lib/CalDAV/Backend/PDO.php * * @package OCA\DAV\CalDAV + * + * @psalm-type CalendarInfo = array{ + * id: int, + * uri: string, + * principaluri: string, + * '{http://calendarserver.org/ns/}getctag': string, + * '{http://sabredav.org/ns}sync-token': int, + * '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set': \Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet, + * '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp': \Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp, + * '{DAV:}displayname': string, + * '{urn:ietf:params:xml:ns:caldav}calendar-timezone': ?string, + * '{http://nextcloud.com/ns}owner-displayname': string, + * } */ class CalDavBackend extends AbstractBackend implements SyncSupport, SubscriptionSupport, SchedulingSupport { use TTransactional; @@ -197,15 +213,13 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** - * Return the number of calendars for a principal + * Return the number of calendars owned by the given principal. * - * By default this excludes the automatically generated birthday calendar + * Calendars shared with the given principal are not counted! * - * @param $principalUri - * @param bool $excludeBirthday - * @return int + * By default, this excludes the automatically generated birthday calendar. */ - public function getCalendarsForUserCount($principalUri, $excludeBirthday = true) { + public function getCalendarsForUserCount(string $principalUri, bool $excludeBirthday = true): int { $principalUri = $this->convertPrincipal($principalUri, true); $query = $this->db->getQueryBuilder(); $query->select($query->func()->count('*')) @@ -372,7 +386,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $subSelect->select('resourceid') ->from('dav_shares', 'd') ->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) - ->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)); + ->andWhere($subSelect->expr()->eq('d.principaluri', $select->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)); $select->select($fields) ->from('dav_shares', 's') @@ -396,8 +410,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription // New share can not have more permissions than the old one. continue; } - if (isset($calendars[$row['id']][$readOnlyPropertyName]) && - $calendars[$row['id']][$readOnlyPropertyName] === 0) { + if (isset($calendars[$row['id']][$readOnlyPropertyName]) + && $calendars[$row['id']][$readOnlyPropertyName] === 0) { // Old share is already read-write, no more permissions can be gained continue; } @@ -649,7 +663,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** - * @return array{id: int, uri: string, '{http://calendarserver.org/ns/}getctag': string, '{http://sabredav.org/ns}sync-token': int, '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set': SupportedCalendarComponentSet, '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp': ScheduleCalendarTransp, '{urn:ietf:params:xml:ns:caldav}calendar-timezone': ?string }|null + * @psalm-return CalendarInfo|null + * @return array|null */ public function getCalendarById(int $calendarId): ?array { $fields = array_column($this->propertyMap, 0); @@ -988,6 +1003,44 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** + * Returns all calendar entries as a stream of data + * + * @since 32.0.0 + * + * @return Generator<array> + */ + public function exportCalendar(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, ?CalendarExportOptions $options = null): Generator { + // extract options + $rangeStart = $options?->getRangeStart(); + $rangeCount = $options?->getRangeCount(); + // construct query + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('calendarobjects') + ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) + ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) + ->andWhere($qb->expr()->isNull('deleted_at')); + if ($rangeStart !== null) { + $qb->andWhere($qb->expr()->gt('uid', $qb->createNamedParameter($rangeStart))); + } + if ($rangeCount !== null) { + $qb->setMaxResults($rangeCount); + } + if ($rangeStart !== null || $rangeCount !== null) { + $qb->orderBy('uid', 'ASC'); + } + $rs = $qb->executeQuery(); + // iterate through results + try { + while (($row = $rs->fetch()) !== false) { + yield $row; + } + } finally { + $rs->closeCursor(); + } + } + + /** * Returns all calendar objects with limited metadata for a calendar * * Every item contains an array with the following keys: @@ -1429,37 +1482,40 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription /** * Moves a calendar object from calendar to calendar. * - * @param int $sourceCalendarId + * @param string $sourcePrincipalUri + * @param int $sourceObjectId + * @param string $targetPrincipalUri * @param int $targetCalendarId - * @param int $objectId - * @param string $oldPrincipalUri - * @param string $newPrincipalUri + * @param string $tragetObjectUri * @param int $calendarType * @return bool * @throws Exception */ - public function moveCalendarObject(int $sourceCalendarId, int $targetCalendarId, int $objectId, string $oldPrincipalUri, string $newPrincipalUri, int $calendarType = self::CALENDAR_TYPE_CALENDAR): bool { + public function moveCalendarObject(string $sourcePrincipalUri, int $sourceObjectId, string $targetPrincipalUri, int $targetCalendarId, string $tragetObjectUri, int $calendarType = self::CALENDAR_TYPE_CALENDAR): bool { $this->cachedObjects = []; - return $this->atomic(function () use ($sourceCalendarId, $targetCalendarId, $objectId, $oldPrincipalUri, $newPrincipalUri, $calendarType) { - $object = $this->getCalendarObjectById($oldPrincipalUri, $objectId); + return $this->atomic(function () use ($sourcePrincipalUri, $sourceObjectId, $targetPrincipalUri, $targetCalendarId, $tragetObjectUri, $calendarType) { + $object = $this->getCalendarObjectById($sourcePrincipalUri, $sourceObjectId); if (empty($object)) { return false; } + $sourceCalendarId = $object['calendarid']; + $sourceObjectUri = $object['uri']; + $query = $this->db->getQueryBuilder(); $query->update('calendarobjects') ->set('calendarid', $query->createNamedParameter($targetCalendarId, IQueryBuilder::PARAM_INT)) - ->where($query->expr()->eq('id', $query->createNamedParameter($objectId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) + ->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR)) + ->where($query->expr()->eq('id', $query->createNamedParameter($sourceObjectId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) ->executeStatement(); - $this->purgeProperties($sourceCalendarId, $objectId); - $this->updateProperties($targetCalendarId, $object['uri'], $object['calendardata'], $calendarType); + $this->purgeProperties($sourceCalendarId, $sourceObjectId); + $this->updateProperties($targetCalendarId, $tragetObjectUri, $object['calendardata'], $calendarType); - $this->addChanges($sourceCalendarId, [$object['uri']], 3, $calendarType); - $this->addChanges($targetCalendarId, [$object['uri']], 1, $calendarType); + $this->addChanges($sourceCalendarId, [$sourceObjectUri], 3, $calendarType); + $this->addChanges($targetCalendarId, [$tragetObjectUri], 1, $calendarType); - $object = $this->getCalendarObjectById($newPrincipalUri, $objectId); + $object = $this->getCalendarObjectById($targetPrincipalUri, $sourceObjectId); // Calendar Object wasn't found - possibly because it was deleted in the meantime by a different client if (empty($object)) { return false; @@ -1481,25 +1537,6 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription }, $this->db); } - - /** - * @param int $calendarObjectId - * @param int $classification - */ - public function setClassification($calendarObjectId, $classification) { - $this->cachedObjects = []; - if (!in_array($classification, [ - self::CLASSIFICATION_PUBLIC, self::CLASSIFICATION_PRIVATE, self::CLASSIFICATION_CONFIDENTIAL - ])) { - throw new \InvalidArgumentException(); - } - $query = $this->db->getQueryBuilder(); - $query->update('calendarobjects') - ->set('classification', $query->createNamedParameter($classification)) - ->where($query->expr()->eq('id', $query->createNamedParameter($calendarObjectId))) - ->executeStatement(); - } - /** * Deletes an existing calendar object. * @@ -1973,8 +2010,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription if ($pattern !== '') { $innerQuery->andWhere($innerQuery->expr()->iLike('op.value', - $outerQuery->createNamedParameter('%' . - $this->db->escapeLikeParameter($pattern) . '%'))); + $outerQuery->createNamedParameter('%' + . $this->db->escapeLikeParameter($pattern) . '%'))); } $start = null; @@ -2950,7 +2987,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription 'calendarid' => $query->createNamedParameter($calendarId), 'operation' => $query->createNamedParameter($operation), 'calendartype' => $query->createNamedParameter($calendarType), - 'created_at' => time(), + 'created_at' => $query->createNamedParameter(time()), ]); foreach ($objectUris as $uri) { $query->setParameter('uri', $uri); @@ -3628,4 +3665,26 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->where($cmd->expr()->eq('uid', $cmd->createNamedParameter($eventId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)); $cmd->executeStatement(); } + + public function unshare(IShareable $shareable, string $principal): void { + $this->atomic(function () use ($shareable, $principal): void { + $calendarData = $this->getCalendarById($shareable->getResourceId()); + if ($calendarData === null) { + throw new \RuntimeException('Trying to update shares for non-existing calendar: ' . $shareable->getResourceId()); + } + + $oldShares = $this->getShares($shareable->getResourceId()); + $unshare = $this->calendarSharingBackend->unshare($shareable, $principal); + + if ($unshare) { + $this->dispatcher->dispatchTyped(new CalendarShareUpdatedEvent( + $shareable->getResourceId(), + $calendarData, + $oldShares, + [], + [$principal] + )); + } + }, $this->db); + } } diff --git a/apps/dav/lib/CalDAV/Calendar.php b/apps/dav/lib/CalDAV/Calendar.php index 6fd7d147131..dd3a4cf3f69 100644 --- a/apps/dav/lib/CalDAV/Calendar.php +++ b/apps/dav/lib/CalDAV/Calendar.php @@ -53,8 +53,8 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable if ($this->getName() === BirthdayService::BIRTHDAY_CALENDAR_URI && strcasecmp($this->calendarInfo['{DAV:}displayname'], 'Contact birthdays') === 0) { $this->calendarInfo['{DAV:}displayname'] = $l10n->t('Contact birthdays'); } - if ($this->getName() === CalDavBackend::PERSONAL_CALENDAR_URI && - $this->calendarInfo['{DAV:}displayname'] === CalDavBackend::PERSONAL_CALENDAR_NAME) { + if ($this->getName() === CalDavBackend::PERSONAL_CALENDAR_URI + && $this->calendarInfo['{DAV:}displayname'] === CalDavBackend::PERSONAL_CALENDAR_NAME) { $this->calendarInfo['{DAV:}displayname'] = $l10n->t('Personal'); } $this->l10n = $l10n; @@ -214,12 +214,8 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable } public function delete() { - if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal']) && - $this->calendarInfo['{http://owncloud.org/ns}owner-principal'] !== $this->calendarInfo['principaluri']) { - $principal = 'principal:' . parent::getOwner(); - $this->caldavBackend->updateShares($this, [], [ - $principal - ]); + if ($this->isShared()) { + $this->caldavBackend->unshare($this, 'principal:' . $this->getPrincipalURI()); return; } @@ -391,9 +387,14 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable if (!($sourceNode instanceof CalendarObject)) { return false; } - try { - return $this->caldavBackend->moveCalendarObject($sourceNode->getCalendarId(), (int)$this->calendarInfo['id'], $sourceNode->getId(), $sourceNode->getOwner(), $this->getOwner()); + return $this->caldavBackend->moveCalendarObject( + $sourceNode->getOwner(), + $sourceNode->getId(), + $this->getOwner(), + $this->getResourceId(), + $targetName, + ); } catch (Exception $e) { $this->logger->error('Could not move calendar object: ' . $e->getMessage(), ['exception' => $e]); return false; diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php index b3062f005ee..b79bf7ea2d0 100644 --- a/apps/dav/lib/CalDAV/CalendarImpl.php +++ b/apps/dav/lib/CalDAV/CalendarImpl.php @@ -8,9 +8,15 @@ declare(strict_types=1); */ namespace OCA\DAV\CalDAV; +use Generator; use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin; use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer; +use OCP\Calendar\CalendarExportOptions; use OCP\Calendar\Exceptions\CalendarException; +use OCP\Calendar\ICalendarExport; +use OCP\Calendar\ICalendarIsEnabled; +use OCP\Calendar\ICalendarIsShared; +use OCP\Calendar\ICalendarIsWritable; use OCP\Calendar\ICreateFromString; use OCP\Calendar\IHandleImipMessage; use OCP\Constants; @@ -24,7 +30,7 @@ use Sabre\VObject\Property; use Sabre\VObject\Reader; use function Sabre\Uri\split as uriSplit; -class CalendarImpl implements ICreateFromString, IHandleImipMessage { +class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIsWritable, ICalendarIsShared, ICalendarExport, ICalendarIsEnabled { public function __construct( private Calendar $calendar, /** @var array<string, mixed> */ @@ -87,16 +93,6 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage { return $vtimezone; } - /** - * @param string $pattern which should match within the $searchProperties - * @param array $searchProperties defines the properties within the query pattern should match - * @param array $options - optional parameters: - * ['timerange' => ['start' => new DateTime(...), 'end' => new DateTime(...)]] - * @param int|null $limit - limit number of search results - * @param int|null $offset - offset for paging of search results - * @return array an array of events/journals/todos which are arrays of key-value-pairs - * @since 13.0.0 - */ public function search(string $pattern, array $searchProperties = [], array $options = [], $limit = null, $offset = null): array { return $this->backend->search($this->calendarInfo, $pattern, $searchProperties, $options, $limit, $offset); @@ -132,6 +128,13 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage { } /** + * @since 32.0.0 + */ + public function isEnabled(): bool { + return $this->calendarInfo['{http://owncloud.org/ns}calendar-enabled'] ?? true; + } + + /** * @since 31.0.0 */ public function isWritable(): bool { @@ -257,4 +260,27 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage { public function getInvitationResponseServer(): InvitationResponseServer { return new InvitationResponseServer(false); } + + /** + * Export objects + * + * @since 32.0.0 + * + * @return Generator<mixed, \Sabre\VObject\Component\VCalendar, mixed, mixed> + */ + public function export(?CalendarExportOptions $options = null): Generator { + foreach ( + $this->backend->exportCalendar( + $this->calendarInfo['id'], + $this->backend::CALENDAR_TYPE_CALENDAR, + $options + ) as $event + ) { + $vObject = Reader::read($event['calendardata']); + if ($vObject instanceof VCalendar) { + yield $vObject; + } + } + } + } diff --git a/apps/dav/lib/CalDAV/CalendarManager.php b/apps/dav/lib/CalDAV/CalendarManager.php index 1baf53ee457..a2d2f1cda8a 100644 --- a/apps/dav/lib/CalDAV/CalendarManager.php +++ b/apps/dav/lib/CalDAV/CalendarManager.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/CalendarObject.php b/apps/dav/lib/CalDAV/CalendarObject.php index 90a1e97ec4d..02178b4236f 100644 --- a/apps/dav/lib/CalDAV/CalendarObject.php +++ b/apps/dav/lib/CalDAV/CalendarObject.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. diff --git a/apps/dav/lib/CalDAV/CalendarProvider.php b/apps/dav/lib/CalDAV/CalendarProvider.php index a31322b2b49..3cc4039ed36 100644 --- a/apps/dav/lib/CalDAV/CalendarProvider.php +++ b/apps/dav/lib/CalDAV/CalendarProvider.php @@ -8,6 +8,8 @@ declare(strict_types=1); */ namespace OCA\DAV\CalDAV; +use OCA\DAV\Db\Property; +use OCA\DAV\Db\PropertyMapper; use OCP\Calendar\ICalendarProvider; use OCP\IConfig; use OCP\IL10N; @@ -20,6 +22,7 @@ class CalendarProvider implements ICalendarProvider { private IL10N $l10n, private IConfig $config, private LoggerInterface $logger, + private PropertyMapper $propertyMapper, ) { } @@ -35,6 +38,7 @@ class CalendarProvider implements ICalendarProvider { $iCalendars = []; foreach ($calendarInfos as $calendarInfo) { + $calendarInfo = array_merge($calendarInfo, $this->getAdditionalProperties($calendarInfo['principaluri'], $calendarInfo['uri'])); $calendar = new Calendar($this->calDavBackend, $calendarInfo, $this->l10n, $this->config, $this->logger); $iCalendars[] = new CalendarImpl( $calendar, @@ -44,4 +48,23 @@ class CalendarProvider implements ICalendarProvider { } return $iCalendars; } + + public function getAdditionalProperties(string $principalUri, string $calendarUri): array { + $user = str_replace('principals/users/', '', $principalUri); + $path = 'calendars/' . $user . '/' . $calendarUri; + + $properties = $this->propertyMapper->findPropertiesByPath($user, $path); + + $list = []; + foreach ($properties as $property) { + if ($property instanceof Property) { + $list[$property->getPropertyname()] = match ($property->getPropertyname()) { + '{http://owncloud.org/ns}calendar-enabled' => (bool)$property->getPropertyvalue(), + default => $property->getPropertyvalue() + }; + } + } + + return $list; + } } diff --git a/apps/dav/lib/CalDAV/CalendarRoot.php b/apps/dav/lib/CalDAV/CalendarRoot.php index bfe5f84ce31..c0a313955bb 100644 --- a/apps/dav/lib/CalDAV/CalendarRoot.php +++ b/apps/dav/lib/CalDAV/CalendarRoot.php @@ -33,8 +33,8 @@ class CalendarRoot extends \Sabre\CalDAV\CalendarRoot { } public function getName() { - if ($this->principalPrefix === 'principals/calendar-resources' || - $this->principalPrefix === 'principals/calendar-rooms') { + if ($this->principalPrefix === 'principals/calendar-resources' + || $this->principalPrefix === 'principals/calendar-rooms') { $parts = explode('/', $this->principalPrefix); return $parts[1]; diff --git a/apps/dav/lib/CalDAV/EventReader.php b/apps/dav/lib/CalDAV/EventReader.php index 7e337f3108a..b7dd2889956 100644 --- a/apps/dav/lib/CalDAV/EventReader.php +++ b/apps/dav/lib/CalDAV/EventReader.php @@ -169,9 +169,9 @@ class EventReader { if (isset($this->baseEvent->DTEND)) { $this->baseEventEndDate = $this->baseEvent->DTEND->getDateTime($this->baseEventEndTimeZone); $this->baseEventEndDateFloating = $this->baseEvent->DTEND->isFloating(); - $this->baseEventDuration = - $this->baseEvent->DTEND->getDateTime($this->baseEventEndTimeZone)->getTimeStamp() - - $this->baseEventStartDate->getTimeStamp(); + $this->baseEventDuration + = $this->baseEvent->DTEND->getDateTime($this->baseEventEndTimeZone)->getTimeStamp() + - $this->baseEventStartDate->getTimeStamp(); } // evaluate if duration exists // extract duration and calculate end date @@ -362,8 +362,8 @@ class EventReader { public function recurringConcludes(): bool { // retrieve rrule conclusions - if ($this->rruleIterator?->concludesOn() !== null || - $this->rruleIterator?->concludesAfter() !== null) { + if ($this->rruleIterator?->concludesOn() !== null + || $this->rruleIterator?->concludesAfter() !== null) { return true; } // retrieve rdate conclusions diff --git a/apps/dav/lib/CalDAV/Export/ExportService.php b/apps/dav/lib/CalDAV/Export/ExportService.php new file mode 100644 index 00000000000..552b9e2b675 --- /dev/null +++ b/apps/dav/lib/CalDAV/Export/ExportService.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Export; + +use Generator; +use OCP\Calendar\CalendarExportOptions; +use OCP\Calendar\ICalendarExport; +use OCP\ServerVersion; +use Sabre\VObject\Component; +use Sabre\VObject\Writer; + +/** + * Calendar Export Service + */ +class ExportService { + + public const FORMATS = ['ical', 'jcal', 'xcal']; + private string $systemVersion; + + public function __construct(ServerVersion $serverVersion) { + $this->systemVersion = $serverVersion->getVersionString(); + } + + /** + * Generates serialized content stream for a calendar and objects based in selected format + * + * @return Generator<string> + */ + public function export(ICalendarExport $calendar, CalendarExportOptions $options): Generator { + // output start of serialized content based on selected format + yield $this->exportStart($options->getFormat()); + // iterate through each returned vCalendar entry + // extract each component except timezones, convert to appropriate format and output + // extract any timezones and save them but do not output + $timezones = []; + foreach ($calendar->export($options) as $entry) { + $consecutive = false; + foreach ($entry->getComponents() as $vComponent) { + if ($vComponent->name === 'VTIMEZONE') { + if (isset($vComponent->TZID) && !isset($timezones[$vComponent->TZID->getValue()])) { + $timezones[$vComponent->TZID->getValue()] = clone $vComponent; + } + } else { + yield $this->exportObject($vComponent, $options->getFormat(), $consecutive); + $consecutive = true; + } + } + } + // iterate through each saved vTimezone entry, convert to appropriate format and output + foreach ($timezones as $vComponent) { + yield $this->exportObject($vComponent, $options->getFormat(), $consecutive); + $consecutive = true; + } + // output end of serialized content based on selected format + yield $this->exportFinish($options->getFormat()); + } + + /** + * Generates serialized content start based on selected format + */ + private function exportStart(string $format): string { + return match ($format) { + 'jcal' => '["vcalendar",[["version",{},"text","2.0"],["prodid",{},"text","-\/\/IDN nextcloud.com\/\/Calendar Export v' . $this->systemVersion . '\/\/EN"]],[', + 'xcal' => '<?xml version="1.0" encoding="UTF-8"?><icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0"><vcalendar><properties><version><text>2.0</text></version><prodid><text>-//IDN nextcloud.com//Calendar Export v' . $this->systemVersion . '//EN</text></prodid></properties><components>', + default => "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//IDN nextcloud.com//Calendar Export v" . $this->systemVersion . "//EN\n" + }; + } + + /** + * Generates serialized content end based on selected format + */ + private function exportFinish(string $format): string { + return match ($format) { + 'jcal' => ']]', + 'xcal' => '</components></vcalendar></icalendar>', + default => "END:VCALENDAR\n" + }; + } + + /** + * Generates serialized content for a component based on selected format + */ + private function exportObject(Component $vobject, string $format, bool $consecutive): string { + return match ($format) { + 'jcal' => $consecutive ? ',' . Writer::writeJson($vobject) : Writer::writeJson($vobject), + 'xcal' => $this->exportObjectXml($vobject), + default => Writer::write($vobject) + }; + } + + /** + * Generates serialized content for a component in xml format + */ + private function exportObjectXml(Component $vobject): string { + $writer = new \Sabre\Xml\Writer(); + $writer->openMemory(); + $writer->setIndent(false); + $vobject->xmlSerialize($writer); + return $writer->outputMemory(); + } + +} diff --git a/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php b/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php index 3f71b1db24c..08dc10f7bf4 100644 --- a/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php +++ b/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php b/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php index 6e755716397..acf81638679 100644 --- a/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php +++ b/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php b/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php index bbee4cbda7c..40a8860dcb4 100644 --- a/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php +++ b/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php index 3d650a4a059..c8a7109abde 100644 --- a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php +++ b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Outbox.php b/apps/dav/lib/CalDAV/Outbox.php index fc9dc87a574..608114d8093 100644 --- a/apps/dav/lib/CalDAV/Outbox.php +++ b/apps/dav/lib/CalDAV/Outbox.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Principal/Collection.php b/apps/dav/lib/CalDAV/Principal/Collection.php index f2cea0b5136..b76fde66464 100644 --- a/apps/dav/lib/CalDAV/Principal/Collection.php +++ b/apps/dav/lib/CalDAV/Principal/Collection.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Principal/User.php b/apps/dav/lib/CalDAV/Principal/User.php index 60b7953ea62..047d83827ed 100644 --- a/apps/dav/lib/CalDAV/Principal/User.php +++ b/apps/dav/lib/CalDAV/Principal/User.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/PublicCalendar.php b/apps/dav/lib/CalDAV/PublicCalendar.php index 4ee811efeae..9af6e544165 100644 --- a/apps/dav/lib/CalDAV/PublicCalendar.php +++ b/apps/dav/lib/CalDAV/PublicCalendar.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/PublicCalendarObject.php b/apps/dav/lib/CalDAV/PublicCalendarObject.php index c3dc5ab1843..2ab40b94347 100644 --- a/apps/dav/lib/CalDAV/PublicCalendarObject.php +++ b/apps/dav/lib/CalDAV/PublicCalendarObject.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php b/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php index db73a761b0d..76378e7a1c5 100644 --- a/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php +++ b/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php b/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php index 2fd55a12643..fb9b7298f9b 100644 --- a/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php +++ b/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Reminder/ReminderService.php b/apps/dav/lib/CalDAV/Reminder/ReminderService.php index e2d44535ce0..c75090e1560 100644 --- a/apps/dav/lib/CalDAV/Reminder/ReminderService.php +++ b/apps/dav/lib/CalDAV/Reminder/ReminderService.php @@ -441,10 +441,10 @@ class ReminderService { */ private function deleteOrProcessNext(array $reminder, VObject\Component\VEvent $vevent):void { - if ($reminder['is_repeat_based'] || - !$reminder['is_recurring'] || - !$reminder['is_relative'] || - $reminder['is_recurrence_exception']) { + if ($reminder['is_repeat_based'] + || !$reminder['is_recurring'] + || !$reminder['is_relative'] + || $reminder['is_recurrence_exception']) { $this->backend->removeReminder($reminder['id']); return; } diff --git a/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php index e1bd2f5be29..68bb3373346 100644 --- a/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php +++ b/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -25,12 +26,6 @@ use function array_values; abstract class AbstractPrincipalBackend implements BackendInterface { - /** @var ProxyMapper */ - private $proxyMapper; - - /** @var string */ - private $principalPrefix; - /** @var string */ private $dbTableName; @@ -45,13 +40,11 @@ abstract class AbstractPrincipalBackend implements BackendInterface { private IUserSession $userSession, private IGroupManager $groupManager, private LoggerInterface $logger, - ProxyMapper $proxyMapper, - string $principalPrefix, + private ProxyMapper $proxyMapper, + private string $principalPrefix, string $dbPrefix, private string $cuType, ) { - $this->proxyMapper = $proxyMapper; - $this->principalPrefix = $principalPrefix; $this->dbTableName = 'calendar_' . $dbPrefix . 's'; $this->dbMetaDataTableName = $this->dbTableName . '_md'; $this->dbForeignKeyName = $dbPrefix . '_id'; @@ -93,8 +86,8 @@ abstract class AbstractPrincipalBackend implements BackendInterface { $metaDataById[$metaDataRow[$this->dbForeignKeyName]] = []; } - $metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']] = - $metaDataRow['value']; + $metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']] + = $metaDataRow['value']; } while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { @@ -478,9 +471,9 @@ abstract class AbstractPrincipalBackend implements BackendInterface { * @return bool */ private function isAllowedToAccessResource(array $row, array $userGroups): bool { - if (!isset($row['group_restrictions']) || - $row['group_restrictions'] === null || - $row['group_restrictions'] === '') { + if (!isset($row['group_restrictions']) + || $row['group_restrictions'] === null + || $row['group_restrictions'] === '') { return true; } diff --git a/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php index 40396f67ce9..c70d93daf52 100644 --- a/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php +++ b/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php index 91cf78c296f..5704b23ae14 100644 --- a/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php +++ b/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php index 7e79388c53a..1f063540df6 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php @@ -45,7 +45,7 @@ use Sabre\VObject\Reader; * @license http://sabre.io/license/ Modified BSD License */ class IMipPlugin extends SabreIMipPlugin { - + private ?VCalendar $vCalendar = null; public const MAX_DATE = '2038-01-01'; public const METHOD_REQUEST = 'request'; @@ -156,9 +156,10 @@ class IMipPlugin extends SabreIMipPlugin { $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; return; } - // Don't send emails to things - if ($this->imipService->isRoomOrResource($attendee)) { - $this->logger->debug('No invitation sent as recipient is room or resource', [ + // Don't send emails to rooms, resources and circles + if ($this->imipService->isRoomOrResource($attendee) + || $this->imipService->isCircle($attendee)) { + $this->logger->debug('No invitation sent as recipient is room, resource or circle', [ 'attendee' => $recipient, ]); $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; @@ -185,7 +186,7 @@ class IMipPlugin extends SabreIMipPlugin { switch (strtolower($iTipMessage->method)) { case self::METHOD_REPLY: $method = self::METHOD_REPLY; - $data = $this->imipService->buildBodyData($vEvent, $oldVevent); + $data = $this->imipService->buildReplyBodyData($vEvent); $replyingAttendee = $this->imipService->getReplyingAttendee($iTipMessage); break; case self::METHOD_CANCEL: diff --git a/apps/dav/lib/CalDAV/Schedule/IMipService.php b/apps/dav/lib/CalDAV/Schedule/IMipService.php index e2844960a23..f7054eb2d34 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipService.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipService.php @@ -159,7 +159,35 @@ class IMipService { if ($eventReaderCurrent->recurs()) { $data['meeting_occurring'] = $this->generateOccurringString($eventReaderCurrent); } - + return $data; + } + + /** + * @param VEvent $vEvent + * @return array + */ + public function buildReplyBodyData(VEvent $vEvent): array { + // construct event reader + $eventReader = new EventReader($vEvent); + $defaultVal = ''; + $data = []; + $data['meeting_when'] = $this->generateWhenString($eventReader); + + foreach (self::STRING_DIFF as $key => $property) { + $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal); + } + + if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) { + $data['meeting_location_html'] = $locationHtml; + } + + $data['meeting_url_html'] = $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $data['meeting_url']) : ''; + + // generate occurring next string + if ($eventReader->recurs()) { + $data['meeting_occurring'] = $this->generateOccurringString($eventReader); + } + return $data; } @@ -324,7 +352,7 @@ class IMipService { * @return string */ public function generateWhenStringRecurringDaily(EventReader $er): string { - + // initialize $interval = (int)$er->recurringInterval(); $startTime = null; @@ -375,7 +403,7 @@ class IMipService { * @return string */ public function generateWhenStringRecurringWeekly(EventReader $er): string { - + // initialize $interval = (int)$er->recurringInterval(); $startTime = null; @@ -428,15 +456,15 @@ class IMipService { * @return string */ public function generateWhenStringRecurringMonthly(EventReader $er): string { - + // initialize $interval = (int)$er->recurringInterval(); $startTime = null; $conclusion = null; // days of month if ($er->recurringPattern() === 'R') { - $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' ' . - implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed())); + $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' ' + . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed())); } else { $days = implode(', ', $er->recurringDaysOfMonth()); } @@ -493,7 +521,7 @@ class IMipService { * @return string */ public function generateWhenStringRecurringYearly(EventReader $er): string { - + // initialize $interval = (int)$er->recurringInterval(); $startTime = null; @@ -502,8 +530,8 @@ class IMipService { $months = implode(', ', array_map(function ($value) { return $this->localizeMonthName($value); }, $er->recurringMonthsOfYearNamed())); // days of month if ($er->recurringPattern() === 'R') { - $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' ' . - implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed())); + $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' ' + . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed())); } else { $days = $er->startDateTime()->format('jS'); } @@ -582,7 +610,7 @@ class IMipService { true => $this->l10n->t('On specific dates between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]), }; } - + /** * generates a occurring next string for a recurring event * @@ -1080,8 +1108,8 @@ class IMipService { $attendee = $iTipMessage->recipient; $organizer = $iTipMessage->sender; $sequence = $iTipMessage->sequence; - $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? - $vevent->{'RECURRENCE-ID'}->serialize() : null; + $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) + ? $vevent->{'RECURRENCE-ID'}->serialize() : null; $uid = $vevent->{'UID'}; $query = $this->db->getQueryBuilder(); @@ -1155,6 +1183,21 @@ class IMipService { return false; } + public function isCircle(Property $attendee): bool { + $cuType = $attendee->offsetGet('CUTYPE'); + if (!$cuType instanceof Parameter) { + return false; + } + + $uri = $attendee->getValue(); + if (!$uri) { + return false; + } + + $cuTypeValue = $cuType->getValue(); + return $cuTypeValue === 'GROUP' && str_starts_with($uri, 'mailto:circle+'); + } + public function minimizeInterval(\DateInterval $dateInterval): array { // evaluate if time interval is in the past if ($dateInterval->invert == 1) { diff --git a/apps/dav/lib/CalDAV/Schedule/Plugin.php b/apps/dav/lib/CalDAV/Schedule/Plugin.php index da0f9ea5637..a001df8b2a8 100644 --- a/apps/dav/lib/CalDAV/Schedule/Plugin.php +++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php @@ -372,8 +372,8 @@ EOF; return null; } - $isResourceOrRoom = str_starts_with($principalUrl, 'principals/calendar-resources') || - str_starts_with($principalUrl, 'principals/calendar-rooms'); + $isResourceOrRoom = str_starts_with($principalUrl, 'principals/calendar-resources') + || str_starts_with($principalUrl, 'principals/calendar-rooms'); if (str_starts_with($principalUrl, 'principals/users')) { [, $userId] = split($principalUrl); diff --git a/apps/dav/lib/CalDAV/Search/SearchPlugin.php b/apps/dav/lib/CalDAV/Search/SearchPlugin.php index 602ce151e12..27e39a76305 100644 --- a/apps/dav/lib/CalDAV/Search/SearchPlugin.php +++ b/apps/dav/lib/CalDAV/Search/SearchPlugin.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -61,8 +62,8 @@ class SearchPlugin extends ServerPlugin { $server->on('report', [$this, 'report']); - $server->xml->elementMap['{' . self::NS_Nextcloud . '}calendar-search'] = - CalendarSearchReport::class; + $server->xml->elementMap['{' . self::NS_Nextcloud . '}calendar-search'] + = CalendarSearchReport::class; } /** diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php index 8a130865842..21a4fff1caf 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php index 943e657903e..a98b325397b 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php index 439a795dde9..ef438aa0258 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php index 3b03b63e909..0c31f32348a 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php index 42ecf630f44..251120e35cc 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php index b10cf3140cf..6d6bf958496 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php b/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php index 639d0b32655..6ece88fa87b 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CalDAV/TipBroker.php b/apps/dav/lib/CalDAV/TipBroker.php index 43eff124f0b..16e68fde1f0 100644 --- a/apps/dav/lib/CalDAV/TipBroker.php +++ b/apps/dav/lib/CalDAV/TipBroker.php @@ -97,7 +97,7 @@ class TipBroker extends Broker { // Also If the meeting STATUS property was changed to CANCELLED // we need to send the attendee a CANCEL message. if (!$attendee['newInstances'] || $eventInfo['status'] === 'CANCELLED') { - + $message->method = $icalMsg->METHOD = 'CANCEL'; $message->significantChange = true; // clone base event @@ -108,7 +108,7 @@ class TipBroker extends Broker { $event->DTSTAMP = gmdate('Ymd\\THis\\Z'); $event->SEQUENCE = $message->sequence; $icalMsg->add($event); - + } else { // The attendee gets the updated event body $message->method = $icalMsg->METHOD = 'REQUEST'; @@ -124,11 +124,11 @@ class TipBroker extends Broker { $oldAttendeeInstances = array_keys($attendee['oldInstances']); $newAttendeeInstances = array_keys($attendee['newInstances']); - $message->significantChange = - $attendee['forceSend'] === 'REQUEST' || - count($oldAttendeeInstances) !== count($newAttendeeInstances) || - count(array_diff($oldAttendeeInstances, $newAttendeeInstances)) > 0 || - $oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash']; + $message->significantChange + = $attendee['forceSend'] === 'REQUEST' + || count($oldAttendeeInstances) !== count($newAttendeeInstances) + || count(array_diff($oldAttendeeInstances, $newAttendeeInstances)) > 0 + || $oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash']; foreach ($attendee['newInstances'] as $instanceId => $instanceInfo) { $currentEvent = clone $eventInfo['instances'][$instanceId]; diff --git a/apps/dav/lib/CalDAV/UpcomingEventsService.php b/apps/dav/lib/CalDAV/UpcomingEventsService.php index 9c054deb4e0..6614d937ff7 100644 --- a/apps/dav/lib/CalDAV/UpcomingEventsService.php +++ b/apps/dav/lib/CalDAV/UpcomingEventsService.php @@ -47,20 +47,36 @@ class UpcomingEventsService { $this->userManager->get($userId), ); - return array_map(fn (array $event) => new UpcomingEvent( - $event['uri'], - ($event['objects'][0]['RECURRENCE-ID'][0] ?? null)?->getTimeStamp(), - $event['calendar-uri'], - $event['objects'][0]['DTSTART'][0]?->getTimestamp(), - $event['objects'][0]['SUMMARY'][0] ?? null, - $event['objects'][0]['LOCATION'][0] ?? null, - match ($calendarAppEnabled) { - // TODO: create a named, deep route in calendar - // TODO: it's a code smell to just assume this route exists, find an abstraction - true => $this->urlGenerator->linkToRouteAbsolute('calendar.view.index'), - false => null, - }, - ), $events); + return array_map(function (array $event) use ($userId, $calendarAppEnabled) { + $calendarAppUrl = null; + + if ($calendarAppEnabled) { + $arguments = [ + 'objectId' => base64_encode($this->urlGenerator->getWebroot() . '/remote.php/dav/calendars/' . $userId . '/' . $event['calendar-uri'] . '/' . $event['uri']), + ]; + + if (isset($event['RECURRENCE-ID'])) { + $arguments['recurrenceId'] = $event['RECURRENCE-ID'][0]; + } + /** + * TODO: create a named, deep route in calendar (it's a code smell to just assume this route exists, find an abstraction) + * When changing, also adjust for: + * - spreed/lib/Service/CalendarIntegrationService.php#getDashboardEvents + * - spreed/lib/Service/CalendarIntegrationService.php#getMutualEvents + */ + $calendarAppUrl = $this->urlGenerator->linkToRouteAbsolute('calendar.view.indexdirect.edit', $arguments); + } + + return new UpcomingEvent( + $event['uri'], + ($event['objects'][0]['RECURRENCE-ID'][0] ?? null)?->getTimeStamp(), + $event['calendar-uri'], + $event['objects'][0]['DTSTART'][0]?->getTimestamp(), + $event['objects'][0]['SUMMARY'][0] ?? null, + $event['objects'][0]['LOCATION'][0] ?? null, + $calendarAppUrl, + ); + }, $events); } } diff --git a/apps/dav/lib/Capabilities.php b/apps/dav/lib/Capabilities.php index ab4e53fce37..f9bad25bf31 100644 --- a/apps/dav/lib/Capabilities.php +++ b/apps/dav/lib/Capabilities.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 ownCloud GmbH * SPDX-License-Identifier: AGPL-3.0-only @@ -17,12 +18,13 @@ class Capabilities implements ICapability { } /** - * @return array{dav: array{chunking: string, bulkupload?: string, absence-supported?: bool, absence-replacement?: bool}} + * @return array{dav: array{chunking: string, public_shares_chunking: bool, bulkupload?: string, absence-supported?: bool, absence-replacement?: bool}} */ public function getCapabilities() { $capabilities = [ 'dav' => [ 'chunking' => '1.0', + 'public_shares_chunking' => true, ] ]; if ($this->config->getSystemValueBool('bulkupload.enabled', true)) { diff --git a/apps/dav/lib/CardDAV/Activity/Filter.php b/apps/dav/lib/CardDAV/Activity/Filter.php index 8934c455def..8b221a29ff0 100644 --- a/apps/dav/lib/CardDAV/Activity/Filter.php +++ b/apps/dav/lib/CardDAV/Activity/Filter.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/CardDAV/Activity/Provider/Base.php b/apps/dav/lib/CardDAV/Activity/Provider/Base.php index 0c73c8558d0..ea7680aed60 100644 --- a/apps/dav/lib/CardDAV/Activity/Provider/Base.php +++ b/apps/dav/lib/CardDAV/Activity/Provider/Base.php @@ -41,8 +41,8 @@ abstract class Base implements IProvider { * @return array */ protected function generateAddressbookParameter(array $data, IL10N $l): array { - if ($data['uri'] === CardDavBackend::PERSONAL_ADDRESSBOOK_URI && - $data['name'] === CardDavBackend::PERSONAL_ADDRESSBOOK_NAME) { + if ($data['uri'] === CardDavBackend::PERSONAL_ADDRESSBOOK_URI + && $data['name'] === CardDavBackend::PERSONAL_ADDRESSBOOK_NAME) { return [ 'type' => 'addressbook', 'id' => (string)$data['id'], diff --git a/apps/dav/lib/CardDAV/AddressBook.php b/apps/dav/lib/CardDAV/AddressBook.php index 2ec645f04d2..67c0b7167fa 100644 --- a/apps/dav/lib/CardDAV/AddressBook.php +++ b/apps/dav/lib/CardDAV/AddressBook.php @@ -38,8 +38,8 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable, IMov parent::__construct($carddavBackend, $addressBookInfo); - if ($this->addressBookInfo['{DAV:}displayname'] === CardDavBackend::PERSONAL_ADDRESSBOOK_NAME && - $this->getName() === CardDavBackend::PERSONAL_ADDRESSBOOK_URI) { + if ($this->addressBookInfo['{DAV:}displayname'] === CardDavBackend::PERSONAL_ADDRESSBOOK_NAME + && $this->getName() === CardDavBackend::PERSONAL_ADDRESSBOOK_URI) { $this->addressBookInfo['{DAV:}displayname'] = $l10n->t('Contacts'); } } diff --git a/apps/dav/lib/CardDAV/AddressBookImpl.php b/apps/dav/lib/CardDAV/AddressBookImpl.php index 8657460d0c6..6bb8e24f628 100644 --- a/apps/dav/lib/CardDAV/AddressBookImpl.php +++ b/apps/dav/lib/CardDAV/AddressBookImpl.php @@ -307,8 +307,8 @@ class AddressBookImpl implements IAddressBookEnabled { */ public function isSystemAddressBook(): bool { return $this->addressBookInfo['principaluri'] === 'principals/system/system' && ( - $this->addressBookInfo['uri'] === 'system' || - $this->addressBookInfo['{DAV:}displayname'] === $this->urlGenerator->getBaseUrl() + $this->addressBookInfo['uri'] === 'system' + || $this->addressBookInfo['{DAV:}displayname'] === $this->urlGenerator->getBaseUrl() ); } @@ -324,7 +324,7 @@ class AddressBookImpl implements IAddressBookEnabled { $user = str_replace('principals/users/', '', $this->addressBookInfo['principaluri']); $uri = $this->addressBookInfo['uri']; } - + $path = 'addressbooks/users/' . $user . '/' . $uri; $properties = $this->propertyMapper->findPropertyByPathAndName($user, $path, '{http://owncloud.org/ns}enabled'); if (count($properties) > 0) { diff --git a/apps/dav/lib/CardDAV/CardDavBackend.php b/apps/dav/lib/CardDAV/CardDavBackend.php index b15ed607076..06f6bf9448e 100644 --- a/apps/dav/lib/CardDAV/CardDavBackend.php +++ b/apps/dav/lib/CardDAV/CardDavBackend.php @@ -127,7 +127,6 @@ class CardDavBackend implements BackendInterface, SyncSupport { // query for shared addressbooks $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true); - $principals = array_merge($principals, $this->principalBackend->getCircleMembership($principalUriOriginal)); $principals[] = $principalUri; @@ -137,7 +136,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { $subSelect->select('id') ->from('dav_shares', 'd') ->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(\OCA\DAV\CardDAV\Sharing\Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) - ->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)); + ->andWhere($subSelect->expr()->eq('d.principaluri', $select->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)); $select->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access']) @@ -160,8 +159,8 @@ class CardDavBackend implements BackendInterface, SyncSupport { // New share can not have more permissions then the old one. continue; } - if (isset($addressBooks[$row['id']][$readOnlyPropertyName]) && - $addressBooks[$row['id']][$readOnlyPropertyName] === 0) { + if (isset($addressBooks[$row['id']][$readOnlyPropertyName]) + && $addressBooks[$row['id']][$readOnlyPropertyName] === 0) { // Old share is already read-write, no more permissions can be gained continue; } diff --git a/apps/dav/lib/CardDAV/PhotoCache.php b/apps/dav/lib/CardDAV/PhotoCache.php index 2f1999b6b1c..03c71f7e4a3 100644 --- a/apps/dav/lib/CardDAV/PhotoCache.php +++ b/apps/dav/lib/CardDAV/PhotoCache.php @@ -1,10 +1,13 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\DAV\CardDAV; +use OCP\Files\AppData\IAppDataFactory; use OCP\Files\IAppData; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; @@ -19,6 +22,7 @@ use Sabre\VObject\Property\Binary; use Sabre\VObject\Reader; class PhotoCache { + private ?IAppData $photoCacheAppData = null; /** @var array */ public const ALLOWED_CONTENT_TYPES = [ @@ -30,12 +34,9 @@ class PhotoCache { 'image/avif' => 'avif', ]; - /** - * PhotoCache constructor. - */ public function __construct( - protected IAppData $appData, - protected LoggerInterface $logger, + private IAppDataFactory $appDataFactory, + private LoggerInterface $logger, ) { } @@ -142,13 +143,12 @@ class PhotoCache { private function getFolder(int $addressBookId, string $cardUri, bool $createIfNotExists = true): ISimpleFolder { $hash = md5($addressBookId . ' ' . $cardUri); try { - return $this->appData->getFolder($hash); + return $this->getPhotoCacheAppData()->getFolder($hash); } catch (NotFoundException $e) { if ($createIfNotExists) { - return $this->appData->newFolder($hash); - } else { - throw $e; + return $this->getPhotoCacheAppData()->newFolder($hash); } + throw $e; } } @@ -265,4 +265,11 @@ class PhotoCache { // that's OK, nothing to do } } + + private function getPhotoCacheAppData(): IAppData { + if ($this->photoCacheAppData === null) { + $this->photoCacheAppData = $this->appDataFactory->get('dav-photocache'); + } + return $this->photoCacheAppData; + } } diff --git a/apps/dav/lib/Command/ClearCalendarUnshares.php b/apps/dav/lib/Command/ClearCalendarUnshares.php new file mode 100644 index 00000000000..bb367a9cd0f --- /dev/null +++ b/apps/dav/lib/Command/ClearCalendarUnshares.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Command; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\Sharing\Backend; +use OCA\DAV\CalDAV\Sharing\Service; +use OCA\DAV\Connector\Sabre\Principal; +use OCA\DAV\DAV\Sharing\Backend as BackendAlias; +use OCA\DAV\DAV\Sharing\SharingMapper; +use OCP\IAppConfig; +use OCP\IUserManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +#[AsCommand( + name: 'dav:clear-calendar-unshares', + description: 'Clear calendar unshares for a user', + hidden: false, +)] +class ClearCalendarUnshares extends Command { + public function __construct( + private IUserManager $userManager, + private IAppConfig $appConfig, + private Principal $principal, + private CalDavBackend $caldav, + private Backend $sharingBackend, + private Service $sharingService, + private SharingMapper $mapper, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this->addArgument( + 'uid', + InputArgument::REQUIRED, + 'User whose unshares to clear' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = (string)$input->getArgument('uid'); + if (!$this->userManager->userExists($user)) { + throw new \InvalidArgumentException("User $user is unknown"); + } + + $principal = $this->principal->getPrincipalByPath('principals/users/' . $user); + if ($principal === null) { + throw new \InvalidArgumentException("Unable to fetch principal for user $user "); + } + + $shares = $this->mapper->getSharesByPrincipals([$principal['uri']], 'calendar'); + $unshares = array_filter($shares, static fn ($share) => $share['access'] === BackendAlias::ACCESS_UNSHARED); + + if (count($unshares) === 0) { + $output->writeln("User $user has no calendar unshares"); + return self::SUCCESS; + } + + $rows = array_map(fn ($share) => $this->formatCalendarUnshare($share), $shares); + + $table = new Table($output); + $table + ->setHeaders(['Share Id', 'Calendar Id', 'Calendar URI', 'Calendar Name']) + ->setRows($rows) + ->render(); + + $output->writeln(''); + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Please confirm to delete the above calendar unshare entries [y/n]', false); + + if ($helper->ask($input, $output, $question)) { + $this->mapper->deleteUnsharesByPrincipal($principal['uri'], 'calendar'); + $output->writeln("Calendar unshares for user $user deleted"); + } + + return self::SUCCESS; + } + + private function formatCalendarUnshare(array $share): array { + $calendarInfo = $this->caldav->getCalendarById($share['resourceid']); + + $resourceUri = 'Resource not found'; + $resourceName = ''; + + if ($calendarInfo !== null) { + $resourceUri = $calendarInfo['uri']; + $resourceName = $calendarInfo['{DAV:}displayname']; + } + + return [ + $share['id'], + $share['resourceid'], + $resourceUri, + $resourceName, + ]; + } +} diff --git a/apps/dav/lib/Command/ClearContactsPhotoCache.php b/apps/dav/lib/Command/ClearContactsPhotoCache.php new file mode 100644 index 00000000000..82e64c3145a --- /dev/null +++ b/apps/dav/lib/Command/ClearContactsPhotoCache.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Command; + +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\NotPermittedException; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +#[AsCommand( + name: 'dav:clear-contacts-photo-cache', + description: 'Clear cached contact photos', + hidden: false, +)] +class ClearContactsPhotoCache extends Command { + + public function __construct( + private IAppDataFactory $appDataFactory, + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $photoCacheAppData = $this->appDataFactory->get('dav-photocache'); + + $folders = $photoCacheAppData->getDirectoryListing(); + $countFolders = count($folders); + + if ($countFolders === 0) { + $output->writeln('No cached contact photos found.'); + return self::SUCCESS; + } + + $output->writeln('Found ' . count($folders) . ' cached contact photos.'); + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Please confirm to clear the contacts photo cache [y/n] ', true); + + if ($helper->ask($input, $output, $question) === false) { + $output->writeln('Clearing the contacts photo cache aborted.'); + return self::SUCCESS; + } + + $progressBar = new ProgressBar($output, $countFolders); + $progressBar->start(); + + foreach ($folders as $folder) { + try { + $folder->delete(); + } catch (NotPermittedException) { + } + $progressBar->advance(); + } + + $progressBar->finish(); + + $output->writeln(''); + $output->writeln('Contacts photo cache cleared.'); + + return self::SUCCESS; + } +} diff --git a/apps/dav/lib/Command/ExportCalendar.php b/apps/dav/lib/Command/ExportCalendar.php new file mode 100644 index 00000000000..6ed8aa2cfe4 --- /dev/null +++ b/apps/dav/lib/Command/ExportCalendar.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Command; + +use InvalidArgumentException; +use OCA\DAV\CalDAV\Export\ExportService; +use OCP\Calendar\CalendarExportOptions; +use OCP\Calendar\ICalendarExport; +use OCP\Calendar\IManager; +use OCP\IUserManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Calendar Export Command + * + * Used to export data from supported calendars to disk or stdout + */ +#[AsCommand( + name: 'calendar:export', + description: 'Export calendar data from supported calendars to disk or stdout', + hidden: false +)] +class ExportCalendar extends Command { + public function __construct( + private IUserManager $userManager, + private IManager $calendarManager, + private ExportService $exportService, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this->setName('calendar:export') + ->setDescription('Export calendar data from supported calendars to disk or stdout') + ->addArgument('uid', InputArgument::REQUIRED, 'Id of system user') + ->addArgument('uri', InputArgument::REQUIRED, 'Uri of calendar') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Format of output (ical, jcal, xcal) defaults to ical', 'ical') + ->addOption('location', null, InputOption::VALUE_REQUIRED, 'Location of where to write the output. defaults to stdout'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $userId = $input->getArgument('uid'); + $calendarId = $input->getArgument('uri'); + $format = $input->getOption('format'); + $location = $input->getOption('location'); + + if (!$this->userManager->userExists($userId)) { + throw new InvalidArgumentException("User <$userId> not found."); + } + // retrieve calendar and evaluate if export is supported + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + throw new InvalidArgumentException("Calendar <$calendarId> not found."); + } + $calendar = $calendars[0]; + if (!$calendar instanceof ICalendarExport) { + throw new InvalidArgumentException("Calendar <$calendarId> does not support exporting"); + } + // construct options object + $options = new CalendarExportOptions(); + // evaluate if provided format is supported + if (!in_array($format, ExportService::FORMATS, true)) { + throw new InvalidArgumentException("Format <$format> is not valid."); + } + $options->setFormat($format); + // evaluate is a valid location was given and is usable otherwise output to stdout + if ($location !== null) { + $handle = fopen($location, 'wb'); + if ($handle === false) { + throw new InvalidArgumentException("Location <$location> is not valid. Can not open location for write operation."); + } + + foreach ($this->exportService->export($calendar, $options) as $chunk) { + fwrite($handle, $chunk); + } + fclose($handle); + } else { + foreach ($this->exportService->export($calendar, $options) as $chunk) { + $output->writeln($chunk); + } + } + + return self::SUCCESS; + } +} diff --git a/apps/dav/lib/Command/ListCalendarShares.php b/apps/dav/lib/Command/ListCalendarShares.php new file mode 100644 index 00000000000..2729bc80530 --- /dev/null +++ b/apps/dav/lib/Command/ListCalendarShares.php @@ -0,0 +1,131 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Command; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\Sharing\Backend; +use OCA\DAV\Connector\Sabre\Principal; +use OCA\DAV\DAV\Sharing\SharingMapper; +use OCP\IUserManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'dav:list-calendar-shares', + description: 'List all calendar shares for a user', + hidden: false, +)] +class ListCalendarShares extends Command { + public function __construct( + private IUserManager $userManager, + private Principal $principal, + private CalDavBackend $caldav, + private SharingMapper $mapper, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this->addArgument( + 'uid', + InputArgument::REQUIRED, + 'User whose calendar shares will be listed' + ); + $this->addOption( + 'calendar-id', + '', + InputOption::VALUE_REQUIRED, + 'List only shares for the given calendar id id', + null, + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = (string)$input->getArgument('uid'); + if (!$this->userManager->userExists($user)) { + throw new \InvalidArgumentException("User $user is unknown"); + } + + $principal = $this->principal->getPrincipalByPath('principals/users/' . $user); + if ($principal === null) { + throw new \InvalidArgumentException("Unable to fetch principal for user $user"); + } + + $memberships = array_merge( + [$principal['uri']], + $this->principal->getGroupMembership($principal['uri']), + $this->principal->getCircleMembership($principal['uri']), + ); + + $shares = $this->mapper->getSharesByPrincipals($memberships, 'calendar'); + + $calendarId = $input->getOption('calendar-id'); + if ($calendarId !== null) { + $shares = array_filter($shares, fn ($share) => $share['resourceid'] === (int)$calendarId); + } + + $rows = array_map(fn ($share) => $this->formatCalendarShare($share), $shares); + + if (count($rows) > 0) { + $table = new Table($output); + $table + ->setHeaders(['Share Id', 'Calendar Id', 'Calendar URI', 'Calendar Name', 'Calendar Owner', 'Access By', 'Permissions']) + ->setRows($rows) + ->render(); + } else { + $output->writeln("User $user has no calendar shares"); + } + + return self::SUCCESS; + } + + private function formatCalendarShare(array $share): array { + $calendarInfo = $this->caldav->getCalendarById($share['resourceid']); + + $calendarUri = 'Resource not found'; + $calendarName = ''; + $calendarOwner = ''; + + if ($calendarInfo !== null) { + $calendarUri = $calendarInfo['uri']; + $calendarName = $calendarInfo['{DAV:}displayname']; + $calendarOwner = $calendarInfo['{http://nextcloud.com/ns}owner-displayname'] . ' (' . $calendarInfo['principaluri'] . ')'; + } + + $accessBy = match (true) { + str_starts_with($share['principaluri'], 'principals/users/') => 'Individual', + str_starts_with($share['principaluri'], 'principals/groups/') => 'Group (' . $share['principaluri'] . ')', + str_starts_with($share['principaluri'], 'principals/circles/') => 'Team (' . $share['principaluri'] . ')', + default => $share['principaluri'], + }; + + $permissions = match ($share['access']) { + Backend::ACCESS_READ => 'Read', + Backend::ACCESS_READ_WRITE => 'Read/Write', + Backend::ACCESS_UNSHARED => 'Unshare', + default => $share['access'], + }; + + return [ + $share['id'], + $share['resourceid'], + $calendarUri, + $calendarName, + $calendarOwner, + $accessBy, + $permissions, + ]; + } +} diff --git a/apps/dav/lib/Command/ListCalendars.php b/apps/dav/lib/Command/ListCalendars.php index 06a1f7397c4..408a7e5247f 100644 --- a/apps/dav/lib/Command/ListCalendars.php +++ b/apps/dav/lib/Command/ListCalendars.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Command/MoveCalendar.php b/apps/dav/lib/Command/MoveCalendar.php index 36f20e5b547..b8acc191cc3 100644 --- a/apps/dav/lib/Command/MoveCalendar.php +++ b/apps/dav/lib/Command/MoveCalendar.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Command/SendEventReminders.php b/apps/dav/lib/Command/SendEventReminders.php index f5afb30ed70..89bb5ce8c20 100644 --- a/apps/dav/lib/Command/SendEventReminders.php +++ b/apps/dav/lib/Command/SendEventReminders.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php b/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php index b39dc7197b0..0e2b1c58748 100644 --- a/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php +++ b/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php b/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php index 88fff5e6a5a..9cff113140a 100644 --- a/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php +++ b/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Connector/Sabre/Auth.php b/apps/dav/lib/Connector/Sabre/Auth.php index d977721bdfa..a174920946a 100644 --- a/apps/dav/lib/Connector/Sabre/Auth.php +++ b/apps/dav/lib/Connector/Sabre/Auth.php @@ -55,8 +55,8 @@ class Auth extends AbstractBasic { * @see https://github.com/owncloud/core/issues/13245 */ public function isDavAuthenticated(string $username): bool { - return !is_null($this->session->get(self::DAV_AUTHENTICATED)) && - $this->session->get(self::DAV_AUTHENTICATED) === $username; + return !is_null($this->session->get(self::DAV_AUTHENTICATED)) + && $this->session->get(self::DAV_AUTHENTICATED) === $username; } /** @@ -71,8 +71,8 @@ class Auth extends AbstractBasic { * @throws PasswordLoginForbidden */ protected function validateUserPass($username, $password) { - if ($this->userSession->isLoggedIn() && - $this->isDavAuthenticated($this->userSession->getUser()->getUID()) + if ($this->userSession->isLoggedIn() + && $this->isDavAuthenticated($this->userSession->getUser()->getUID()) ) { $this->session->close(); return true; @@ -118,7 +118,7 @@ class Auth extends AbstractBasic { * Checks whether a CSRF check is required on the request */ private function requiresCSRFCheck(): bool { - + $methodsWithoutCsrf = ['GET', 'HEAD', 'OPTIONS']; if (in_array($this->request->getMethod(), $methodsWithoutCsrf)) { return false; @@ -144,8 +144,8 @@ class Auth extends AbstractBasic { } // If logged-in AND DAV authenticated no check is required - if ($this->userSession->isLoggedIn() && - $this->isDavAuthenticated($this->userSession->getUser()->getUID())) { + if ($this->userSession->isLoggedIn() + && $this->isDavAuthenticated($this->userSession->getUser()->getUID())) { return false; } @@ -159,8 +159,8 @@ class Auth extends AbstractBasic { private function auth(RequestInterface $request, ResponseInterface $response): array { $forcedLogout = false; - if (!$this->request->passesCSRFCheck() && - $this->requiresCSRFCheck()) { + if (!$this->request->passesCSRFCheck() + && $this->requiresCSRFCheck()) { // In case of a fail with POST we need to recheck the credentials if ($this->request->getMethod() === 'POST') { $forcedLogout = true; @@ -178,10 +178,10 @@ class Auth extends AbstractBasic { } if ( //Fix for broken webdav clients - ($this->userSession->isLoggedIn() && is_null($this->session->get(self::DAV_AUTHENTICATED))) || + ($this->userSession->isLoggedIn() && is_null($this->session->get(self::DAV_AUTHENTICATED))) //Well behaved clients that only send the cookie are allowed - ($this->userSession->isLoggedIn() && $this->session->get(self::DAV_AUTHENTICATED) === $this->userSession->getUser()->getUID() && empty($request->getHeader('Authorization'))) || - \OC_User::handleApacheAuth() + || ($this->userSession->isLoggedIn() && $this->session->get(self::DAV_AUTHENTICATED) === $this->userSession->getUser()->getUID() && empty($request->getHeader('Authorization'))) + || \OC_User::handleApacheAuth() ) { $user = $this->userSession->getUser()->getUID(); $this->currentUser = $user; diff --git a/apps/dav/lib/Connector/Sabre/BearerAuth.php b/apps/dav/lib/Connector/Sabre/BearerAuth.php index e189d8fa128..23453ae8efb 100644 --- a/apps/dav/lib/Connector/Sabre/BearerAuth.php +++ b/apps/dav/lib/Connector/Sabre/BearerAuth.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Connector/Sabre/CachingTree.php b/apps/dav/lib/Connector/Sabre/CachingTree.php index 86e102677c1..5d72b530f58 100644 --- a/apps/dav/lib/Connector/Sabre/CachingTree.php +++ b/apps/dav/lib/Connector/Sabre/CachingTree.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php b/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php index 64a61a43a9b..18009080585 100644 --- a/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php +++ b/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php @@ -27,20 +27,6 @@ class ChecksumUpdatePlugin extends ServerPlugin { } /** @return string[] */ - public function getHTTPMethods($path): array { - $tree = $this->server->tree; - - if ($tree->nodeExists($path)) { - $node = $tree->getNodeForPath($path); - if ($node instanceof File) { - return ['PATCH']; - } - } - - return []; - } - - /** @return string[] */ public function getFeatures(): array { return ['nextcloud-checksum-update']; } diff --git a/apps/dav/lib/Connector/Sabre/DavAclPlugin.php b/apps/dav/lib/Connector/Sabre/DavAclPlugin.php index 7846896182f..100d719ef01 100644 --- a/apps/dav/lib/Connector/Sabre/DavAclPlugin.php +++ b/apps/dav/lib/Connector/Sabre/DavAclPlugin.php @@ -62,7 +62,7 @@ class DavAclPlugin extends \Sabre\DAVACL\Plugin { ) ); } - + } return $access; diff --git a/apps/dav/lib/Connector/Sabre/Directory.php b/apps/dav/lib/Connector/Sabre/Directory.php index 7f8fe3a84de..fe09c3f423f 100644 --- a/apps/dav/lib/Connector/Sabre/Directory.php +++ b/apps/dav/lib/Connector/Sabre/Directory.php @@ -13,7 +13,9 @@ use OCA\DAV\AppInfo\Application; use OCA\DAV\Connector\Sabre\Exception\FileLocked; use OCA\DAV\Connector\Sabre\Exception\Forbidden; use OCA\DAV\Connector\Sabre\Exception\InvalidPath; +use OCA\DAV\Storage\PublicShareWrapper; use OCP\App\IAppManager; +use OCP\Constants; use OCP\Files\FileInfo; use OCP\Files\Folder; use OCP\Files\ForbiddenException; @@ -172,7 +174,20 @@ class Directory extends Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuot * @throws \Sabre\DAV\Exception\ServiceUnavailable */ public function getChild($name, $info = null, ?IRequest $request = null, ?IL10N $l10n = null) { - if (!$this->info->isReadable()) { + $storage = $this->info->getStorage(); + $allowDirectory = false; + + // Checking if we're in a file drop + // If we are, then only PUT and MKCOL are allowed (see plugin) + // so we are safe to return the directory without a risk of + // leaking files and folders structure. + if ($storage instanceof PublicShareWrapper) { + $share = $storage->getShare(); + $allowDirectory = ($share->getPermissions() & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ; + } + + // For file drop we need to be allowed to read the directory with the nickname + if (!$allowDirectory && !$this->info->isReadable()) { // avoid detecting files through this way throw new NotFound(); } @@ -198,6 +213,11 @@ class Directory extends Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuot if ($info->getMimeType() === FileInfo::MIMETYPE_FOLDER) { $node = new \OCA\DAV\Connector\Sabre\Directory($this->fileView, $info, $this->tree, $this->shareManager); } else { + // In case reading a directory was allowed but it turns out the node was a not a directory, reject it now. + if (!$this->info->isReadable()) { + throw new NotFound(); + } + $node = new File($this->fileView, $info, $this->shareManager, $request, $l10n); } if ($this->tree) { diff --git a/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php b/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php index 4a7e30caa10..f6baceb748b 100644 --- a/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php +++ b/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php @@ -43,8 +43,8 @@ class DummyGetResponsePlugin extends \Sabre\DAV\ServerPlugin { * @return false */ public function httpGet(RequestInterface $request, ResponseInterface $response) { - $string = 'This is the WebDAV interface. It can only be accessed by ' . - 'WebDAV clients such as the Nextcloud desktop sync client.'; + $string = 'This is the WebDAV interface. It can only be accessed by ' + . 'WebDAV clients such as the Nextcloud desktop sync client.'; $stream = fopen('php://memory', 'r+'); fwrite($stream, $string); rewind($stream); diff --git a/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php b/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php index 41ace002660..1e1e4aaed04 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php +++ b/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-only diff --git a/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php b/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php index 61ecfaf845c..b0c5a079ce1 100644 --- a/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php @@ -117,8 +117,8 @@ class FakeLockerPlugin extends ServerPlugin { $lockInfo->timeout = 1800; $body = $this->server->xml->write('{DAV:}prop', [ - '{DAV:}lockdiscovery' => - new LockDiscovery([$lockInfo]) + '{DAV:}lockdiscovery' + => new LockDiscovery([$lockInfo]) ]); $response->setStatus(Http::STATUS_OK); diff --git a/apps/dav/lib/Connector/Sabre/File.php b/apps/dav/lib/Connector/Sabre/File.php index 045b9d7e784..218d38e1c4b 100644 --- a/apps/dav/lib/Connector/Sabre/File.php +++ b/apps/dav/lib/Connector/Sabre/File.php @@ -19,6 +19,7 @@ use OCA\DAV\Connector\Sabre\Exception\Forbidden as DAVForbiddenException; use OCA\DAV\Connector\Sabre\Exception\UnsupportedMediaType; use OCP\App\IAppManager; use OCP\Encryption\Exceptions\GenericEncryptionException; +use OCP\Files; use OCP\Files\EntityTooLargeException; use OCP\Files\FileInfo; use OCP\Files\ForbiddenException; @@ -215,7 +216,9 @@ class File extends Node implements IFile { try { /** @var IWriteStreamStorage $partStorage */ $count = $partStorage->writeStream($internalPartPath, $wrappedData); - } catch (GenericFileException) { + } catch (GenericFileException $e) { + $logger = Server::get(LoggerInterface::class); + $logger->error('Error while writing stream to storage: ' . $e->getMessage(), ['exception' => $e, 'app' => 'webdav']); $result = $isEOF; if (is_resource($wrappedData)) { $result = feof($wrappedData); @@ -229,7 +232,7 @@ class File extends Node implements IFile { // because we have no clue about the cause we can only throw back a 500/Internal Server Error throw new Exception($this->l10n->t('Could not write file contents')); } - [$count, $result] = \OC_Helper::streamCopy($data, $target); + [$count, $result] = Files::streamCopy($data, $target, true); fclose($target); } diff --git a/apps/dav/lib/Connector/Sabre/FilesPlugin.php b/apps/dav/lib/Connector/Sabre/FilesPlugin.php index 34f48384e12..843383a0452 100644 --- a/apps/dav/lib/Connector/Sabre/FilesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -9,8 +9,10 @@ namespace OCA\DAV\Connector\Sabre; use OC\AppFramework\Http\Request; use OC\FilesMetadata\Model\FilesMetadata; +use OC\User\NoUserException; use OCA\DAV\Connector\Sabre\Exception\InvalidPath; use OCA\Files_Sharing\External\Mount as SharingExternalMount; +use OCP\Accounts\IAccountManager; use OCP\Constants; use OCP\Files\ForbiddenException; use OCP\Files\IFilenameValidator; @@ -91,6 +93,7 @@ class FilesPlugin extends ServerPlugin { private IPreview $previewManager, private IUserSession $userSession, private IFilenameValidator $validator, + private IAccountManager $accountManager, private bool $isPublic = false, private bool $downloadAttachment = true, ) { @@ -254,8 +257,8 @@ class FilesPlugin extends ServerPlugin { // adds a 'Content-Disposition: attachment' header in case no disposition // header has been set before - if ($this->downloadAttachment && - $response->getHeader('Content-Disposition') === null) { + if ($this->downloadAttachment + && $response->getHeader('Content-Disposition') === null) { $filename = $node->getName(); if ($this->request->isUserAgent( [ @@ -361,9 +364,32 @@ class FilesPlugin extends ServerPlugin { $owner = $node->getOwner(); if (!$owner) { return null; - } else { + } + + // Get current user to see if we're in a public share or not + $user = $this->userSession->getUser(); + + // If the user is logged in, we can return the display name + if ($user !== null) { return $owner->getDisplayName(); } + + // Check if the user published their display name + try { + $ownerAccount = $this->accountManager->getAccount($owner); + } catch (NoUserException) { + // do not lock process if owner is not local + return null; + } + + $ownerNameProperty = $ownerAccount->getProperty(IAccountManager::PROPERTY_DISPLAYNAME); + + // Since we are not logged in, we need to have at least the published scope + if ($ownerNameProperty->getScope() === IAccountManager::SCOPE_PUBLISHED) { + return $owner->getDisplayName(); + } + + return null; }); $propFind->handle(self::HAS_PREVIEW_PROPERTYNAME, function () use ($node) { @@ -515,8 +541,8 @@ class FilesPlugin extends ServerPlugin { $ocmPermissions[] = 'read'; } - if (($ncPermissions & Constants::PERMISSION_CREATE) || - ($ncPermissions & Constants::PERMISSION_UPDATE)) { + if (($ncPermissions & Constants::PERMISSION_CREATE) + || ($ncPermissions & Constants::PERMISSION_UPDATE)) { $ocmPermissions[] = 'write'; } @@ -701,15 +727,15 @@ class FilesPlugin extends ServerPlugin { */ public function sendFileIdHeader($filePath, ?\Sabre\DAV\INode $node = null) { // we get the node for the given $filePath here because in case of afterCreateFile $node is the parent folder - if (!$this->server->tree->nodeExists($filePath)) { - return; - } - $node = $this->server->tree->getNodeForPath($filePath); - if ($node instanceof Node) { - $fileId = $node->getFileId(); - if (!is_null($fileId)) { - $this->server->httpResponse->setHeader('OC-FileId', $fileId); + try { + $node = $this->server->tree->getNodeForPath($filePath); + if ($node instanceof Node) { + $fileId = $node->getFileId(); + if (!is_null($fileId)) { + $this->server->httpResponse->setHeader('OC-FileId', $fileId); + } } + } catch (NotFound) { } } } diff --git a/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php b/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php index efed6ce09f8..e18ef58149a 100644 --- a/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php +++ b/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-only diff --git a/apps/dav/lib/Connector/Sabre/Principal.php b/apps/dav/lib/Connector/Sabre/Principal.php index 515ef807a25..b61cabedf5f 100644 --- a/apps/dav/lib/Connector/Sabre/Principal.php +++ b/apps/dav/lib/Connector/Sabre/Principal.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. @@ -41,9 +42,6 @@ class Principal implements BackendInterface { /** @var bool */ private $hasCircles; - /** @var ProxyMapper */ - private $proxyMapper; - /** @var KnownUserService */ private $knownUserService; @@ -54,7 +52,7 @@ class Principal implements BackendInterface { private IShareManager $shareManager, private IUserSession $userSession, private IAppManager $appManager, - ProxyMapper $proxyMapper, + private ProxyMapper $proxyMapper, KnownUserService $knownUserService, private IConfig $config, private IFactory $languageFactory, @@ -62,7 +60,6 @@ class Principal implements BackendInterface { ) { $this->principalPrefix = trim($principalPrefix, '/'); $this->hasGroups = $this->hasCircles = ($principalPrefix === 'principals/users/'); - $this->proxyMapper = $proxyMapper; $this->knownUserService = $knownUserService; } @@ -155,6 +152,11 @@ class Principal implements BackendInterface { 'uri' => 'principals/system/' . $name, '{DAV:}displayname' => $this->languageFactory->get('dav')->t('Accounts'), ]; + } elseif ($prefix === 'principals/shares') { + return [ + 'uri' => 'principals/shares/' . $name, + '{DAV:}displayname' => $name, + ]; } return null; } diff --git a/apps/dav/lib/Connector/Sabre/PublicAuth.php b/apps/dav/lib/Connector/Sabre/PublicAuth.php index ea59d9efc8f..2ca1c25e2f6 100644 --- a/apps/dav/lib/Connector/Sabre/PublicAuth.php +++ b/apps/dav/lib/Connector/Sabre/PublicAuth.php @@ -14,7 +14,9 @@ namespace OCA\DAV\Connector\Sabre; use OCP\Defaults; use OCP\IRequest; use OCP\ISession; +use OCP\IURLGenerator; use OCP\Security\Bruteforce\IThrottler; +use OCP\Security\Bruteforce\MaxDelayReached; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; use OCP\Share\IShare; @@ -22,6 +24,7 @@ use Psr\Log\LoggerInterface; use Sabre\DAV\Auth\Backend\AbstractBasic; use Sabre\DAV\Exception\NotAuthenticated; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Exception\PreconditionFailed; use Sabre\DAV\Exception\ServiceUnavailable; use Sabre\HTTP; use Sabre\HTTP\RequestInterface; @@ -44,6 +47,7 @@ class PublicAuth extends AbstractBasic { private ISession $session, private IThrottler $throttler, private LoggerInterface $logger, + private IURLGenerator $urlGenerator, ) { // setup realm $defaults = new Defaults(); @@ -51,17 +55,18 @@ class PublicAuth extends AbstractBasic { } /** - * @param RequestInterface $request - * @param ResponseInterface $response - * - * @return array * @throws NotAuthenticated + * @throws MaxDelayReached * @throws ServiceUnavailable */ public function check(RequestInterface $request, ResponseInterface $response): array { try { $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), self::BRUTEFORCE_ACTION); + if (count($_COOKIE) > 0 && !$this->request->passesStrictCookieCheck() && $this->getShare()->getPassword() !== null) { + throw new PreconditionFailed('Strict cookie check failed'); + } + $auth = new HTTP\Auth\Basic( $this->realm, $request, @@ -75,7 +80,17 @@ class PublicAuth extends AbstractBasic { } return $this->checkToken(); - } catch (NotAuthenticated $e) { + } catch (NotAuthenticated|MaxDelayReached $e) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + throw $e; + } catch (PreconditionFailed $e) { + $response->setHeader( + 'Location', + $this->urlGenerator->linkToRoute( + 'files_sharing.share.showShare', + [ 'token' => $this->getToken() ], + ), + ); throw $e; } catch (\Exception $e) { $class = get_class($e); @@ -87,14 +102,13 @@ class PublicAuth extends AbstractBasic { /** * Extract token from request url - * @return string * @throws NotFound */ private function getToken(): string { $path = $this->request->getPathInfo() ?: ''; // ['', 'dav', 'files', 'token'] $splittedPath = explode('/', $path); - + if (count($splittedPath) < 4 || $splittedPath[3] === '') { throw new NotFound(); } @@ -104,7 +118,7 @@ class PublicAuth extends AbstractBasic { /** * Check token validity - * @return array + * * @throws NotFound * @throws NotAuthenticated */ @@ -152,15 +166,13 @@ class PublicAuth extends AbstractBasic { protected function validateUserPass($username, $password) { $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), self::BRUTEFORCE_ACTION); - $token = $this->getToken(); try { - $share = $this->shareManager->getShareByToken($token); + $share = $this->getShare(); } catch (ShareNotFound $e) { $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); return false; } - $this->share = $share; \OC_User::setIncognitoMode(true); // check if the share is password protected @@ -176,7 +188,7 @@ class PublicAuth extends AbstractBasic { } return true; } - + if ($this->session->exists(PublicAuth::DAV_AUTHENTICATED) && $this->session->get(PublicAuth::DAV_AUTHENTICATED) === $share->getId()) { return true; @@ -203,7 +215,13 @@ class PublicAuth extends AbstractBasic { } public function getShare(): IShare { - assert($this->share !== null); + $token = $this->getToken(); + + if ($this->share === null) { + $share = $this->shareManager->getShareByToken($token); + $this->share = $share; + } + return $this->share; } } diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index f035ff8d625..214412e1744 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -8,17 +8,23 @@ namespace OCA\DAV\Connector\Sabre; use OC\Files\View; +use OC\KnownUser\KnownUserService; use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\CalDAV\DefaultCalendarValidator; +use OCA\DAV\CalDAV\Proxy\ProxyMapper; use OCA\DAV\DAV\CustomPropertiesBackend; use OCA\DAV\DAV\ViewOnlyPlugin; use OCA\DAV\Files\BrowserErrorPagePlugin; +use OCA\DAV\Files\Sharing\RootCollection; +use OCA\DAV\Upload\CleanupService; use OCA\Theming\ThemingDefaults; +use OCP\Accounts\IAccountManager; use OCP\App\IAppManager; use OCP\Comments\ICommentsManager; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Folder; use OCP\Files\IFilenameValidator; +use OCP\Files\IRootFolder; use OCP\Files\Mount\IMountManager; use OCP\IConfig; use OCP\IDBConnection; @@ -27,12 +33,14 @@ use OCP\IL10N; use OCP\IPreview; use OCP\IRequest; use OCP\ITagManager; +use OCP\IUserManager; use OCP\IUserSession; use OCP\SabrePluginEvent; use OCP\SystemTag\ISystemTagManager; use OCP\SystemTag\ISystemTagObjectMapper; use Psr\Log\LoggerInterface; use Sabre\DAV\Auth\Plugin; +use Sabre\DAV\SimpleCollection; class ServerFactory { @@ -53,13 +61,22 @@ class ServerFactory { /** * @param callable $viewCallBack callback that should return the view for the dav endpoint */ - public function createServer(string $baseUri, + public function createServer( + bool $isPublicShare, + string $baseUri, string $requestUri, Plugin $authPlugin, - callable $viewCallBack): Server { + callable $viewCallBack, + ): Server { // Fire up server - $objectTree = new ObjectTree(); - $server = new Server($objectTree); + if ($isPublicShare) { + $rootCollection = new SimpleCollection('root'); + $tree = new CachingTree($rootCollection); + } else { + $rootCollection = null; + $tree = new ObjectTree(); + } + $server = new Server($tree); // Set URL explicitly due to reverse-proxy situations $server->httpRequest->setUrl($requestUri); $server->setBaseUri($baseUri); @@ -80,7 +97,7 @@ class ServerFactory { $server->addPlugin(new RequestIdHeaderPlugin($this->request)); $server->addPlugin(new ZipFolderPlugin( - $objectTree, + $tree, $this->logger, $this->eventDispatcher, )); @@ -100,7 +117,7 @@ class ServerFactory { } // wait with registering these until auth is handled and the filesystem is setup - $server->on('beforeMethod:*', function () use ($server, $objectTree, $viewCallBack): void { + $server->on('beforeMethod:*', function () use ($server, $tree, $viewCallBack, $isPublicShare, $rootCollection): void { // ensure the skeleton is copied $userFolder = \OC::$server->getUserFolder(); @@ -114,20 +131,55 @@ class ServerFactory { // Create Nextcloud Dir if ($rootInfo->getType() === 'dir') { - $root = new Directory($view, $rootInfo, $objectTree); + $root = new Directory($view, $rootInfo, $tree); } else { $root = new File($view, $rootInfo); } - $objectTree->init($root, $view, $this->mountManager); + + if ($isPublicShare) { + $userPrincipalBackend = new Principal( + \OCP\Server::get(IUserManager::class), + \OCP\Server::get(IGroupManager::class), + \OCP\Server::get(IAccountManager::class), + \OCP\Server::get(\OCP\Share\IManager::class), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(IAppManager::class), + \OCP\Server::get(ProxyMapper::class), + \OCP\Server::get(KnownUserService::class), + \OCP\Server::get(IConfig::class), + \OC::$server->getL10NFactory(), + ); + + // Mount the share collection at /public.php/dav/shares/<share token> + $rootCollection->addChild(new RootCollection( + $root, + $userPrincipalBackend, + 'principals/shares', + )); + + // Mount the upload collection at /public.php/dav/uploads/<share token> + $rootCollection->addChild(new \OCA\DAV\Upload\RootCollection( + $userPrincipalBackend, + 'principals/shares', + \OCP\Server::get(CleanupService::class), + \OCP\Server::get(IRootFolder::class), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(\OCP\Share\IManager::class), + )); + } else { + /** @var ObjectTree $tree */ + $tree->init($root, $view, $this->mountManager); + } $server->addPlugin( new FilesPlugin( - $objectTree, + $tree, $this->config, $this->request, $this->previewManager, $this->userSession, \OCP\Server::get(IFilenameValidator::class), + \OCP\Server::get(IAccountManager::class), false, !$this->config->getSystemValue('debug', false) ) @@ -141,16 +193,16 @@ class ServerFactory { )); if ($this->userSession->isLoggedIn()) { - $server->addPlugin(new TagsPlugin($objectTree, $this->tagManager, $this->eventDispatcher, $this->userSession)); + $server->addPlugin(new TagsPlugin($tree, $this->tagManager, $this->eventDispatcher, $this->userSession)); $server->addPlugin(new SharesPlugin( - $objectTree, + $tree, $this->userSession, $userFolder, \OCP\Server::get(\OCP\Share\IManager::class) )); $server->addPlugin(new CommentPropertiesPlugin(\OCP\Server::get(ICommentsManager::class), $this->userSession)); $server->addPlugin(new FilesReportPlugin( - $objectTree, + $tree, $view, \OCP\Server::get(ISystemTagManager::class), \OCP\Server::get(ISystemTagObjectMapper::class), @@ -165,7 +217,7 @@ class ServerFactory { new \Sabre\DAV\PropertyStorage\Plugin( new CustomPropertiesBackend( $server, - $objectTree, + $tree, $this->databaseConnection, $this->userSession->getUser(), \OCP\Server::get(DefaultCalendarValidator::class), diff --git a/apps/dav/lib/Connector/Sabre/SharesPlugin.php b/apps/dav/lib/Connector/Sabre/SharesPlugin.php index 088cf33d85f..f49e85333f3 100644 --- a/apps/dav/lib/Connector/Sabre/SharesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/SharesPlugin.php @@ -176,8 +176,8 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { if ($sabreNode instanceof Directory && $propFind->getDepth() !== 0 && ( - !is_null($propFind->getStatus(self::SHARETYPES_PROPERTYNAME)) || - !is_null($propFind->getStatus(self::SHAREES_PROPERTYNAME)) + !is_null($propFind->getStatus(self::SHARETYPES_PROPERTYNAME)) + || !is_null($propFind->getStatus(self::SHAREES_PROPERTYNAME)) ) ) { $folderNode = $sabreNode->getNode(); diff --git a/apps/dav/lib/Connector/Sabre/TagsPlugin.php b/apps/dav/lib/Connector/Sabre/TagsPlugin.php index eb06fa5cef6..25c1633df36 100644 --- a/apps/dav/lib/Connector/Sabre/TagsPlugin.php +++ b/apps/dav/lib/Connector/Sabre/TagsPlugin.php @@ -94,6 +94,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { $this->server = $server; $this->server->on('propFind', [$this, 'handleGetProperties']); $this->server->on('propPatch', [$this, 'handleUpdateProperties']); + $this->server->on('preloadProperties', [$this, 'handlePreloadProperties']); } /** @@ -150,6 +151,24 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { } /** + * Prefetches tags for a list of file IDs and caches the results + * + * @param array $fileIds List of file IDs to prefetch tags for + * @return void + */ + private function prefetchTagsForFileIds(array $fileIds) { + $tags = $this->getTagger()->getTagsForObjects($fileIds); + if ($tags === false) { + // the tags API returns false on error... + $tags = []; + } + + foreach ($fileIds as $fileId) { + $this->cachedTags[$fileId] = $tags[$fileId] ?? []; + } + } + + /** * Updates the tags of the given file id * * @param int $fileId @@ -199,22 +218,11 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { )) { // note: pre-fetching only supported for depth <= 1 $folderContent = $node->getChildren(); - $fileIds[] = (int)$node->getId(); + $fileIds = [(int)$node->getId()]; foreach ($folderContent as $info) { $fileIds[] = (int)$info->getId(); } - $tags = $this->getTagger()->getTagsForObjects($fileIds); - if ($tags === false) { - // the tags API returns false on error... - $tags = []; - } - - $this->cachedTags = $this->cachedTags + $tags; - $emptyFileIds = array_diff($fileIds, array_keys($tags)); - // also cache the ones that were not found - foreach ($emptyFileIds as $fileId) { - $this->cachedTags[$fileId] = []; - } + $this->prefetchTagsForFileIds($fileIds); } $isFav = null; @@ -270,4 +278,14 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { return 200; }); } + + public function handlePreloadProperties(array $nodes, array $requestProperties): void { + if ( + !in_array(self::FAVORITE_PROPERTYNAME, $requestProperties, true) + && !in_array(self::TAGS_PROPERTYNAME, $requestProperties, true) + ) { + return; + } + $this->prefetchTagsForFileIds(array_map(fn ($node) => $node->getId(), $nodes)); + } } diff --git a/apps/dav/lib/Controller/BirthdayCalendarController.php b/apps/dav/lib/Controller/BirthdayCalendarController.php index d3a9239dd22..f6bfb229a9c 100644 --- a/apps/dav/lib/Controller/BirthdayCalendarController.php +++ b/apps/dav/lib/Controller/BirthdayCalendarController.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Controller/ExampleContentController.php b/apps/dav/lib/Controller/ExampleContentController.php index 9146eeb639d..e20ee4b7f49 100644 --- a/apps/dav/lib/Controller/ExampleContentController.php +++ b/apps/dav/lib/Controller/ExampleContentController.php @@ -10,77 +10,89 @@ declare(strict_types=1); namespace OCA\DAV\Controller; use OCA\DAV\AppInfo\Application; -use OCP\App\IAppManager; +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\Files\AppData\IAppDataFactory; -use OCP\Files\IAppData; -use OCP\Files\NotFoundException; -use OCP\IConfig; use OCP\IRequest; use Psr\Log\LoggerInterface; class ExampleContentController extends ApiController { - private IAppData $appData; public function __construct( IRequest $request, - private IConfig $config, - private IAppDataFactory $appDataFactory, - private IAppManager $appManager, - private LoggerInterface $logger, + private readonly LoggerInterface $logger, + private readonly ExampleEventService $exampleEventService, + private readonly ExampleContactService $exampleContactService, ) { parent::__construct(Application::APP_ID, $request); - $this->appData = $this->appDataFactory->get('dav'); } - public function setEnableDefaultContact($allow) { - if ($allow === 'yes' && !$this->defaultContactExists()) { + #[FrontpageRoute(verb: 'PUT', url: '/api/defaultcontact/config')] + public function setEnableDefaultContact(bool $allow): JSONResponse { + if ($allow && !$this->exampleContactService->defaultContactExists()) { try { - $this->setCard(); + $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->config->setAppValue(Application::APP_ID, 'enableDefaultContact', $allow); + $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->config->getAppValue(Application::APP_ID, 'enableDefaultContact', 'no')) { + if (!$this->exampleContactService->isDefaultContactEnabled()) { return new JSONResponse([], Http::STATUS_FORBIDDEN); } - $this->setCard($contactData); + $this->exampleContactService->setCard($contactData); return new JSONResponse([], Http::STATUS_OK); } - private function setCard(?string $cardData = null) { - try { - $folder = $this->appData->getFolder('defaultContact'); - } catch (NotFoundException $e) { - $folder = $this->appData->newFolder('defaultContact'); - } + #[FrontpageRoute(verb: 'POST', url: '/api/exampleEvent/enable')] + public function setCreateExampleEvent(bool $enable): JSONResponse { + $this->exampleEventService->setCreateExampleEvent($enable); + return new JsonResponse([]); + } - if (is_null($cardData)) { - $cardData = file_get_contents(__DIR__ . '/../ExampleContentFiles/exampleContact.vcf'); - } + #[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', + ); + } - if (!$cardData) { - throw new \Exception('Could not read exampleContact.vcf'); + #[FrontpageRoute(verb: 'POST', url: '/api/exampleEvent/event')] + public function uploadExampleEvent(string $ics): JSONResponse { + if (!$this->exampleEventService->shouldCreateExampleEvent()) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); } - $file = (!$folder->fileExists('defaultContact.vcf')) ? $folder->newFile('defaultContact.vcf') : $folder->getFile('defaultContact.vcf'); - $file->putContent($cardData); + $this->exampleEventService->saveCustomExampleEvent($ics); + return new JsonResponse([]); } - private function defaultContactExists(): bool { - try { - $folder = $this->appData->getFolder('defaultContact'); - } catch (NotFoundException $e) { - return false; - } - return $folder->fileExists('defaultContact.vcf'); + #[FrontpageRoute(verb: 'DELETE', url: '/api/exampleEvent/event')] + public function deleteExampleEvent(): JSONResponse { + $this->exampleEventService->deleteCustomExampleEvent(); + return new JsonResponse([]); } } diff --git a/apps/dav/lib/DAV/CustomPropertiesBackend.php b/apps/dav/lib/DAV/CustomPropertiesBackend.php index c41ecd8450e..f3fff11b3da 100644 --- a/apps/dav/lib/DAV/CustomPropertiesBackend.php +++ b/apps/dav/lib/DAV/CustomPropertiesBackend.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. @@ -92,6 +93,11 @@ class CustomPropertiesBackend implements BackendInterface { '{http://nextcloud.org/ns}lock-time', '{http://nextcloud.org/ns}lock-timeout', '{http://nextcloud.org/ns}lock-token', + // photos + '{http://nextcloud.org/ns}realpath', + '{http://nextcloud.org/ns}nbItems', + '{http://nextcloud.org/ns}face-detections', + '{http://nextcloud.org/ns}face-preview-image', ]; /** @@ -277,8 +283,8 @@ class CustomPropertiesBackend implements BackendInterface { */ public function move($source, $destination) { $statement = $this->connection->prepare( - 'UPDATE `*PREFIX*properties` SET `propertypath` = ?' . - ' WHERE `userid` = ? AND `propertypath` = ?' + 'UPDATE `*PREFIX*properties` SET `propertypath` = ?' + . ' WHERE `userid` = ? AND `propertypath` = ?' ); $statement->execute([$this->formatPath($destination), $this->user->getUID(), $this->formatPath($source)]); $statement->closeCursor(); diff --git a/apps/dav/lib/DAV/GroupPrincipalBackend.php b/apps/dav/lib/DAV/GroupPrincipalBackend.php index 143fc7d69f1..77ba45182c9 100644 --- a/apps/dav/lib/DAV/GroupPrincipalBackend.php +++ b/apps/dav/lib/DAV/GroupPrincipalBackend.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. @@ -50,8 +51,10 @@ class GroupPrincipalBackend implements BackendInterface { $principals = []; if ($prefixPath === self::PRINCIPAL_PREFIX) { - foreach ($this->groupManager->search('') as $user) { - $principals[] = $this->groupToPrincipal($user); + foreach ($this->groupManager->search('') as $group) { + if (!$group->hideFromCollaboration()) { + $principals[] = $this->groupToPrincipal($group); + } } } @@ -77,7 +80,7 @@ class GroupPrincipalBackend implements BackendInterface { $name = urldecode($elements[2]); $group = $this->groupManager->get($name); - if (!is_null($group)) { + if ($group !== null && !$group->hideFromCollaboration()) { return $this->groupToPrincipal($group); } @@ -186,6 +189,10 @@ class GroupPrincipalBackend implements BackendInterface { $groups = $this->groupManager->search($value, $searchLimit); $results[] = array_reduce($groups, function (array $carry, IGroup $group) use ($restrictGroups) { + if ($group->hideFromCollaboration()) { + return $carry; + } + $gid = $group->getGID(); // is sharing restricted to groups only? if ($restrictGroups !== false) { diff --git a/apps/dav/lib/DAV/Sharing/Backend.php b/apps/dav/lib/DAV/Sharing/Backend.php index 06a082628d3..d60f5cca7c6 100644 --- a/apps/dav/lib/DAV/Sharing/Backend.php +++ b/apps/dav/lib/DAV/Sharing/Backend.php @@ -64,8 +64,8 @@ abstract class Backend { } $principalparts[2] = urldecode($principalparts[2]); - if (($principalparts[1] === 'users' && !$this->userManager->userExists($principalparts[2])) || - ($principalparts[1] === 'groups' && !$this->groupManager->groupExists($principalparts[2]))) { + if (($principalparts[1] === 'users' && !$this->userManager->userExists($principalparts[2])) + || ($principalparts[1] === 'groups' && !$this->groupManager->groupExists($principalparts[2]))) { // User or group does not exist continue; } @@ -90,14 +90,6 @@ abstract class Backend { // Delete any possible direct shares (since the frontend does not separate between them) $this->service->deleteShare($shareable->getResourceId(), $principal); - - // Check if a user has a groupshare that they're trying to free themselves from - // If so we need to add a self::ACCESS_UNSHARED row - if (!str_contains($principal, 'group') - && $this->service->hasGroupShare($oldShares) - ) { - $this->service->unshare($shareable->getResourceId(), $principal); - } } } @@ -204,4 +196,45 @@ abstract class Backend { } return $acl; } + + public function unshare(IShareable $shareable, string $principalUri): bool { + $this->shareCache->clear(); + + $principal = $this->principalBackend->findByUri($principalUri, ''); + if (empty($principal)) { + return false; + } + + if ($shareable->getOwner() === $principal) { + return false; + } + + // Delete any possible direct shares (since the frontend does not separate between them) + $this->service->deleteShare($shareable->getResourceId(), $principal); + + $needsUnshare = $this->hasAccessByGroupOrCirclesMembership( + $shareable->getResourceId(), + $principal + ); + + if ($needsUnshare) { + $this->service->unshare($shareable->getResourceId(), $principal); + } + + return true; + } + + private function hasAccessByGroupOrCirclesMembership(int $resourceId, string $principal) { + $memberships = array_merge( + $this->principalBackend->getGroupMembership($principal, true), + $this->principalBackend->getCircleMembership($principal) + ); + + $shares = array_column( + $this->service->getShares($resourceId), + 'principaluri' + ); + + return count(array_intersect($memberships, $shares)) > 0; + } } diff --git a/apps/dav/lib/DAV/Sharing/SharingMapper.php b/apps/dav/lib/DAV/Sharing/SharingMapper.php index 0aec5b7fe81..e4722208189 100644 --- a/apps/dav/lib/DAV/Sharing/SharingMapper.php +++ b/apps/dav/lib/DAV/Sharing/SharingMapper.php @@ -110,4 +110,28 @@ class SharingMapper { ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) ->executeStatement(); } + + public function getSharesByPrincipals(array $principals, string $resourceType): array { + $query = $this->db->getQueryBuilder(); + $result = $query->select(['id', 'principaluri', 'type', 'access', 'resourceid']) + ->from('dav_shares') + ->where($query->expr()->in('principaluri', $query->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) + ->orderBy('id') + ->executeQuery(); + + $rows = $result->fetchAll(); + $result->closeCursor(); + + return $rows; + } + + public function deleteUnsharesByPrincipal(string $principal, string $resourceType): void { + $query = $this->db->getQueryBuilder(); + $query->delete('dav_shares') + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principal))) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) + ->andWhere($query->expr()->eq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } } diff --git a/apps/dav/lib/DAV/Sharing/SharingService.php b/apps/dav/lib/DAV/Sharing/SharingService.php index b9ac36ea066..11459e12d74 100644 --- a/apps/dav/lib/DAV/Sharing/SharingService.php +++ b/apps/dav/lib/DAV/Sharing/SharingService.php @@ -50,14 +50,4 @@ abstract class SharingService { public function getSharesForIds(array $resourceIds): array { return $this->mapper->getSharesForIds($resourceIds, $this->getResourceType()); } - - /** - * @param array $oldShares - * @return bool - */ - public function hasGroupShare(array $oldShares): bool { - return !empty(array_filter($oldShares, function (array $share) { - return $share['{http://owncloud.org/ns}group-share'] === true; - })); - } } diff --git a/apps/dav/lib/DAV/ViewOnlyPlugin.php b/apps/dav/lib/DAV/ViewOnlyPlugin.php index 4c3b49a45b0..9b9615b8063 100644 --- a/apps/dav/lib/DAV/ViewOnlyPlugin.php +++ b/apps/dav/lib/DAV/ViewOnlyPlugin.php @@ -84,18 +84,25 @@ class ViewOnlyPlugin extends ServerPlugin { if (!$storage->instanceOfStorage(ISharedStorage::class)) { return true; } + // Extract extra permissions /** @var ISharedStorage $storage */ $share = $storage->getShare(); - $attributes = $share->getAttributes(); if ($attributes === null) { return true; } - // Check if read-only and on whether permission can download is both set and disabled. + // We have two options here, if download is disabled, but viewing is allowed, + // we still allow the GET request to return the file content. $canDownload = $attributes->getAttribute('permissions', 'download'); - if ($canDownload !== null && !$canDownload) { + if (!$share->canSeeContent()) { + throw new Forbidden('Access to this shared resource has been denied because its download permission is disabled.'); + } + + // If download is disabled, we disable the COPY and MOVE methods even if the + // shareapi_allow_view_without_download is set to true. + if ($request->getMethod() !== 'GET' && ($canDownload !== null && !$canDownload)) { throw new Forbidden('Access to this shared resource has been denied because its download permission is disabled.'); } } catch (NotFound $e) { diff --git a/apps/dav/lib/Db/PropertyMapper.php b/apps/dav/lib/Db/PropertyMapper.php index a0ecb348ba4..1789194ee7a 100644 --- a/apps/dav/lib/Db/PropertyMapper.php +++ b/apps/dav/lib/Db/PropertyMapper.php @@ -38,4 +38,18 @@ class PropertyMapper extends QBMapper { return $this->findEntities($selectQb); } + /** + * @return Property[] + */ + public function findPropertiesByPath(string $userId, string $path): array { + $selectQb = $this->db->getQueryBuilder(); + $selectQb->select('*') + ->from(self::TABLE_NAME) + ->where( + $selectQb->expr()->eq('userid', $selectQb->createNamedParameter($userId)), + $selectQb->expr()->eq('propertypath', $selectQb->createNamedParameter($path)), + ); + return $this->findEntities($selectQb); + } + } diff --git a/apps/dav/lib/Direct/DirectHome.php b/apps/dav/lib/Direct/DirectHome.php index 10e1017f5a4..ac411c9b52f 100644 --- a/apps/dav/lib/Direct/DirectHome.php +++ b/apps/dav/lib/Direct/DirectHome.php @@ -53,7 +53,7 @@ class DirectHome implements ICollection { } catch (DoesNotExistException $e) { // Since the token space is so huge only throttle on non-existing token $this->throttler->registerAttempt('directlink', $this->request->getRemoteAddress()); - $this->throttler->sleepDelay($this->request->getRemoteAddress(), 'directlink'); + $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), 'directlink'); throw new NotFound(); } diff --git a/apps/dav/lib/Events/CalendarShareUpdatedEvent.php b/apps/dav/lib/Events/CalendarShareUpdatedEvent.php index 762307b202f..0f8b23ad3ac 100644 --- a/apps/dav/lib/Events/CalendarShareUpdatedEvent.php +++ b/apps/dav/lib/Events/CalendarShareUpdatedEvent.php @@ -8,22 +8,24 @@ declare(strict_types=1); */ namespace OCA\DAV\Events; +use OCA\DAV\CalDAV\CalDavBackend; use OCP\EventDispatcher\Event; -use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp; -use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet; /** * Class CalendarShareUpdatedEvent * * @package OCA\DAV\Events * @since 20.0.0 + * + * @psalm-import-type CalendarInfo from CalDavBackend */ class CalendarShareUpdatedEvent extends Event { /** * CalendarShareUpdatedEvent constructor. * * @param int $calendarId - * @param array{id: int, uri: string, '{http://calendarserver.org/ns/}getctag': string, '{http://sabredav.org/ns}sync-token': int, '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set': SupportedCalendarComponentSet, '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp': ScheduleCalendarTransp, '{urn:ietf:params:xml:ns:caldav}calendar-timezone': ?string } $calendarData + * @psalm-param CalendarInfo $calendarData + * @param array $calendarData * @param list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> $oldShares * @param list<array{href: string, commonName: string, readOnly: bool}> $added * @param list<string> $removed @@ -47,7 +49,8 @@ class CalendarShareUpdatedEvent extends Event { } /** - * @return array{id: int, uri: string, '{http://calendarserver.org/ns/}getctag': string, '{http://sabredav.org/ns}sync-token': int, '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set': SupportedCalendarComponentSet, '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp': ScheduleCalendarTransp, '{urn:ietf:params:xml:ns:caldav}calendar-timezone': ?string } + * @psalm-return CalendarInfo + * @return array * @since 20.0.0 */ public function getCalendarData(): array { diff --git a/apps/dav/lib/Exception/ExampleEventException.php b/apps/dav/lib/Exception/ExampleEventException.php new file mode 100644 index 00000000000..2d77cc443cb --- /dev/null +++ b/apps/dav/lib/Exception/ExampleEventException.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Exception; + +class ExampleEventException extends \Exception { +} diff --git a/apps/dav/lib/Exception/UnsupportedLimitOnInitialSyncException.php b/apps/dav/lib/Exception/UnsupportedLimitOnInitialSyncException.php index 1022625c23b..c6b7f8564c5 100644 --- a/apps/dav/lib/Exception/UnsupportedLimitOnInitialSyncException.php +++ b/apps/dav/lib/Exception/UnsupportedLimitOnInitialSyncException.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Files/BrowserErrorPagePlugin.php b/apps/dav/lib/Files/BrowserErrorPagePlugin.php index de86c4995e2..85ed975a409 100644 --- a/apps/dav/lib/Files/BrowserErrorPagePlugin.php +++ b/apps/dav/lib/Files/BrowserErrorPagePlugin.php @@ -11,6 +11,7 @@ use OC\AppFramework\Http\Request; use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\TemplateResponse; use OCP\IRequest; +use OCP\Security\Bruteforce\MaxDelayReached; use OCP\Template\ITemplateManager; use Sabre\DAV\Exception; use Sabre\DAV\Server; @@ -60,6 +61,9 @@ class BrowserErrorPagePlugin extends ServerPlugin { if ($ex instanceof Exception) { $httpCode = $ex->getHTTPCode(); $headers = $ex->getHTTPHeaders($this->server); + } elseif ($ex instanceof MaxDelayReached) { + $httpCode = 429; + $headers = []; } else { $httpCode = 500; $headers = []; @@ -81,7 +85,7 @@ class BrowserErrorPagePlugin extends ServerPlugin { $request = \OCP\Server::get(IRequest::class); $templateName = 'exception'; - if ($httpCode === 403 || $httpCode === 404) { + if ($httpCode === 403 || $httpCode === 404 || $httpCode === 429) { $templateName = (string)$httpCode; } diff --git a/apps/dav/lib/Files/FileSearchBackend.php b/apps/dav/lib/Files/FileSearchBackend.php index ace367e4490..eb548bbd55c 100644 --- a/apps/dav/lib/Files/FileSearchBackend.php +++ b/apps/dav/lib/Files/FileSearchBackend.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -15,6 +16,7 @@ use OCA\DAV\Connector\Sabre\CachingTree; use OCA\DAV\Connector\Sabre\Directory; use OCA\DAV\Connector\Sabre\File; use OCA\DAV\Connector\Sabre\FilesPlugin; +use OCA\DAV\Connector\Sabre\Server; use OCA\DAV\Connector\Sabre\TagsPlugin; use OCP\Files\Cache\ICacheEntry; use OCP\Files\Folder; @@ -44,6 +46,7 @@ class FileSearchBackend implements ISearchBackend { public const OPERATOR_LIMIT = 100; public function __construct( + private Server $server, private CachingTree $tree, private IUser $user, private IRootFolder $rootFolder, @@ -133,6 +136,7 @@ class FileSearchBackend implements ISearchBackend { * @param string[] $requestProperties */ public function preloadPropertyFor(array $nodes, array $requestProperties): void { + $this->server->emit('preloadProperties', [$nodes, $requestProperties]); } private function getFolderForPath(?string $path = null): Folder { diff --git a/apps/dav/lib/Files/LazySearchBackend.php b/apps/dav/lib/Files/LazySearchBackend.php index a0ad730ff2b..6ba539ddd87 100644 --- a/apps/dav/lib/Files/LazySearchBackend.php +++ b/apps/dav/lib/Files/LazySearchBackend.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Files/Sharing/FilesDropPlugin.php b/apps/dav/lib/Files/Sharing/FilesDropPlugin.php index 9d883be81fc..a3dbd32ce6b 100644 --- a/apps/dav/lib/Files/Sharing/FilesDropPlugin.php +++ b/apps/dav/lib/Files/Sharing/FilesDropPlugin.php @@ -1,12 +1,15 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Files\Sharing; -use OC\Files\View; +use OCP\Files\Folder; +use OCP\Files\NotFoundException; use OCP\Share\IShare; +use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\MethodNotAllowed; use Sabre\DAV\ServerPlugin; use Sabre\HTTP\RequestInterface; @@ -17,14 +20,9 @@ use Sabre\HTTP\ResponseInterface; */ class FilesDropPlugin extends ServerPlugin { - private ?View $view = null; private ?IShare $share = null; private bool $enabled = false; - public function setView(View $view): void { - $this->view = $view; - } - public function setShare(IShare $share): void { $this->share = $share; } @@ -33,60 +31,169 @@ class FilesDropPlugin extends ServerPlugin { $this->enabled = true; } - /** * This initializes the plugin. - * - * @param \Sabre\DAV\Server $server Sabre server - * - * @return void - * @throws MethodNotAllowed + * It is ONLY initialized by the server on a file drop request. */ public function initialize(\Sabre\DAV\Server $server): void { $server->on('beforeMethod:*', [$this, 'beforeMethod'], 999); + $server->on('method:MKCOL', [$this, 'onMkcol']); $this->enabled = false; } - public function beforeMethod(RequestInterface $request, ResponseInterface $response): void { - if (!$this->enabled || $this->share === null || $this->view === null) { + public function onMkcol(RequestInterface $request, ResponseInterface $response) { + if (!$this->enabled || $this->share === null) { + return; + } + + $node = $this->share->getNode(); + if (!($node instanceof Folder)) { + return; + } + + // If this is a folder creation request we need + // to fake a success so we can pretend every + // folder now exists. + $response->setStatus(201); + return false; + } + + public function beforeMethod(RequestInterface $request, ResponseInterface $response) { + if (!$this->enabled || $this->share === null) { return; } - // Only allow file drop + $node = $this->share->getNode(); + if (!($node instanceof Folder)) { + return; + } + + // Retrieve the nickname from the request + $nickname = $request->hasHeader('X-NC-Nickname') + ? trim(urldecode($request->getHeader('X-NC-Nickname'))) + : null; + if ($request->getMethod() !== 'PUT') { - throw new MethodNotAllowed('Only PUT is allowed on files drop'); + // If uploading subfolders we need to ensure they get created + // within the nickname folder + if ($request->getMethod() === 'MKCOL') { + if (!$nickname) { + throw new BadRequest('A nickname header is required when uploading subfolders'); + } + } else { + throw new MethodNotAllowed('Only PUT is allowed on files drop'); + } + } + + // If this is a folder creation request + // let's stop there and let the onMkcol handle it + if ($request->getMethod() === 'MKCOL') { + return; } - // Always upload at the root level - $path = explode('/', $request->getPath()); - $path = array_pop($path); + // Now if we create a file, we need to create the + // full path along the way. We'll only handle conflict + // resolution on file conflicts, but not on folders. + + // e.g files/dCP8yn3N86EK9sL/Folder/image.jpg + $path = $request->getPath(); + $token = $this->share->getToken(); + + // e.g files/dCP8yn3N86EK9sL + $rootPath = substr($path, 0, strpos($path, $token) + strlen($token)); + // e.g /Folder/image.jpg + $relativePath = substr($path, strlen($rootPath)); + $isRootUpload = substr_count($relativePath, '/') === 1; // Extract the attributes for the file request $isFileRequest = false; $attributes = $this->share->getAttributes(); - $nickName = $request->hasHeader('X-NC-Nickname') ? urldecode($request->getHeader('X-NC-Nickname')) : null; if ($attributes !== null) { $isFileRequest = $attributes->getAttribute('fileRequest', 'enabled') === true; } // We need a valid nickname for file requests - if ($isFileRequest && ($nickName == null || trim($nickName) === '')) { - throw new MethodNotAllowed('Nickname is required for file requests'); + if ($isFileRequest && !$nickname) { + throw new BadRequest('A nickname header is required for file requests'); + } + + // We're only allowing the upload of + // long path with subfolders if a nickname is set. + // This prevents confusion when uploading files and help + // classify them by uploaders. + if (!$nickname && !$isRootUpload) { + throw new BadRequest('A nickname header is required when uploading subfolders'); + } + + if ($nickname) { + try { + $node->verifyPath($nickname); + } catch (\Exception $e) { + // If the path is not valid, we throw an exception + throw new BadRequest('Invalid nickname: ' . $nickname); + } + + // Forbid nicknames starting with a dot + if (str_starts_with($nickname, '.')) { + throw new BadRequest('Invalid nickname: ' . $nickname); + } + + // If we have a nickname, let's put + // all files in the subfolder + $relativePath = '/' . $nickname . '/' . $relativePath; + $relativePath = str_replace('//', '/', $relativePath); } - - // If this is a file request we need to create a folder for the user - if ($isFileRequest) { - // Check if the folder already exists - if (!($this->view->file_exists($nickName) === true)) { - $this->view->mkdir($nickName); + + // Create the folders along the way + $folder = $node; + $pathSegments = $this->getPathSegments(dirname($relativePath)); + foreach ($pathSegments as $pathSegment) { + if ($pathSegment === '') { + continue; + } + + try { + // get the current folder + $currentFolder = $folder->get($pathSegment); + // check target is a folder + if ($currentFolder instanceof Folder) { + $folder = $currentFolder; + } else { + // otherwise look in the parent folder if we already create an unique folder name + foreach ($folder->getDirectoryListing() as $child) { + // we look for folders which match "NAME (SUFFIX)" + if ($child instanceof Folder && str_starts_with($child->getName(), $pathSegment)) { + $suffix = substr($child->getName(), strlen($pathSegment)); + if (preg_match('/^ \(\d+\)$/', $suffix)) { + // we found the unique folder name and can use it + $folder = $child; + break; + } + } + } + // no folder found so we need to create a new unique folder name + if (!isset($child) || $child !== $folder) { + $folder = $folder->newFolder($folder->getNonExistingName($pathSegment)); + } + } + } catch (NotFoundException) { + // the folder does simply not exist so we create it + $folder = $folder->newFolder($pathSegment); } - // Put all files in the subfolder - $path = $nickName . '/' . $path; } - - $newName = \OC_Helper::buildNotExistingFileNameForView('/', $path, $this->view); - $url = $request->getBaseUrl() . $newName; + + // Finally handle conflicts on the end files + $uniqueName = $folder->getNonExistingName(basename($relativePath)); + $relativePath = substr($folder->getPath(), strlen($node->getPath())); + $path = '/files/' . $token . '/' . $relativePath . '/' . $uniqueName; + $url = rtrim($request->getBaseUrl(), '/') . str_replace('//', '/', $path); $request->setUrl($url); } + private function getPathSegments(string $path): array { + // Normalize slashes and remove trailing slash + $path = trim(str_replace('\\', '/', $path), '/'); + + return explode('/', $path); + } } diff --git a/apps/dav/lib/Files/Sharing/RootCollection.php b/apps/dav/lib/Files/Sharing/RootCollection.php new file mode 100644 index 00000000000..dd585fbb59b --- /dev/null +++ b/apps/dav/lib/Files/Sharing/RootCollection.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Files\Sharing; + +use Sabre\DAV\INode; +use Sabre\DAVACL\AbstractPrincipalCollection; +use Sabre\DAVACL\PrincipalBackend\BackendInterface; + +class RootCollection extends AbstractPrincipalCollection { + public function __construct( + private INode $root, + BackendInterface $principalBackend, + string $principalPrefix = 'principals', + ) { + parent::__construct($principalBackend, $principalPrefix); + } + + public function getChildForPrincipal(array $principalInfo): INode { + return $this->root; + } + + public function getName() { + return 'files'; + } +} diff --git a/apps/dav/lib/Listener/AddMissingIndicesListener.php b/apps/dav/lib/Listener/AddMissingIndicesListener.php index 035c6c9582e..d3a1cf4b224 100644 --- a/apps/dav/lib/Listener/AddMissingIndicesListener.php +++ b/apps/dav/lib/Listener/AddMissingIndicesListener.php @@ -30,6 +30,11 @@ class AddMissingIndicesListener implements IEventListener { 'dav_shares_resourceid_access', ['resourceid', 'access'] ); + $event->addMissingIndex( + 'calendarobjects', + 'calobjects_by_uid_index', + ['calendarid', 'calendartype', 'uid'] + ); } } diff --git a/apps/dav/lib/Listener/DavAdminSettingsListener.php b/apps/dav/lib/Listener/DavAdminSettingsListener.php index c59c2df1575..69501915208 100644 --- a/apps/dav/lib/Listener/DavAdminSettingsListener.php +++ b/apps/dav/lib/Listener/DavAdminSettingsListener.php @@ -42,11 +42,11 @@ class DavAdminSettingsListener implements IEventListener { $this->handleSetValue($event); return; } - + } private function handleGetValue(DeclarativeSettingsGetValueEvent $event): void { - + if ($event->getFieldId() === 'system_addressbook_enabled') { $event->setValue((int)$this->config->getValueBool('dav', 'system_addressbook_exposed', true)); } diff --git a/apps/dav/lib/Listener/UserEventsListener.php b/apps/dav/lib/Listener/UserEventsListener.php index 61d945e829b..c876192d67f 100644 --- a/apps/dav/lib/Listener/UserEventsListener.php +++ b/apps/dav/lib/Listener/UserEventsListener.php @@ -12,14 +12,14 @@ namespace OCA\DAV\Listener; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CardDAV\CardDavBackend; use OCA\DAV\CardDAV\SyncService; -use OCA\DAV\Service\DefaultContactService; +use OCA\DAV\Service\ExampleContactService; +use OCA\DAV\Service\ExampleEventService; use OCP\Accounts\UserUpdatedEvent; use OCP\Defaults; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\IUser; use OCP\IUserManager; -use OCP\Server; use OCP\User\Events\BeforeUserDeletedEvent; use OCP\User\Events\BeforeUserIdUnassignedEvent; use OCP\User\Events\UserChangedEvent; @@ -46,7 +46,9 @@ class UserEventsListener implements IEventListener { private CalDavBackend $calDav, private CardDavBackend $cardDav, private Defaults $themingDefaults, - private DefaultContactService $defaultContactService, + private ExampleContactService $exampleContactService, + private ExampleEventService $exampleEventService, + private LoggerInterface $logger, ) { } @@ -137,17 +139,31 @@ class UserEventsListener implements IEventListener { public function firstLogin(IUser $user): void { $principal = 'principals/users/' . $user->getUID(); + + $calendarId = null; if ($this->calDav->getCalendarsForUserCount($principal) === 0) { try { - $this->calDav->createCalendar($principal, CalDavBackend::PERSONAL_CALENDAR_URI, [ + $calendarId = $this->calDav->createCalendar($principal, CalDavBackend::PERSONAL_CALENDAR_URI, [ '{DAV:}displayname' => CalDavBackend::PERSONAL_CALENDAR_NAME, '{http://apple.com/ns/ical/}calendar-color' => $this->themingDefaults->getColorPrimary(), 'components' => 'VEVENT' ]); } catch (\Exception $e) { - Server::get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]); + $this->logger->error($e->getMessage(), ['exception' => $e]); } } + if ($calendarId !== null) { + try { + $this->exampleEventService->createExampleEvent($calendarId); + } catch (\Exception $e) { + $this->logger->error('Failed to create example event: ' . $e->getMessage(), [ + 'exception' => $e, + 'userId' => $user->getUID(), + 'calendarId' => $calendarId, + ]); + } + } + $addressBookId = null; if ($this->cardDav->getAddressBooksForUserCount($principal) === 0) { try { @@ -155,11 +171,11 @@ class UserEventsListener implements IEventListener { '{DAV:}displayname' => CardDavBackend::PERSONAL_ADDRESSBOOK_NAME, ]); } catch (\Exception $e) { - Server::get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]); + $this->logger->error($e->getMessage(), ['exception' => $e]); } } if ($addressBookId) { - $this->defaultContactService->createDefaultContact($addressBookId); + $this->exampleContactService->createDefaultContact($addressBookId); } } } diff --git a/apps/dav/lib/Migration/BuildCalendarSearchIndex.php b/apps/dav/lib/Migration/BuildCalendarSearchIndex.php index b157934a1ff..d8f906f22ee 100644 --- a/apps/dav/lib/Migration/BuildCalendarSearchIndex.php +++ b/apps/dav/lib/Migration/BuildCalendarSearchIndex.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Migration/BuildSocialSearchIndex.php b/apps/dav/lib/Migration/BuildSocialSearchIndex.php index 5fab3f4ef77..a808034365a 100644 --- a/apps/dav/lib/Migration/BuildSocialSearchIndex.php +++ b/apps/dav/lib/Migration/BuildSocialSearchIndex.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Migration/CalDAVRemoveEmptyValue.php b/apps/dav/lib/Migration/CalDAVRemoveEmptyValue.php index ecc462e153b..24e182e46eb 100644 --- a/apps/dav/lib/Migration/CalDAVRemoveEmptyValue.php +++ b/apps/dav/lib/Migration/CalDAVRemoveEmptyValue.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Migration/RegenerateBirthdayCalendars.php b/apps/dav/lib/Migration/RegenerateBirthdayCalendars.php index 14037801eb4..ef8e9002e9d 100644 --- a/apps/dav/lib/Migration/RegenerateBirthdayCalendars.php +++ b/apps/dav/lib/Migration/RegenerateBirthdayCalendars.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Migration/RegisterUpdateCalendarResourcesRoomBackgroundJob.php b/apps/dav/lib/Migration/RegisterUpdateCalendarResourcesRoomBackgroundJob.php new file mode 100644 index 00000000000..9d77aefafd2 --- /dev/null +++ b/apps/dav/lib/Migration/RegisterUpdateCalendarResourcesRoomBackgroundJob.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Migration; + +use OCA\DAV\BackgroundJob\UpdateCalendarResourcesRoomsBackgroundJob; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class RegisterUpdateCalendarResourcesRoomBackgroundJob implements IRepairStep { + public function __construct( + private readonly IJobList $jobList, + ) { + } + + public function getName() { + return 'Register a background job to update rooms and resources'; + } + + public function run(IOutput $output) { + $this->jobList->add(UpdateCalendarResourcesRoomsBackgroundJob::class); + } +} diff --git a/apps/dav/lib/Migration/RemoveObjectProperties.php b/apps/dav/lib/Migration/RemoveObjectProperties.php index 3f505ecb1e2..f09293ae0bb 100644 --- a/apps/dav/lib/Migration/RemoveObjectProperties.php +++ b/apps/dav/lib/Migration/RemoveObjectProperties.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-only diff --git a/apps/dav/lib/Migration/RemoveOrphanEventsAndContacts.php b/apps/dav/lib/Migration/RemoveOrphanEventsAndContacts.php index ead2645779b..143dc3cd1e6 100644 --- a/apps/dav/lib/Migration/RemoveOrphanEventsAndContacts.php +++ b/apps/dav/lib/Migration/RemoveOrphanEventsAndContacts.php @@ -8,82 +8,45 @@ declare(strict_types=1); */ namespace OCA\DAV\Migration; -use OCA\DAV\CalDAV\CalDavBackend; -use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\IDBConnection; +use OCA\DAV\BackgroundJob\CleanupOrphanedChildrenJob; +use OCP\BackgroundJob\IJobList; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; class RemoveOrphanEventsAndContacts implements IRepairStep { - public function __construct( - private IDBConnection $connection, + private readonly IJobList $jobList, ) { } - /** - * @inheritdoc - */ public function getName(): string { - return 'Clean up orphan event and contact data'; + return 'Queue jobs to clean up orphan event and contact data'; } - /** - * @inheritdoc - */ - public function run(IOutput $output) { - $orphanItems = $this->removeOrphanChildren('calendarobjects', 'calendars', 'calendarid'); - $output->info(sprintf('%d events without a calendar have been cleaned up', $orphanItems)); - $orphanItems = $this->removeOrphanChildren('calendarobjects_props', 'calendarobjects', 'objectid'); - $output->info(sprintf('%d properties without an events have been cleaned up', $orphanItems)); - $orphanItems = $this->removeOrphanChildren('calendarchanges', 'calendars', 'calendarid'); - $output->info(sprintf('%d changes without a calendar have been cleaned up', $orphanItems)); + public function run(IOutput $output): void { + $this->queueJob('calendarobjects', 'calendars', 'calendarid', '%d events without a calendar have been cleaned up'); + $this->queueJob('calendarobjects_props', 'calendarobjects', 'objectid', '%d properties without an events have been cleaned up'); + $this->queueJob('calendarchanges', 'calendars', 'calendarid', '%d changes without a calendar have been cleaned up'); - $orphanItems = $this->removeOrphanChildren('calendarobjects', 'calendarsubscriptions', 'calendarid'); - $output->info(sprintf('%d cached events without a calendar subscription have been cleaned up', $orphanItems)); - $orphanItems = $this->removeOrphanChildren('calendarchanges', 'calendarsubscriptions', 'calendarid'); - $output->info(sprintf('%d changes without a calendar subscription have been cleaned up', $orphanItems)); + $this->queueJob('calendarobjects', 'calendarsubscriptions', 'calendarid', '%d cached events without a calendar subscription have been cleaned up'); + $this->queueJob('calendarchanges', 'calendarsubscriptions', 'calendarid', '%d changes without a calendar subscription have been cleaned up'); - $orphanItems = $this->removeOrphanChildren('cards', 'addressbooks', 'addressbookid'); - $output->info(sprintf('%d contacts without an addressbook have been cleaned up', $orphanItems)); - $orphanItems = $this->removeOrphanChildren('cards_properties', 'cards', 'cardid'); - $output->info(sprintf('%d properties without a contact have been cleaned up', $orphanItems)); - $orphanItems = $this->removeOrphanChildren('addressbookchanges', 'addressbooks', 'addressbookid'); - $output->info(sprintf('%d changes without an addressbook have been cleaned up', $orphanItems)); + $this->queueJob('cards', 'addressbooks', 'addressbookid', '%d contacts without an addressbook have been cleaned up'); + $this->queueJob('cards_properties', 'cards', 'cardid', '%d properties without a contact have been cleaned up'); + $this->queueJob('addressbookchanges', 'addressbooks', 'addressbookid', '%d changes without an addressbook have been cleaned up'); } - protected function removeOrphanChildren($childTable, $parentTable, $parentId): int { - $qb = $this->connection->getQueryBuilder(); - - $qb->select('c.id') - ->from($childTable, 'c') - ->leftJoin('c', $parentTable, 'p', $qb->expr()->eq('c.' . $parentId, 'p.id')) - ->where($qb->expr()->isNull('p.id')); - - if (\in_array($parentTable, ['calendars', 'calendarsubscriptions'], true)) { - $calendarType = $parentTable === 'calendarsubscriptions' ? CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION : CalDavBackend::CALENDAR_TYPE_CALENDAR; - $qb->andWhere($qb->expr()->eq('c.calendartype', $qb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); - } - - $result = $qb->executeQuery(); - - $orphanItems = []; - while ($row = $result->fetch()) { - $orphanItems[] = (int)$row['id']; - } - $result->closeCursor(); - - if (!empty($orphanItems)) { - $qb->delete($childTable) - ->where($qb->expr()->in('id', $qb->createParameter('ids'))); - - $orphanItemsBatch = array_chunk($orphanItems, 200); - foreach ($orphanItemsBatch as $items) { - $qb->setParameter('ids', $items, IQueryBuilder::PARAM_INT_ARRAY); - $qb->executeStatement(); - } - } - - return count($orphanItems); + private function queueJob( + string $childTable, + string $parentTable, + string $parentId, + string $logMessage, + ): void { + $this->jobList->add(CleanupOrphanedChildrenJob::class, [ + CleanupOrphanedChildrenJob::ARGUMENT_CHILD_TABLE => $childTable, + CleanupOrphanedChildrenJob::ARGUMENT_PARENT_TABLE => $parentTable, + CleanupOrphanedChildrenJob::ARGUMENT_PARENT_ID => $parentId, + CleanupOrphanedChildrenJob::ARGUMENT_LOG_MESSAGE => $logMessage, + ]); } } diff --git a/apps/dav/lib/Migration/Version1004Date20170825134824.php b/apps/dav/lib/Migration/Version1004Date20170825134824.php index 54c4c194778..4bf9637b697 100644 --- a/apps/dav/lib/Migration/Version1004Date20170825134824.php +++ b/apps/dav/lib/Migration/Version1004Date20170825134824.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Migration/Version1004Date20170919104507.php b/apps/dav/lib/Migration/Version1004Date20170919104507.php index ca20e2fb4e7..678d92d2b83 100644 --- a/apps/dav/lib/Migration/Version1004Date20170919104507.php +++ b/apps/dav/lib/Migration/Version1004Date20170919104507.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Migration/Version1004Date20170924124212.php b/apps/dav/lib/Migration/Version1004Date20170924124212.php index fbfec7e8e2d..4d221e91132 100644 --- a/apps/dav/lib/Migration/Version1004Date20170924124212.php +++ b/apps/dav/lib/Migration/Version1004Date20170924124212.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Migration/Version1004Date20170926103422.php b/apps/dav/lib/Migration/Version1004Date20170926103422.php index 38506b0fc5d..ec56e035006 100644 --- a/apps/dav/lib/Migration/Version1004Date20170926103422.php +++ b/apps/dav/lib/Migration/Version1004Date20170926103422.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Migration/Version1005Date20180530124431.php b/apps/dav/lib/Migration/Version1005Date20180530124431.php index ac1994893fd..b5f9ff26962 100644 --- a/apps/dav/lib/Migration/Version1005Date20180530124431.php +++ b/apps/dav/lib/Migration/Version1005Date20180530124431.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Migration/Version1006Date20180619154313.php b/apps/dav/lib/Migration/Version1006Date20180619154313.php index 195209ed046..231861a68c4 100644 --- a/apps/dav/lib/Migration/Version1006Date20180619154313.php +++ b/apps/dav/lib/Migration/Version1006Date20180619154313.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Migration/Version1006Date20180628111625.php b/apps/dav/lib/Migration/Version1006Date20180628111625.php index 5f3aa4b6fe2..f4be26e6ad0 100644 --- a/apps/dav/lib/Migration/Version1006Date20180628111625.php +++ b/apps/dav/lib/Migration/Version1006Date20180628111625.php @@ -49,6 +49,7 @@ class Version1006Date20180628111625 extends SimpleMigrationStep { $calendarObjectsTable->dropIndex('calobjects_index'); } $calendarObjectsTable->addUniqueIndex(['calendarid', 'calendartype', 'uri'], 'calobjects_index'); + $calendarObjectsTable->addUniqueIndex(['calendarid', 'calendartype', 'uid'], 'calobjects_by_uid_index'); } if ($schema->hasTable('calendarobjects_props')) { diff --git a/apps/dav/lib/Model/ExampleEvent.php b/apps/dav/lib/Model/ExampleEvent.php new file mode 100644 index 00000000000..d2a5b8ad2d1 --- /dev/null +++ b/apps/dav/lib/Model/ExampleEvent.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Model; + +use Sabre\VObject\Component\VCalendar; + +/** + * Simple DTO to store a parsed example event and its UID. + */ +final class ExampleEvent { + public function __construct( + private readonly VCalendar $vCalendar, + private readonly string $uid, + ) { + } + + public function getUid(): string { + return $this->uid; + } + + public function getIcs(): string { + return $this->vCalendar->serialize(); + } +} diff --git a/apps/dav/lib/Paginate/PaginatePlugin.php b/apps/dav/lib/Paginate/PaginatePlugin.php index c02eb9f21eb..c5da18f5c47 100644 --- a/apps/dav/lib/Paginate/PaginatePlugin.php +++ b/apps/dav/lib/Paginate/PaginatePlugin.php @@ -49,8 +49,8 @@ class PaginatePlugin extends ServerPlugin { } $url = $request->getUrl(); if ( - $request->hasHeader(self::PAGINATE_HEADER) && - (!$request->hasHeader(self::PAGINATE_TOKEN_HEADER) || !$this->cache->exists($url, $request->getHeader(self::PAGINATE_TOKEN_HEADER))) + $request->hasHeader(self::PAGINATE_HEADER) + && (!$request->hasHeader(self::PAGINATE_TOKEN_HEADER) || !$this->cache->exists($url, $request->getHeader(self::PAGINATE_TOKEN_HEADER))) ) { $pageSize = (int)$request->getHeader(self::PAGINATE_COUNT_HEADER) ?: $this->pageSize; $offset = (int)$request->getHeader(self::PAGINATE_OFFSET_HEADER); @@ -68,9 +68,9 @@ class PaginatePlugin extends ServerPlugin { public function onMethod(RequestInterface $request, ResponseInterface $response) { $url = $this->server->httpRequest->getUrl(); if ( - $request->hasHeader(self::PAGINATE_TOKEN_HEADER) && - $request->hasHeader(self::PAGINATE_OFFSET_HEADER) && - $this->cache->exists($url, $request->getHeader(self::PAGINATE_TOKEN_HEADER)) + $request->hasHeader(self::PAGINATE_TOKEN_HEADER) + && $request->hasHeader(self::PAGINATE_OFFSET_HEADER) + && $this->cache->exists($url, $request->getHeader(self::PAGINATE_TOKEN_HEADER)) ) { $token = $request->getHeader(self::PAGINATE_TOKEN_HEADER); $offset = (int)$request->getHeader(self::PAGINATE_OFFSET_HEADER); diff --git a/apps/dav/lib/Provisioning/Apple/AppleProvisioningNode.php b/apps/dav/lib/Provisioning/Apple/AppleProvisioningNode.php index 198a09b4bc8..bb098a0f107 100644 --- a/apps/dav/lib/Provisioning/Apple/AppleProvisioningNode.php +++ b/apps/dav/lib/Provisioning/Apple/AppleProvisioningNode.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -57,7 +58,7 @@ class AppleProvisioningNode implements INode, IProperties { return [ '{DAV:}getcontentlength' => 42, - '{DAV:}getlastmodified' => $datetime->format(\DateTimeInterface::RFC2822), + '{DAV:}getlastmodified' => $datetime->format(\DateTimeInterface::RFC7231), ]; } diff --git a/apps/dav/lib/Provisioning/Apple/AppleProvisioningPlugin.php b/apps/dav/lib/Provisioning/Apple/AppleProvisioningPlugin.php index be5831feee3..258138caa42 100644 --- a/apps/dav/lib/Provisioning/Apple/AppleProvisioningPlugin.php +++ b/apps/dav/lib/Provisioning/Apple/AppleProvisioningPlugin.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/RootCollection.php b/apps/dav/lib/RootCollection.php index b2b34b26980..f1963c0ef01 100644 --- a/apps/dav/lib/RootCollection.php +++ b/apps/dav/lib/RootCollection.php @@ -160,6 +160,7 @@ class RootCollection extends SimpleCollection { Server::get(CleanupService::class), $rootFolder, $userSession, + $shareManager, ); $uploadCollection->disableListing = $disableListing; diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 9ea18c029c8..f81c7fa6f29 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. @@ -63,7 +64,9 @@ use OCA\DAV\Provisioning\Apple\AppleProvisioningPlugin; use OCA\DAV\SystemTag\SystemTagPlugin; use OCA\DAV\Upload\ChunkingPlugin; use OCA\DAV\Upload\ChunkingV2Plugin; +use OCA\DAV\Upload\UploadAutoMkcolPlugin; use OCA\Theming\ThemingDefaults; +use OCP\Accounts\IAccountManager; use OCP\App\IAppManager; use OCP\AppFramework\Http\Response; use OCP\AppFramework\Utility\ITimeFactory; @@ -71,7 +74,6 @@ use OCP\Comments\ICommentsManager; use OCP\Defaults; use OCP\Diagnostics\IEventLogger; use OCP\EventDispatcher\IEventDispatcher; -use OCP\Files\AppData\IAppDataFactory; use OCP\Files\IFilenameValidator; use OCP\Files\IRootFolder; use OCP\FilesMetadata\IFilesMetadataManager; @@ -215,10 +217,7 @@ class Server { $this->server->addPlugin(new VCFExportPlugin()); $this->server->addPlugin(new MultiGetExportPlugin()); $this->server->addPlugin(new HasPhotoPlugin()); - $this->server->addPlugin(new ImageExportPlugin(new PhotoCache( - \OCP\Server::get(IAppDataFactory::class)->get('dav-photocache'), - $logger) - )); + $this->server->addPlugin(new ImageExportPlugin(\OCP\Server::get(PhotoCache::class))); $this->server->addPlugin(\OCP\Server::get(CardDavRateLimitingPlugin::class)); $this->server->addPlugin(\OCP\Server::get(CardDavValidatePlugin::class)); @@ -235,6 +234,7 @@ class Server { $this->server->addPlugin(new CopyEtagHeaderPlugin()); $this->server->addPlugin(new RequestIdHeaderPlugin(\OCP\Server::get(IRequest::class))); + $this->server->addPlugin(new UploadAutoMkcolPlugin()); $this->server->addPlugin(new ChunkingV2Plugin(\OCP\Server::get(ICacheFactory::class))); $this->server->addPlugin(new ChunkingPlugin()); $this->server->addPlugin(new ZipFolderPlugin( @@ -287,6 +287,7 @@ class Server { \OCP\Server::get(IPreview::class), \OCP\Server::get(IUserSession::class), \OCP\Server::get(IFilenameValidator::class), + \OCP\Server::get(IAccountManager::class), false, $config->getSystemValueBool('debug', false) === false, ) @@ -354,6 +355,7 @@ class Server { \OCP\Server::get(IAppManager::class) )); $lazySearchBackend->setBackend(new FileSearchBackend( + $this->server, $this->server->tree, $user, \OCP\Server::get(IRootFolder::class), diff --git a/apps/dav/lib/Service/DefaultContactService.php b/apps/dav/lib/Service/DefaultContactService.php deleted file mode 100644 index ad7a1179195..00000000000 --- a/apps/dav/lib/Service/DefaultContactService.php +++ /dev/null @@ -1,77 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace OCA\DAV\Service; - -use OCA\DAV\AppInfo\Application; -use OCA\DAV\CardDAV\CardDavBackend; -use OCP\App\IAppManager; -use OCP\Files\AppData\IAppDataFactory; -use OCP\IAppConfig; -use Psr\Log\LoggerInterface; -use Symfony\Component\Uid\Uuid; - -class DefaultContactService { - public function __construct( - private CardDavBackend $cardDav, - private IAppManager $appManager, - private IAppDataFactory $appDataFactory, - private IAppConfig $config, - private LoggerInterface $logger, - ) { - } - - public function createDefaultContact(int $addressBookId): void { - $enableDefaultContact = $this->config->getValueString(Application::APP_ID, 'enableDefaultContact', 'no'); - if ($enableDefaultContact !== 'yes') { - return; - } - $appData = $this->appDataFactory->get('dav'); - try { - $folder = $appData->getFolder('defaultContact'); - $defaultContactFile = $folder->getFile('defaultContact.vcf'); - $data = $defaultContactFile->getContent(); - } catch (\Exception $e) { - $this->logger->error('Couldn\'t get default contact file', ['exception' => $e]); - return; - } - - // Make sure the UID is unique - $newUid = Uuid::v4()->toRfc4122(); - $newRev = date('Ymd\THis\Z'); - $vcard = \Sabre\VObject\Reader::read($data, \Sabre\VObject\Reader::OPTION_FORGIVING); - if ($vcard->UID) { - $vcard->UID->setValue($newUid); - } else { - $vcard->add('UID', $newUid); - } - if ($vcard->REV) { - $vcard->REV->setValue($newRev); - } else { - $vcard->add('REV', $newRev); - } - - // Level 3 means that the document is invalid - // https://sabre.io/vobject/vcard/#validating-vcard - $level3Warnings = array_filter($vcard->validate(), function ($warning) { - return $warning['level'] === 3; - }); - - if (!empty($level3Warnings)) { - $this->logger->error('Default contact is invalid', ['warnings' => $level3Warnings]); - return; - } - try { - $this->cardDav->createCard($addressBookId, 'default', $vcard->serialize(), false); - } catch (\Exception $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); - } - - } -} diff --git a/apps/dav/lib/Service/ExampleContactService.php b/apps/dav/lib/Service/ExampleContactService.php new file mode 100644 index 00000000000..6ed6c66cbb3 --- /dev/null +++ b/apps/dav/lib/Service/ExampleContactService.php @@ -0,0 +1,132 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Service; + +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CardDAV\CardDavBackend; +use OCP\AppFramework\Services\IAppConfig; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use Psr\Log\LoggerInterface; +use Symfony\Component\Uid\Uuid; + +class ExampleContactService { + private readonly IAppData $appData; + + public function __construct( + IAppDataFactory $appDataFactory, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + private readonly CardDavBackend $cardDav, + ) { + $this->appData = $appDataFactory->get(Application::APP_ID); + } + + public function isDefaultContactEnabled(): bool { + return $this->appConfig->getAppValueBool('enableDefaultContact', true); + } + + public function setDefaultContactEnabled(bool $value): void { + $this->appConfig->setAppValueBool('enableDefaultContact', $value); + } + + public function getCard(): ?string { + try { + $folder = $this->appData->getFolder('defaultContact'); + } catch (NotFoundException $e) { + return null; + } + + if (!$folder->fileExists('defaultContact.vcf')) { + return null; + } + + return $folder->getFile('defaultContact.vcf')->getContent(); + } + + public function setCard(?string $cardData = null) { + try { + $folder = $this->appData->getFolder('defaultContact'); + } catch (NotFoundException $e) { + $folder = $this->appData->newFolder('defaultContact'); + } + + $isCustom = true; + if (is_null($cardData)) { + $cardData = file_get_contents(__DIR__ . '/../ExampleContentFiles/exampleContact.vcf'); + $isCustom = false; + } + + if (!$cardData) { + throw new \Exception('Could not read exampleContact.vcf'); + } + + $file = (!$folder->fileExists('defaultContact.vcf')) ? $folder->newFile('defaultContact.vcf') : $folder->getFile('defaultContact.vcf'); + $file->putContent($cardData); + + $this->appConfig->setAppValueBool('hasCustomDefaultContact', $isCustom); + } + + public function defaultContactExists(): bool { + try { + $folder = $this->appData->getFolder('defaultContact'); + } catch (NotFoundException $e) { + return false; + } + return $folder->fileExists('defaultContact.vcf'); + } + + public function createDefaultContact(int $addressBookId): void { + if (!$this->isDefaultContactEnabled()) { + return; + } + + try { + $folder = $this->appData->getFolder('defaultContact'); + $defaultContactFile = $folder->getFile('defaultContact.vcf'); + $data = $defaultContactFile->getContent(); + } catch (\Exception $e) { + $this->logger->error('Couldn\'t get default contact file', ['exception' => $e]); + return; + } + + // Make sure the UID is unique + $newUid = Uuid::v4()->toRfc4122(); + $newRev = date('Ymd\THis\Z'); + $vcard = \Sabre\VObject\Reader::read($data, \Sabre\VObject\Reader::OPTION_FORGIVING); + if ($vcard->UID) { + $vcard->UID->setValue($newUid); + } else { + $vcard->add('UID', $newUid); + } + if ($vcard->REV) { + $vcard->REV->setValue($newRev); + } else { + $vcard->add('REV', $newRev); + } + + // Level 3 means that the document is invalid + // https://sabre.io/vobject/vcard/#validating-vcard + $level3Warnings = array_filter($vcard->validate(), static function ($warning) { + return $warning['level'] === 3; + }); + + if (!empty($level3Warnings)) { + $this->logger->error('Default contact is invalid', ['warnings' => $level3Warnings]); + return; + } + try { + $this->cardDav->createCard($addressBookId, 'default', $vcard->serialize(), false); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } +} diff --git a/apps/dav/lib/Service/ExampleEventService.php b/apps/dav/lib/Service/ExampleEventService.php new file mode 100644 index 00000000000..3b2b07fe416 --- /dev/null +++ b/apps/dav/lib/Service/ExampleEventService.php @@ -0,0 +1,205 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Service; + +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\Exception\ExampleEventException; +use OCA\DAV\Model\ExampleEvent; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IAppConfig; +use OCP\IL10N; +use OCP\Security\ISecureRandom; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; + +class ExampleEventService { + private const FOLDER_NAME = 'example_event'; + private const FILE_NAME = 'example_event.ics'; + private const ENABLE_CONFIG_KEY = 'create_example_event'; + + public function __construct( + private readonly CalDavBackend $calDavBackend, + private readonly ISecureRandom $random, + private readonly ITimeFactory $time, + private readonly IAppData $appData, + private readonly IAppConfig $appConfig, + private readonly IL10N $l10n, + ) { + } + + public function createExampleEvent(int $calendarId): void { + if (!$this->shouldCreateExampleEvent()) { + return; + } + + $exampleEvent = $this->getExampleEvent(); + $uid = $exampleEvent->getUid(); + $this->calDavBackend->createCalendarObject( + $calendarId, + "$uid.ics", + $exampleEvent->getIcs(), + ); + } + + private function getStartDate(): \DateTimeInterface { + return $this->time->now() + ->add(new \DateInterval('P7D')) + ->setTime(10, 00); + } + + private function getEndDate(): \DateTimeInterface { + return $this->time->now() + ->add(new \DateInterval('P7D')) + ->setTime(11, 00); + } + + private function getDefaultEvent(string $uid): VCalendar { + $defaultDescription = $this->l10n->t(<<<EOF +Welcome to Nextcloud Calendar! + +This is a sample event - explore the flexibility of planning with Nextcloud Calendar by making any edits you want! + +With Nextcloud Calendar, you can: +- Create, edit, and manage events effortlessly. +- Create multiple calendars and share them with teammates, friends, or family. +- Check availability and display your busy times to others. +- Seamlessly integrate with apps and devices via CalDAV. +- Customize your experience: schedule recurring events, adjust notifications and other settings. +EOF); + + $vCalendar = new VCalendar(); + $props = [ + 'UID' => $uid, + 'DTSTAMP' => $this->time->now(), + 'SUMMARY' => $this->l10n->t('Example event - open me!'), + 'DTSTART' => $this->getStartDate(), + 'DTEND' => $this->getEndDate(), + 'DESCRIPTION' => $defaultDescription, + ]; + $vCalendar->add('VEVENT', $props); + return $vCalendar; + } + + /** + * @return string|null The ics of the custom example event or null if no custom event was uploaded. + * @throws ExampleEventException If reading the custom ics file fails. + */ + private function getCustomExampleEvent(): ?string { + try { + $folder = $this->appData->getFolder(self::FOLDER_NAME); + $icsFile = $folder->getFile(self::FILE_NAME); + } catch (NotFoundException $e) { + return null; + } + + try { + return $icsFile->getContent(); + } catch (NotFoundException|NotPermittedException $e) { + throw new ExampleEventException( + 'Failed to read custom example event', + 0, + $e, + ); + } + } + + /** + * Get the configured example event or the default one. + * + * @throws ExampleEventException If loading the custom example event fails. + */ + public function getExampleEvent(): ExampleEvent { + $uid = $this->random->generate(32, ISecureRandom::CHAR_ALPHANUMERIC); + $customIcs = $this->getCustomExampleEvent(); + if ($customIcs === null) { + return new ExampleEvent($this->getDefaultEvent($uid), $uid); + } + + [$vCalendar, $vEvent] = $this->parseEvent($customIcs); + $vEvent->UID = $uid; + $vEvent->DTSTART = $this->getStartDate(); + $vEvent->DTEND = $this->getEndDate(); + $vEvent->remove('ORGANIZER'); + $vEvent->remove('ATTENDEE'); + return new ExampleEvent($vCalendar, $uid); + } + + /** + * @psalm-return list{VCalendar, VEvent} The VCALENDAR document and its VEVENT child component + * @throws ExampleEventException If parsing the event fails or if it is invalid. + */ + private function parseEvent(string $ics): array { + try { + $vCalendar = \Sabre\VObject\Reader::read($ics); + if (!($vCalendar instanceof VCalendar)) { + throw new ExampleEventException('Custom event does not contain a VCALENDAR component'); + } + + /** @var VEvent|null $vEvent */ + $vEvent = $vCalendar->getBaseComponent('VEVENT'); + if ($vEvent === null) { + throw new ExampleEventException('Custom event does not contain a VEVENT component'); + } + } catch (\Exception $e) { + throw new ExampleEventException('Failed to parse custom event: ' . $e->getMessage(), 0, $e); + } + + return [$vCalendar, $vEvent]; + } + + public function saveCustomExampleEvent(string $ics): void { + // Parse and validate the event before attempting to save it to prevent run time errors + $this->parseEvent($ics); + + try { + $folder = $this->appData->getFolder(self::FOLDER_NAME); + } catch (NotFoundException $e) { + $folder = $this->appData->newFolder(self::FOLDER_NAME); + } + + try { + $existingFile = $folder->getFile(self::FILE_NAME); + $existingFile->putContent($ics); + } catch (NotFoundException $e) { + $folder->newFile(self::FILE_NAME, $ics); + } + } + + public function deleteCustomExampleEvent(): void { + try { + $folder = $this->appData->getFolder(self::FOLDER_NAME); + $file = $folder->getFile(self::FILE_NAME); + } catch (NotFoundException $e) { + return; + } + + $file->delete(); + } + + public function hasCustomExampleEvent(): bool { + try { + return $this->getCustomExampleEvent() !== null; + } catch (ExampleEventException $e) { + return false; + } + } + + public function setCreateExampleEvent(bool $enable): void { + $this->appConfig->setValueBool(Application::APP_ID, self::ENABLE_CONFIG_KEY, $enable); + } + + public function shouldCreateExampleEvent(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, self::ENABLE_CONFIG_KEY, true); + } +} diff --git a/apps/dav/lib/Settings/CalDAVSettings.php b/apps/dav/lib/Settings/CalDAVSettings.php index 5b6b7fa7e3d..5e19539a899 100644 --- a/apps/dav/lib/Settings/CalDAVSettings.php +++ b/apps/dav/lib/Settings/CalDAVSettings.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Settings/ExampleContentSettings.php b/apps/dav/lib/Settings/ExampleContentSettings.php index f5607b6a31b..7b6f9b03a3a 100644 --- a/apps/dav/lib/Settings/ExampleContentSettings.php +++ b/apps/dav/lib/Settings/ExampleContentSettings.php @@ -9,28 +9,56 @@ declare(strict_types=1); namespace OCA\DAV\Settings; use OCA\DAV\AppInfo\Application; +use OCA\DAV\Service\ExampleContactService; +use OCA\DAV\Service\ExampleEventService; use OCP\App\IAppManager; use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IAppConfig; use OCP\AppFramework\Services\IInitialState; -use OCP\IConfig; use OCP\Settings\ISettings; class ExampleContentSettings implements ISettings { - public function __construct( - private IConfig $config, - private IInitialState $initialState, - private IAppManager $appManager, + private readonly IAppConfig $appConfig, + private readonly IInitialState $initialState, + private readonly IAppManager $appManager, + private readonly ExampleEventService $exampleEventService, + private readonly ExampleContactService $exampleContactService, ) { } public function getForm(): TemplateResponse { - $enableDefaultContact = $this->config->getAppValue(Application::APP_ID, 'enableDefaultContact', 'no'); - $this->initialState->provideInitialState('enableDefaultContact', $enableDefaultContact); + $calendarEnabled = $this->appManager->isEnabledForUser('calendar'); + $contactsEnabled = $this->appManager->isEnabledForUser('contacts'); + $this->initialState->provideInitialState('calendarEnabled', $calendarEnabled); + $this->initialState->provideInitialState('contactsEnabled', $contactsEnabled); + + if ($calendarEnabled) { + $enableDefaultEvent = $this->exampleEventService->shouldCreateExampleEvent(); + $this->initialState->provideInitialState('create_example_event', $enableDefaultEvent); + $this->initialState->provideInitialState( + 'has_custom_example_event', + $this->exampleEventService->hasCustomExampleEvent(), + ); + } + + if ($contactsEnabled) { + $this->initialState->provideInitialState( + 'enableDefaultContact', + $this->exampleContactService->isDefaultContactEnabled(), + ); + $this->initialState->provideInitialState( + 'hasCustomDefaultContact', + $this->appConfig->getAppValueBool('hasCustomDefaultContact'), + ); + } + return new TemplateResponse(Application::APP_ID, 'settings-example-content'); } + public function getSection(): ?string { - if (!$this->appManager->isEnabledForUser('contacts')) { + if (!$this->appManager->isEnabledForUser('contacts') + && !$this->appManager->isEnabledForUser('calendar')) { return null; } @@ -40,5 +68,4 @@ class ExampleContentSettings implements ISettings { public function getPriority(): int { return 10; } - } diff --git a/apps/dav/lib/SystemTag/SystemTagList.php b/apps/dav/lib/SystemTag/SystemTagList.php index 546467f562e..b55f10164d7 100644 --- a/apps/dav/lib/SystemTag/SystemTagList.php +++ b/apps/dav/lib/SystemTag/SystemTagList.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-only diff --git a/apps/dav/lib/SystemTag/SystemTagNode.php b/apps/dav/lib/SystemTag/SystemTagNode.php index da51279a9d2..2341d4823ba 100644 --- a/apps/dav/lib/SystemTag/SystemTagNode.php +++ b/apps/dav/lib/SystemTag/SystemTagNode.php @@ -122,8 +122,8 @@ class SystemTagNode implements \Sabre\DAV\ICollection { throw new NotFound('Tag with id ' . $this->tag->getId() . ' does not exist'); } catch (TagAlreadyExistsException $e) { throw new Conflict( - 'Tag with the properties "' . $name . '", ' . - $userVisible . ', ' . $userAssignable . ' already exists' + 'Tag with the properties "' . $name . '", ' + . $userVisible . ', ' . $userAssignable . ' already exists' ); } } @@ -176,15 +176,15 @@ class SystemTagNode implements \Sabre\DAV\ICollection { public function createFile($name, $data = null) { throw new MethodNotAllowed(); } - + public function createDirectory($name) { throw new MethodNotAllowed(); } - + public function getChild($name) { return new SystemTagObjectType($this->tag, $name, $this->tagManager, $this->tagMapper); } - + public function childExists($name) { $objectTypes = $this->tagMapper->getAvailableObjectTypes(); return in_array($name, $objectTypes); diff --git a/apps/dav/lib/SystemTag/SystemTagObjectType.php b/apps/dav/lib/SystemTag/SystemTagObjectType.php index 0e1368854cd..0d348cd95f4 100644 --- a/apps/dav/lib/SystemTag/SystemTagObjectType.php +++ b/apps/dav/lib/SystemTag/SystemTagObjectType.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-only diff --git a/apps/dav/lib/SystemTag/SystemTagsObjectList.php b/apps/dav/lib/SystemTag/SystemTagsObjectList.php index 5ccc924eb53..64e8b1bbebb 100644 --- a/apps/dav/lib/SystemTag/SystemTagsObjectList.php +++ b/apps/dav/lib/SystemTag/SystemTagsObjectList.php @@ -45,7 +45,7 @@ class SystemTagsObjectList implements XmlSerializable, XmlDeserializable { if ($tree === null) { return null; } - + $objects = []; foreach ($tree as $elem) { if ($elem['name'] === self::OBJECTID_ROOT_PROPERTYNAME) { diff --git a/apps/dav/lib/Traits/PrincipalProxyTrait.php b/apps/dav/lib/Traits/PrincipalProxyTrait.php index 279f796b720..feec485fe5c 100644 --- a/apps/dav/lib/Traits/PrincipalProxyTrait.php +++ b/apps/dav/lib/Traits/PrincipalProxyTrait.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Upload/CleanupService.php b/apps/dav/lib/Upload/CleanupService.php index 36b75280504..ffa6bad533c 100644 --- a/apps/dav/lib/Upload/CleanupService.php +++ b/apps/dav/lib/Upload/CleanupService.php @@ -10,20 +10,18 @@ namespace OCA\DAV\Upload; use OCA\DAV\BackgroundJob\UploadCleanup; use OCP\BackgroundJob\IJobList; -use OCP\IUserSession; class CleanupService { public function __construct( - private IUserSession $userSession, private IJobList $jobList, ) { } - public function addJob(string $folder) { - $this->jobList->add(UploadCleanup::class, ['uid' => $this->userSession->getUser()->getUID(), 'folder' => $folder]); + public function addJob(string $uid, string $folder) { + $this->jobList->add(UploadCleanup::class, ['uid' => $uid, 'folder' => $folder]); } - public function removeJob(string $folder) { - $this->jobList->remove(UploadCleanup::class, ['uid' => $this->userSession->getUser()->getUID(), 'folder' => $folder]); + public function removeJob(string $uid, string $folder) { + $this->jobList->remove(UploadCleanup::class, ['uid' => $uid, 'folder' => $folder]); } } diff --git a/apps/dav/lib/Upload/RootCollection.php b/apps/dav/lib/Upload/RootCollection.php index 9ea2592702b..cd7ab7f5e0a 100644 --- a/apps/dav/lib/Upload/RootCollection.php +++ b/apps/dav/lib/Upload/RootCollection.php @@ -11,6 +11,7 @@ namespace OCA\DAV\Upload; use OCP\Files\IRootFolder; use OCP\IUserSession; +use OCP\Share\IManager; use Sabre\DAVACL\AbstractPrincipalCollection; use Sabre\DAVACL\PrincipalBackend; @@ -22,6 +23,7 @@ class RootCollection extends AbstractPrincipalCollection { private CleanupService $cleanupService, private IRootFolder $rootFolder, private IUserSession $userSession, + private IManager $shareManager, ) { parent::__construct($principalBackend, $principalPrefix); } @@ -30,7 +32,13 @@ class RootCollection extends AbstractPrincipalCollection { * @inheritdoc */ public function getChildForPrincipal(array $principalInfo): UploadHome { - return new UploadHome($principalInfo, $this->cleanupService, $this->rootFolder, $this->userSession); + return new UploadHome( + $principalInfo, + $this->cleanupService, + $this->rootFolder, + $this->userSession, + $this->shareManager, + ); } /** diff --git a/apps/dav/lib/Upload/UploadAutoMkcolPlugin.php b/apps/dav/lib/Upload/UploadAutoMkcolPlugin.php new file mode 100644 index 00000000000..a7030ba1133 --- /dev/null +++ b/apps/dav/lib/Upload/UploadAutoMkcolPlugin.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Upload; + +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use function Sabre\Uri\split as uriSplit; + +/** + * Class that allows automatically creating non-existing collections on file + * upload. + * + * Since this functionality is not WebDAV compliant, it needs a special + * header to be activated. + */ +class UploadAutoMkcolPlugin extends ServerPlugin { + + private Server $server; + + public function initialize(Server $server): void { + $server->on('beforeMethod:PUT', [$this, 'beforeMethod']); + $this->server = $server; + } + + /** + * @throws NotFound a node expected to exist cannot be found + */ + public function beforeMethod(RequestInterface $request, ResponseInterface $response): bool { + if ($request->getHeader('X-NC-WebDAV-Auto-Mkcol') !== '1') { + return true; + } + + [$path,] = uriSplit($request->getPath()); + + if ($this->server->tree->nodeExists($path)) { + return true; + } + + $parts = explode('/', trim($path, '/')); + $rootPath = array_shift($parts); + $node = $this->server->tree->getNodeForPath('/' . $rootPath); + + if (!($node instanceof ICollection)) { + // the root node is not a collection, let SabreDAV handle it + return true; + } + + foreach ($parts as $part) { + if (!$node->childExists($part)) { + $node->createDirectory($part); + } + + $node = $node->getChild($part); + } + + return true; + } +} diff --git a/apps/dav/lib/Upload/UploadFolder.php b/apps/dav/lib/Upload/UploadFolder.php index 57e95d2b17b..8890d472f87 100644 --- a/apps/dav/lib/Upload/UploadFolder.php +++ b/apps/dav/lib/Upload/UploadFolder.php @@ -21,6 +21,7 @@ class UploadFolder implements ICollection { private Directory $node, private CleanupService $cleanupService, private IStorage $storage, + private string $uid, ) { } @@ -89,7 +90,7 @@ class UploadFolder implements ICollection { $this->node->delete(); // Background cleanup job is not needed anymore - $this->cleanupService->removeJob($this->getName()); + $this->cleanupService->removeJob($this->uid, $this->getName()); } public function getName() { diff --git a/apps/dav/lib/Upload/UploadHome.php b/apps/dav/lib/Upload/UploadHome.php index a6551d4d079..4042f1c4101 100644 --- a/apps/dav/lib/Upload/UploadHome.php +++ b/apps/dav/lib/Upload/UploadHome.php @@ -17,6 +17,7 @@ use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\ICollection; class UploadHome implements ICollection { + private string $uid; private ?Folder $uploadFolder = null; public function __construct( @@ -24,7 +25,19 @@ class UploadHome implements ICollection { private readonly CleanupService $cleanupService, private readonly IRootFolder $rootFolder, private readonly IUserSession $userSession, + private readonly \OCP\Share\IManager $shareManager, ) { + [$prefix, $name] = \Sabre\Uri\split($principalInfo['uri']); + if ($prefix === 'principals/shares') { + $this->uid = $this->shareManager->getShareByToken($name)->getShareOwner(); + } else { + $user = $this->userSession->getUser(); + if (!$user) { + throw new Forbidden('Not logged in'); + } + + $this->uid = $user->getUID(); + } } public function createFile($name, $data = null) { @@ -35,16 +48,26 @@ class UploadHome implements ICollection { $this->impl()->createDirectory($name); // Add a cleanup job - $this->cleanupService->addJob($name); + $this->cleanupService->addJob($this->uid, $name); } public function getChild($name): UploadFolder { - return new UploadFolder($this->impl()->getChild($name), $this->cleanupService, $this->getStorage()); + return new UploadFolder( + $this->impl()->getChild($name), + $this->cleanupService, + $this->getStorage(), + $this->uid, + ); } public function getChildren(): array { return array_map(function ($node) { - return new UploadFolder($node, $this->cleanupService, $this->getStorage()); + return new UploadFolder( + $node, + $this->cleanupService, + $this->getStorage(), + $this->uid, + ); }, $this->impl()->getChildren()); } @@ -71,11 +94,7 @@ class UploadHome implements ICollection { private function getUploadFolder(): Folder { if ($this->uploadFolder === null) { - $user = $this->userSession->getUser(); - if (!$user) { - throw new Forbidden('Not logged in'); - } - $path = '/' . $user->getUID() . '/uploads'; + $path = '/' . $this->uid . '/uploads'; try { $folder = $this->rootFolder->get($path); if (!$folder instanceof Folder) { |