diff options
Diffstat (limited to 'apps/dav/lib/CardDAV')
28 files changed, 2868 insertions, 1343 deletions
diff --git a/apps/dav/lib/CardDAV/Activity/Backend.php b/apps/dav/lib/CardDAV/Activity/Backend.php new file mode 100644 index 00000000000..b08414d3b02 --- /dev/null +++ b/apps/dav/lib/CardDAV/Activity/Backend.php @@ -0,0 +1,472 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CardDAV\Activity; + +use OCA\DAV\CardDAV\Activity\Provider\Addressbook; +use OCP\Activity\IEvent; +use OCP\Activity\IManager as IActivityManager; +use OCP\App\IAppManager; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use Sabre\CardDAV\Plugin; +use Sabre\VObject\Reader; + +class Backend { + + public function __construct( + protected IActivityManager $activityManager, + protected IGroupManager $groupManager, + protected IUserSession $userSession, + protected IAppManager $appManager, + protected IUserManager $userManager, + ) { + } + + /** + * Creates activities when an addressbook was creates + * + * @param array $addressbookData + */ + public function onAddressbookCreate(array $addressbookData): void { + $this->triggerAddressbookActivity(Addressbook::SUBJECT_ADD, $addressbookData); + } + + /** + * Creates activities when a calendar was updated + * + * @param array $addressbookData + * @param array $shares + * @param array $properties + */ + public function onAddressbookUpdate(array $addressbookData, array $shares, array $properties): void { + $this->triggerAddressbookActivity(Addressbook::SUBJECT_UPDATE, $addressbookData, $shares, $properties); + } + + /** + * Creates activities when a calendar was deleted + * + * @param array $addressbookData + * @param array $shares + */ + public function onAddressbookDelete(array $addressbookData, array $shares): void { + $this->triggerAddressbookActivity(Addressbook::SUBJECT_DELETE, $addressbookData, $shares); + } + + /** + * Creates activities for all related users when a calendar was touched + * + * @param string $action + * @param array $addressbookData + * @param array $shares + * @param array $changedProperties + */ + protected function triggerAddressbookActivity(string $action, array $addressbookData, array $shares = [], array $changedProperties = []): void { + if (!isset($addressbookData['principaluri'])) { + return; + } + + $principalUri = $addressbookData['principaluri']; + + // We are not interested in changes from the system addressbook + if ($principalUri === 'principals/system/system') { + return; + } + + $principal = explode('/', $principalUri); + $owner = array_pop($principal); + + $currentUser = $this->userSession->getUser(); + if ($currentUser instanceof IUser) { + $currentUser = $currentUser->getUID(); + } else { + $currentUser = $owner; + } + + $event = $this->activityManager->generateEvent(); + $event->setApp('dav') + ->setObject('addressbook', (int)$addressbookData['id']) + ->setType('contacts') + ->setAuthor($currentUser); + + $changedVisibleInformation = array_intersect([ + '{DAV:}displayname', + '{' . Plugin::NS_CARDDAV . '}addressbook-description', + ], array_keys($changedProperties)); + + if (empty($shares) || ($action === Addressbook::SUBJECT_UPDATE && empty($changedVisibleInformation))) { + $users = [$owner]; + } else { + $users = $this->getUsersForShares($shares); + $users[] = $owner; + } + + foreach ($users as $user) { + if ($action === Addressbook::SUBJECT_DELETE && !$this->userManager->userExists($user)) { + // Avoid creating addressbook_delete activities for deleted users + continue; + } + + $event->setAffectedUser($user) + ->setSubject( + $user === $currentUser ? $action . '_self' : $action, + [ + 'actor' => $currentUser, + 'addressbook' => [ + 'id' => (int)$addressbookData['id'], + 'uri' => $addressbookData['uri'], + 'name' => $addressbookData['{DAV:}displayname'], + ], + ] + ); + $this->activityManager->publish($event); + } + } + + /** + * Creates activities for all related users when an addressbook was (un-)shared + * + * @param array $addressbookData + * @param array $shares + * @param array $add + * @param array $remove + */ + public function onAddressbookUpdateShares(array $addressbookData, array $shares, array $add, array $remove): void { + $principal = explode('/', $addressbookData['principaluri']); + $owner = $principal[2]; + + $currentUser = $this->userSession->getUser(); + if ($currentUser instanceof IUser) { + $currentUser = $currentUser->getUID(); + } else { + $currentUser = $owner; + } + + $event = $this->activityManager->generateEvent(); + $event->setApp('dav') + ->setObject('addressbook', (int)$addressbookData['id']) + ->setType('contacts') + ->setAuthor($currentUser); + + foreach ($remove as $principal) { + // principal:principals/users/test + $parts = explode(':', $principal, 2); + if ($parts[0] !== 'principal') { + continue; + } + $principal = explode('/', $parts[1]); + + if ($principal[1] === 'users') { + $this->triggerActivityUser( + $principal[2], + $event, + $addressbookData, + Addressbook::SUBJECT_UNSHARE_USER, + Addressbook::SUBJECT_DELETE . '_self' + ); + + if ($owner !== $principal[2]) { + $parameters = [ + 'actor' => $event->getAuthor(), + 'addressbook' => [ + 'id' => (int)$addressbookData['id'], + 'uri' => $addressbookData['uri'], + 'name' => $addressbookData['{DAV:}displayname'], + ], + 'user' => $principal[2], + ]; + + if ($owner === $event->getAuthor()) { + $subject = Addressbook::SUBJECT_UNSHARE_USER . '_you'; + } elseif ($principal[2] === $event->getAuthor()) { + $subject = Addressbook::SUBJECT_UNSHARE_USER . '_self'; + } else { + $event->setAffectedUser($event->getAuthor()) + ->setSubject(Addressbook::SUBJECT_UNSHARE_USER . '_you', $parameters); + $this->activityManager->publish($event); + + $subject = Addressbook::SUBJECT_UNSHARE_USER . '_by'; + } + + $event->setAffectedUser($owner) + ->setSubject($subject, $parameters); + $this->activityManager->publish($event); + } + } elseif ($principal[1] === 'groups') { + $this->triggerActivityGroup($principal[2], $event, $addressbookData, Addressbook::SUBJECT_UNSHARE_USER); + + $parameters = [ + 'actor' => $event->getAuthor(), + 'addressbook' => [ + 'id' => (int)$addressbookData['id'], + 'uri' => $addressbookData['uri'], + 'name' => $addressbookData['{DAV:}displayname'], + ], + 'group' => $principal[2], + ]; + + if ($owner === $event->getAuthor()) { + $subject = Addressbook::SUBJECT_UNSHARE_GROUP . '_you'; + } else { + $event->setAffectedUser($event->getAuthor()) + ->setSubject(Addressbook::SUBJECT_UNSHARE_GROUP . '_you', $parameters); + $this->activityManager->publish($event); + + $subject = Addressbook::SUBJECT_UNSHARE_GROUP . '_by'; + } + + $event->setAffectedUser($owner) + ->setSubject($subject, $parameters); + $this->activityManager->publish($event); + } + } + + foreach ($add as $share) { + if ($this->isAlreadyShared($share['href'], $shares)) { + continue; + } + + // principal:principals/users/test + $parts = explode(':', $share['href'], 2); + if ($parts[0] !== 'principal') { + continue; + } + $principal = explode('/', $parts[1]); + + if ($principal[1] === 'users') { + $this->triggerActivityUser($principal[2], $event, $addressbookData, Addressbook::SUBJECT_SHARE_USER); + + if ($owner !== $principal[2]) { + $parameters = [ + 'actor' => $event->getAuthor(), + 'addressbook' => [ + 'id' => (int)$addressbookData['id'], + 'uri' => $addressbookData['uri'], + 'name' => $addressbookData['{DAV:}displayname'], + ], + 'user' => $principal[2], + ]; + + if ($owner === $event->getAuthor()) { + $subject = Addressbook::SUBJECT_SHARE_USER . '_you'; + } else { + $event->setAffectedUser($event->getAuthor()) + ->setSubject(Addressbook::SUBJECT_SHARE_USER . '_you', $parameters); + $this->activityManager->publish($event); + + $subject = Addressbook::SUBJECT_SHARE_USER . '_by'; + } + + $event->setAffectedUser($owner) + ->setSubject($subject, $parameters); + $this->activityManager->publish($event); + } + } elseif ($principal[1] === 'groups') { + $this->triggerActivityGroup($principal[2], $event, $addressbookData, Addressbook::SUBJECT_SHARE_USER); + + $parameters = [ + 'actor' => $event->getAuthor(), + 'addressbook' => [ + 'id' => (int)$addressbookData['id'], + 'uri' => $addressbookData['uri'], + 'name' => $addressbookData['{DAV:}displayname'], + ], + 'group' => $principal[2], + ]; + + if ($owner === $event->getAuthor()) { + $subject = Addressbook::SUBJECT_SHARE_GROUP . '_you'; + } else { + $event->setAffectedUser($event->getAuthor()) + ->setSubject(Addressbook::SUBJECT_SHARE_GROUP . '_you', $parameters); + $this->activityManager->publish($event); + + $subject = Addressbook::SUBJECT_SHARE_GROUP . '_by'; + } + + $event->setAffectedUser($owner) + ->setSubject($subject, $parameters); + $this->activityManager->publish($event); + } + } + } + + /** + * Checks if a calendar is already shared with a principal + * + * @param string $principal + * @param array[] $shares + * @return bool + */ + protected function isAlreadyShared(string $principal, array $shares): bool { + foreach ($shares as $share) { + if ($principal === $share['href']) { + return true; + } + } + + return false; + } + + /** + * Creates the given activity for all members of the given group + * + * @param string $gid + * @param IEvent $event + * @param array $properties + * @param string $subject + */ + protected function triggerActivityGroup(string $gid, IEvent $event, array $properties, string $subject): void { + $group = $this->groupManager->get($gid); + + if ($group instanceof IGroup) { + foreach ($group->getUsers() as $user) { + // Exclude current user + if ($user->getUID() !== $event->getAuthor()) { + $this->triggerActivityUser($user->getUID(), $event, $properties, $subject); + } + } + } + } + + /** + * Creates the given activity for the given user + * + * @param string $user + * @param IEvent $event + * @param array $properties + * @param string $subject + * @param string $subjectSelf + */ + protected function triggerActivityUser(string $user, IEvent $event, array $properties, string $subject, string $subjectSelf = ''): void { + $event->setAffectedUser($user) + ->setSubject( + $user === $event->getAuthor() && $subjectSelf ? $subjectSelf : $subject, + [ + 'actor' => $event->getAuthor(), + 'addressbook' => [ + 'id' => (int)$properties['id'], + 'uri' => $properties['uri'], + 'name' => $properties['{DAV:}displayname'], + ], + ] + ); + + $this->activityManager->publish($event); + } + + /** + * Creates activities when a card was created/updated/deleted + * + * @param string $action + * @param array $addressbookData + * @param array $shares + * @param array $cardData + */ + public function triggerCardActivity(string $action, array $addressbookData, array $shares, array $cardData): void { + if (!isset($addressbookData['principaluri'])) { + return; + } + + $principalUri = $addressbookData['principaluri']; + + // We are not interested in changes from the system addressbook + if ($principalUri === 'principals/system/system') { + return; + } + + $principal = explode('/', $principalUri); + $owner = array_pop($principal); + + $currentUser = $this->userSession->getUser(); + if ($currentUser instanceof IUser) { + $currentUser = $currentUser->getUID(); + } else { + $currentUser = $owner; + } + + $card = $this->getCardNameAndId($cardData); + + $event = $this->activityManager->generateEvent(); + $event->setApp('dav') + ->setObject('addressbook', (int)$addressbookData['id']) + ->setType('contacts') + ->setAuthor($currentUser); + + $users = $this->getUsersForShares($shares); + $users[] = $owner; + + // Users for share can return the owner itself if the calendar is published + foreach (array_unique($users) as $user) { + $params = [ + 'actor' => $event->getAuthor(), + 'addressbook' => [ + 'id' => (int)$addressbookData['id'], + 'uri' => $addressbookData['uri'], + 'name' => $addressbookData['{DAV:}displayname'], + ], + 'card' => [ + 'id' => $card['id'], + 'name' => $card['name'], + ], + ]; + + + $event->setAffectedUser($user) + ->setSubject( + $user === $currentUser ? $action . '_self' : $action, + $params + ); + + $this->activityManager->publish($event); + } + } + + /** + * @param array $cardData + * @return string[] + */ + protected function getCardNameAndId(array $cardData): array { + $vObject = Reader::read($cardData['carddata']); + return ['id' => (string)$vObject->UID, 'name' => (string)($vObject->FN ?? '')]; + } + + /** + * Get all users that have access to a given calendar + * + * @param array $shares + * @return string[] + */ + protected function getUsersForShares(array $shares): array { + $users = $groups = []; + foreach ($shares as $share) { + $principal = explode('/', $share['{http://owncloud.org/ns}principal']); + if ($principal[1] === 'users') { + $users[] = $principal[2]; + } elseif ($principal[1] === 'groups') { + $groups[] = $principal[2]; + } + } + + if (!empty($groups)) { + foreach ($groups as $gid) { + $group = $this->groupManager->get($gid); + if ($group instanceof IGroup) { + foreach ($group->getUsers() as $user) { + $users[] = $user->getUID(); + } + } + } + } + + return array_unique($users); + } +} diff --git a/apps/dav/lib/CardDAV/Activity/Filter.php b/apps/dav/lib/CardDAV/Activity/Filter.php new file mode 100644 index 00000000000..8b221a29ff0 --- /dev/null +++ b/apps/dav/lib/CardDAV/Activity/Filter.php @@ -0,0 +1,65 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CardDAV\Activity; + +use OCP\Activity\IFilter; +use OCP\IL10N; +use OCP\IURLGenerator; + +class Filter implements IFilter { + + public function __construct( + protected IL10N $l, + protected IURLGenerator $url, + ) { + } + + /** + * @return string Lowercase a-z and underscore only identifier + */ + public function getIdentifier(): string { + return 'contacts'; + } + + /** + * @return string A translated string + */ + public function getName(): string { + return $this->l->t('Contacts'); + } + + /** + * @return int whether the filter should be rather on the top or bottom of + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + */ + public function getPriority(): int { + return 40; + } + + /** + * @return string Full URL to an icon, empty string when none is given + */ + public function getIcon(): string { + return $this->url->getAbsoluteURL($this->url->imagePath('core', 'places/contacts.svg')); + } + + /** + * @param string[] $types + * @return string[] An array of allowed apps from which activities should be displayed + */ + public function filterTypes(array $types): array { + return array_intersect(['contacts'], $types); + } + + /** + * @return string[] An array of allowed apps from which activities should be displayed + */ + public function allowedApps(): array { + return []; + } +} diff --git a/apps/dav/lib/CardDAV/Activity/Provider/Addressbook.php b/apps/dav/lib/CardDAV/Activity/Provider/Addressbook.php new file mode 100644 index 00000000000..cdb9769401f --- /dev/null +++ b/apps/dav/lib/CardDAV/Activity/Provider/Addressbook.php @@ -0,0 +1,165 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CardDAV\Activity\Provider; + +use OCP\Activity\Exceptions\UnknownActivityException; +use OCP\Activity\IEvent; +use OCP\Activity\IEventMerger; +use OCP\Activity\IManager; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\L10N\IFactory; + +class Addressbook extends Base { + public const SUBJECT_ADD = 'addressbook_add'; + public const SUBJECT_UPDATE = 'addressbook_update'; + public const SUBJECT_DELETE = 'addressbook_delete'; + public const SUBJECT_SHARE_USER = 'addressbook_user_share'; + public const SUBJECT_SHARE_GROUP = 'addressbook_group_share'; + public const SUBJECT_UNSHARE_USER = 'addressbook_user_unshare'; + public const SUBJECT_UNSHARE_GROUP = 'addressbook_group_unshare'; + + public function __construct( + protected IFactory $languageFactory, + IURLGenerator $url, + protected IManager $activityManager, + IUserManager $userManager, + IGroupManager $groupManager, + protected IEventMerger $eventMerger, + ) { + parent::__construct($userManager, $groupManager, $url); + } + + /** + * @param string $language + * @param IEvent $event + * @param IEvent|null $previousEvent + * @return IEvent + * @throws UnknownActivityException + */ + public function parse($language, IEvent $event, ?IEvent $previousEvent = null): IEvent { + if ($event->getApp() !== 'dav' || $event->getType() !== 'contacts') { + throw new UnknownActivityException(); + } + + $l = $this->languageFactory->get('dav', $language); + + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/contacts-dark.png'))); + } else { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/contacts.svg'))); + } + + if ($event->getSubject() === self::SUBJECT_ADD) { + $subject = $l->t('{actor} created address book {addressbook}'); + } elseif ($event->getSubject() === self::SUBJECT_ADD . '_self') { + $subject = $l->t('You created address book {addressbook}'); + } elseif ($event->getSubject() === self::SUBJECT_DELETE) { + $subject = $l->t('{actor} deleted address book {addressbook}'); + } elseif ($event->getSubject() === self::SUBJECT_DELETE . '_self') { + $subject = $l->t('You deleted address book {addressbook}'); + } elseif ($event->getSubject() === self::SUBJECT_UPDATE) { + $subject = $l->t('{actor} updated address book {addressbook}'); + } elseif ($event->getSubject() === self::SUBJECT_UPDATE . '_self') { + $subject = $l->t('You updated address book {addressbook}'); + } elseif ($event->getSubject() === self::SUBJECT_SHARE_USER) { + $subject = $l->t('{actor} shared address book {addressbook} with you'); + } elseif ($event->getSubject() === self::SUBJECT_SHARE_USER . '_you') { + $subject = $l->t('You shared address book {addressbook} with {user}'); + } elseif ($event->getSubject() === self::SUBJECT_SHARE_USER . '_by') { + $subject = $l->t('{actor} shared address book {addressbook} with {user}'); + } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_USER) { + $subject = $l->t('{actor} unshared address book {addressbook} from you'); + } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_you') { + $subject = $l->t('You unshared address book {addressbook} from {user}'); + } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_by') { + $subject = $l->t('{actor} unshared address book {addressbook} from {user}'); + } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_self') { + $subject = $l->t('{actor} unshared address book {addressbook} from themselves'); + } elseif ($event->getSubject() === self::SUBJECT_SHARE_GROUP . '_you') { + $subject = $l->t('You shared address book {addressbook} with group {group}'); + } elseif ($event->getSubject() === self::SUBJECT_SHARE_GROUP . '_by') { + $subject = $l->t('{actor} shared address book {addressbook} with group {group}'); + } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_GROUP . '_you') { + $subject = $l->t('You unshared address book {addressbook} from group {group}'); + } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_GROUP . '_by') { + $subject = $l->t('{actor} unshared address book {addressbook} from group {group}'); + } else { + throw new UnknownActivityException(); + } + + $parsedParameters = $this->getParameters($event, $l); + $this->setSubjects($event, $subject, $parsedParameters); + + $event = $this->eventMerger->mergeEvents('addressbook', $event, $previousEvent); + + if ($event->getChildEvent() === null) { + if (isset($parsedParameters['user'])) { + // Couldn't group by calendar, maybe we can group by users + $event = $this->eventMerger->mergeEvents('user', $event, $previousEvent); + } elseif (isset($parsedParameters['group'])) { + // Couldn't group by calendar, maybe we can group by groups + $event = $this->eventMerger->mergeEvents('group', $event, $previousEvent); + } + } + + return $event; + } + + protected function getParameters(IEvent $event, IL10N $l): array { + $subject = $event->getSubject(); + $parameters = $event->getSubjectParameters(); + + switch ($subject) { + case self::SUBJECT_ADD: + case self::SUBJECT_ADD . '_self': + case self::SUBJECT_DELETE: + case self::SUBJECT_DELETE . '_self': + case self::SUBJECT_UPDATE: + case self::SUBJECT_UPDATE . '_self': + case self::SUBJECT_SHARE_USER: + case self::SUBJECT_UNSHARE_USER: + case self::SUBJECT_UNSHARE_USER . '_self': + return [ + 'actor' => $this->generateUserParameter($parameters['actor']), + 'addressbook' => $this->generateAddressbookParameter($parameters['addressbook'], $l), + ]; + case self::SUBJECT_SHARE_USER . '_you': + case self::SUBJECT_UNSHARE_USER . '_you': + return [ + 'addressbook' => $this->generateAddressbookParameter($parameters['addressbook'], $l), + 'user' => $this->generateUserParameter($parameters['user']), + ]; + case self::SUBJECT_SHARE_USER . '_by': + case self::SUBJECT_UNSHARE_USER . '_by': + return [ + 'actor' => $this->generateUserParameter($parameters['actor']), + 'addressbook' => $this->generateAddressbookParameter($parameters['addressbook'], $l), + 'user' => $this->generateUserParameter($parameters['user']), + ]; + case self::SUBJECT_SHARE_GROUP . '_you': + case self::SUBJECT_UNSHARE_GROUP . '_you': + return [ + 'addressbook' => $this->generateAddressbookParameter($parameters['addressbook'], $l), + 'group' => $this->generateGroupParameter($parameters['group']), + ]; + case self::SUBJECT_SHARE_GROUP . '_by': + case self::SUBJECT_UNSHARE_GROUP . '_by': + return [ + 'actor' => $this->generateUserParameter($parameters['actor']), + 'addressbook' => $this->generateAddressbookParameter($parameters['addressbook'], $l), + 'group' => $this->generateGroupParameter($parameters['group']), + ]; + } + + throw new \InvalidArgumentException(); + } +} diff --git a/apps/dav/lib/CardDAV/Activity/Provider/Base.php b/apps/dav/lib/CardDAV/Activity/Provider/Base.php new file mode 100644 index 00000000000..ea7680aed60 --- /dev/null +++ b/apps/dav/lib/CardDAV/Activity/Provider/Base.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CardDAV\Activity\Provider; + +use OCA\DAV\CardDAV\CardDavBackend; +use OCP\Activity\IEvent; +use OCP\Activity\IProvider; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUserManager; + +abstract class Base implements IProvider { + /** @var string[] */ + protected $userDisplayNames = []; + + /** @var string[] */ + protected $groupDisplayNames = []; + + public function __construct( + protected IUserManager $userManager, + protected IGroupManager $groupManager, + protected IURLGenerator $url, + ) { + } + + protected function setSubjects(IEvent $event, string $subject, array $parameters): void { + $event->setRichSubject($subject, $parameters); + } + + /** + * @param array $data + * @param IL10N $l + * @return array + */ + protected function generateAddressbookParameter(array $data, IL10N $l): array { + if ($data['uri'] === CardDavBackend::PERSONAL_ADDRESSBOOK_URI + && $data['name'] === CardDavBackend::PERSONAL_ADDRESSBOOK_NAME) { + return [ + 'type' => 'addressbook', + 'id' => (string)$data['id'], + 'name' => $l->t('Personal'), + ]; + } + + return [ + 'type' => 'addressbook', + 'id' => (string)$data['id'], + 'name' => $data['name'], + ]; + } + + protected function generateUserParameter(string $uid): array { + return [ + 'type' => 'user', + 'id' => $uid, + 'name' => $this->userManager->getDisplayName($uid) ?? $uid, + ]; + } + + /** + * @param string $gid + * @return array + */ + protected function generateGroupParameter(string $gid): array { + if (!isset($this->groupDisplayNames[$gid])) { + $this->groupDisplayNames[$gid] = $this->getGroupDisplayName($gid); + } + + return [ + 'type' => 'user-group', + 'id' => $gid, + 'name' => $this->groupDisplayNames[$gid], + ]; + } + + /** + * @param string $gid + * @return string + */ + protected function getGroupDisplayName(string $gid): string { + $group = $this->groupManager->get($gid); + if ($group instanceof IGroup) { + return $group->getDisplayName(); + } + return $gid; + } +} diff --git a/apps/dav/lib/CardDAV/Activity/Provider/Card.php b/apps/dav/lib/CardDAV/Activity/Provider/Card.php new file mode 100644 index 00000000000..acf23c00531 --- /dev/null +++ b/apps/dav/lib/CardDAV/Activity/Provider/Card.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CardDAV\Activity\Provider; + +use OCP\Activity\Exceptions\UnknownActivityException; +use OCP\Activity\IEvent; +use OCP\Activity\IEventMerger; +use OCP\Activity\IManager; +use OCP\App\IAppManager; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\L10N\IFactory; + +class Card extends Base { + public const SUBJECT_ADD = 'card_add'; + public const SUBJECT_UPDATE = 'card_update'; + public const SUBJECT_DELETE = 'card_delete'; + + public function __construct( + protected IFactory $languageFactory, + IURLGenerator $url, + protected IManager $activityManager, + IUserManager $userManager, + IGroupManager $groupManager, + protected IEventMerger $eventMerger, + protected IAppManager $appManager, + ) { + parent::__construct($userManager, $groupManager, $url); + } + + /** + * @param string $language + * @param IEvent $event + * @param IEvent|null $previousEvent + * @return IEvent + * @throws UnknownActivityException + */ + public function parse($language, IEvent $event, ?IEvent $previousEvent = null): IEvent { + if ($event->getApp() !== 'dav' || $event->getType() !== 'contacts') { + throw new UnknownActivityException(); + } + + $l = $this->languageFactory->get('dav', $language); + + if ($this->activityManager->getRequirePNG()) { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/contacts-dark.png'))); + } else { + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/contacts.svg'))); + } + + if ($event->getSubject() === self::SUBJECT_ADD) { + $subject = $l->t('{actor} created contact {card} in address book {addressbook}'); + } elseif ($event->getSubject() === self::SUBJECT_ADD . '_self') { + $subject = $l->t('You created contact {card} in address book {addressbook}'); + } elseif ($event->getSubject() === self::SUBJECT_DELETE) { + $subject = $l->t('{actor} deleted contact {card} from address book {addressbook}'); + } elseif ($event->getSubject() === self::SUBJECT_DELETE . '_self') { + $subject = $l->t('You deleted contact {card} from address book {addressbook}'); + } elseif ($event->getSubject() === self::SUBJECT_UPDATE) { + $subject = $l->t('{actor} updated contact {card} in address book {addressbook}'); + } elseif ($event->getSubject() === self::SUBJECT_UPDATE . '_self') { + $subject = $l->t('You updated contact {card} in address book {addressbook}'); + } else { + throw new UnknownActivityException(); + } + + $parsedParameters = $this->getParameters($event, $l); + $this->setSubjects($event, $subject, $parsedParameters); + + $event = $this->eventMerger->mergeEvents('card', $event, $previousEvent); + return $event; + } + + protected function getParameters(IEvent $event, IL10N $l): array { + $subject = $event->getSubject(); + $parameters = $event->getSubjectParameters(); + + switch ($subject) { + case self::SUBJECT_ADD: + case self::SUBJECT_DELETE: + case self::SUBJECT_UPDATE: + return [ + 'actor' => $this->generateUserParameter($parameters['actor']), + 'addressbook' => $this->generateAddressbookParameter($parameters['addressbook'], $l), + 'card' => $this->generateCardParameter($parameters['card']), + ]; + case self::SUBJECT_ADD . '_self': + case self::SUBJECT_DELETE . '_self': + case self::SUBJECT_UPDATE . '_self': + return [ + 'addressbook' => $this->generateAddressbookParameter($parameters['addressbook'], $l), + 'card' => $this->generateCardParameter($parameters['card']), + ]; + } + + throw new \InvalidArgumentException(); + } + + private function generateCardParameter(array $cardData): array { + return [ + 'type' => 'addressbook-contact', + 'id' => $cardData['id'], + 'name' => $cardData['name'], + ]; + } +} diff --git a/apps/dav/lib/CardDAV/Activity/Setting.php b/apps/dav/lib/CardDAV/Activity/Setting.php new file mode 100644 index 00000000000..cc68cf87c83 --- /dev/null +++ b/apps/dav/lib/CardDAV/Activity/Setting.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CardDAV\Activity; + +use OCA\DAV\CalDAV\Activity\Setting\CalDAVSetting; + +class Setting extends CalDAVSetting { + /** + * @return string Lowercase a-z and underscore only identifier + */ + public function getIdentifier(): string { + return 'contacts'; + } + + /** + * @return string A translated string + */ + public function getName(): string { + return $this->l->t('A <strong>contact</strong> or <strong>address book</strong> was modified'); + } + + /** + * @return int whether the filter should be rather on the top or bottom of + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + */ + public function getPriority(): int { + return 50; + } + + /** + * @return bool True when the option can be changed for the stream + */ + public function canChangeStream(): bool { + return true; + } + + /** + * @return bool True when the option can be changed for the stream + */ + public function isDefaultEnabledStream(): bool { + return true; + } + + /** + * @return bool True when the option can be changed for the mail + */ + public function canChangeMail(): bool { + return true; + } + + /** + * @return bool True when the option can be changed for the stream + */ + public function isDefaultEnabledMail(): bool { + return false; + } +} diff --git a/apps/dav/lib/CardDAV/AddressBook.php b/apps/dav/lib/CardDAV/AddressBook.php index 0078dece854..4d30d507a7d 100644 --- a/apps/dav/lib/CardDAV/AddressBook.php +++ b/apps/dav/lib/CardDAV/AddressBook.php @@ -1,48 +1,31 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CardDAV; use OCA\DAV\DAV\Sharing\IShareable; -use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException; +use OCP\DB\Exception; use OCP\IL10N; +use OCP\Server; +use Psr\Log\LoggerInterface; use Sabre\CardDAV\Backend\BackendInterface; -use Sabre\CardDAV\Card; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\IMoveTarget; +use Sabre\DAV\INode; use Sabre\DAV\PropPatch; /** * Class AddressBook * * @package OCA\DAV\CardDAV - * @property BackendInterface|CardDavBackend $carddavBackend + * @property CardDavBackend $carddavBackend */ -class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { - +class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable, IMoveTarget { /** * AddressBook constructor. * @@ -53,8 +36,9 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { public function __construct(BackendInterface $carddavBackend, array $addressBookInfo, IL10N $l10n) { 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'); } } @@ -68,17 +52,15 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { * Every element in the add array has the following properties: * * href - A url. Usually a mailto: address * * commonName - Usually a first and last name, or false - * * summary - A description of the share, can also be false * * readOnly - A boolean value * * Every element in the remove array is just the address string. * - * @param array $add - * @param array $remove - * @return void + * @param list<array{href: string, commonName: string, readOnly: bool}> $add + * @param list<string> $remove * @throws Forbidden */ - public function updateShares(array $add, array $remove) { + public function updateShares(array $add, array $remove): void { if ($this->isShared()) { throw new Forbidden(); } @@ -93,11 +75,10 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { * * commonName - Optional, for example a first + last name * * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants. * * readOnly - boolean - * * summary - Optional, a description for the share * - * @return array + * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> */ - public function getShares() { + public function getShares(): array { if ($this->isShared()) { return []; } @@ -114,7 +95,12 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { 'privilege' => '{DAV:}write', 'principal' => $this->getOwner(), 'protected' => true, - ] + ], + [ + 'privilege' => '{DAV:}write-properties', + 'principal' => $this->getOwner(), + 'protected' => true, + ], ]; if ($this->getOwner() === 'principals/system/system') { @@ -123,6 +109,11 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { 'principal' => '{DAV:}authenticated', 'protected' => true, ]; + $acl[] = [ + 'privilege' => '{DAV:}write-properties', + 'principal' => '{DAV:}authenticated', + 'protected' => true, + ]; } if (!$this->isShared()) { @@ -145,7 +136,7 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { } $acl = $this->carddavBackend->applyShareAcl($this->getResourceId(), $acl); - $allowedPrincipals = [$this->getOwner(), parent::getOwner(), 'principals/system/system']; + $allowedPrincipals = [$this->getOwner(), parent::getOwner(), 'principals/system/system', '{DAV:}authenticated']; return array_filter($acl, function ($rule) use ($allowedPrincipals) { return \in_array($rule['principal'], $allowedPrincipals, true); }); @@ -164,14 +155,33 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { return new Card($this->carddavBackend, $this->addressBookInfo, $obj); } - /** - * @return int - */ - public function getResourceId() { + public function getChildren() { + $objs = $this->carddavBackend->getCards($this->addressBookInfo['id']); + $children = []; + foreach ($objs as $obj) { + $obj['acl'] = $this->getChildACL(); + $children[] = new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + + return $children; + } + + public function getMultipleChildren(array $paths) { + $objs = $this->carddavBackend->getMultipleCards($this->addressBookInfo['id'], $paths); + $children = []; + foreach ($objs as $obj) { + $obj['acl'] = $this->getChildACL(); + $children[] = new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + + return $children; + } + + public function getResourceId(): int { return $this->addressBookInfo['id']; } - public function getOwner() { + public function getOwner(): ?string { if (isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { return $this->addressBookInfo['{http://owncloud.org/ns}owner-principal']; } @@ -198,17 +208,16 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { } public function propPatch(PropPatch $propPatch) { - if (isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { - throw new Forbidden(); + if (!isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { + parent::propPatch($propPatch); } - parent::propPatch($propPatch); } public function getContactsGroups() { return $this->carddavBackend->collectCardProperties($this->getResourceId(), 'CATEGORIES'); } - private function isShared() { + private function isShared(): bool { if (!isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { return false; } @@ -216,7 +225,7 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { return $this->addressBookInfo['{http://owncloud.org/ns}owner-principal'] !== $this->addressBookInfo['principaluri']; } - private function canWrite() { + private function canWrite(): bool { if (isset($this->addressBookInfo['{http://owncloud.org/ns}read-only'])) { return !$this->addressBookInfo['{http://owncloud.org/ns}read-only']; } @@ -224,10 +233,29 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { } public function getChanges($syncToken, $syncLevel, $limit = null) { - if (!$syncToken && $limit) { - throw new UnsupportedLimitOnInitialSyncException(); - } return parent::getChanges($syncToken, $syncLevel, $limit); } + + /** + * @inheritDoc + */ + public function moveInto($targetName, $sourcePath, INode $sourceNode) { + if (!($sourceNode instanceof Card)) { + return false; + } + + try { + return $this->carddavBackend->moveCard( + $sourceNode->getAddressbookId(), + $sourceNode->getUri(), + $this->getResourceId(), + $targetName, + ); + } catch (Exception $e) { + // Avoid injecting LoggerInterface everywhere + Server::get(LoggerInterface::class)->error('Could not move calendar object: ' . $e->getMessage(), ['exception' => $e]); + return false; + } + } } diff --git a/apps/dav/lib/CardDAV/AddressBookImpl.php b/apps/dav/lib/CardDAV/AddressBookImpl.php index a2895fed34a..ae77498539b 100644 --- a/apps/dav/lib/CardDAV/AddressBookImpl.php +++ b/apps/dav/lib/CardDAV/AddressBookImpl.php @@ -1,57 +1,22 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arne Hamann <kontakt+github@arne.email> - * @author Björn Schießle <bjoern@schiessle.org> - * @author call-me-matt <nextcloud@matthiasheinisch.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CardDAV; +use OCA\DAV\Db\PropertyMapper; use OCP\Constants; -use OCP\IAddressBook; +use OCP\IAddressBookEnabled; use OCP\IURLGenerator; use Sabre\VObject\Component\VCard; use Sabre\VObject\Property; use Sabre\VObject\Reader; use Sabre\VObject\UUIDUtil; -class AddressBookImpl implements IAddressBook { - - /** @var CardDavBackend */ - private $backend; - - /** @var array */ - private $addressBookInfo; - - /** @var AddressBook */ - private $addressBook; - - /** @var IURLGenerator */ - private $urlGenerator; +class AddressBookImpl implements IAddressBookEnabled { /** * AddressBookImpl constructor. @@ -62,14 +27,13 @@ class AddressBookImpl implements IAddressBook { * @param IUrlGenerator $urlGenerator */ public function __construct( - AddressBook $addressBook, - array $addressBookInfo, - CardDavBackend $backend, - IURLGenerator $urlGenerator) { - $this->addressBook = $addressBook; - $this->addressBookInfo = $addressBookInfo; - $this->backend = $backend; - $this->urlGenerator = $urlGenerator; + private AddressBook $addressBook, + private array $addressBookInfo, + private CardDavBackend $backend, + private IURLGenerator $urlGenerator, + private PropertyMapper $propertyMapper, + private ?string $userId, + ) { } /** @@ -102,17 +66,19 @@ class AddressBookImpl implements IAddressBook { * @param string $pattern which should match within the $searchProperties * @param array $searchProperties defines the properties within the query pattern should match * @param array $options Options to define the output format and search behavior - * - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array - * example: ['id' => 5, 'FN' => 'Thomas Tanghus', 'EMAIL' => ['type => 'HOME', 'value' => 'g@h.i']] - * - 'escape_like_param' - If set to false wildcards _ and % are not escaped - * - 'limit' - Set a numeric limit for the search results - * - 'offset' - Set the offset for the limited search results + * - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array + * example: ['id' => 5, 'FN' => 'Thomas Tanghus', 'EMAIL' => ['type => 'HOME', 'value' => 'g@h.i']] + * - 'escape_like_param' - If set to false wildcards _ and % are not escaped + * - 'limit' - Set a numeric limit for the search results + * - 'offset' - Set the offset for the limited search results + * - 'wildcard' - Whether the search should use wildcards + * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options * @return array an array of contacts which are arrays of key-value-pairs - * example result: - * [ - * ['id' => 0, 'FN' => 'Thomas Müller', 'EMAIL' => 'a@b.c', 'GEO' => '37.386013;-122.082932'], - * ['id' => 5, 'FN' => 'Thomas Tanghus', 'EMAIL' => ['d@e.f', 'g@h.i']] - * ] + * example result: + * [ + * ['id' => 0, 'FN' => 'Thomas Müller', 'EMAIL' => 'a@b.c', 'GEO' => '37.386013;-122.082932'], + * ['id' => 5, 'FN' => 'Thomas Tanghus', 'EMAIL' => ['d@e.f', 'g@h.i']] + * ] * @since 5.0.0 */ public function search($pattern, $searchProperties, $options) { @@ -150,13 +116,17 @@ class AddressBookImpl implements IAddressBook { if (is_array($value)) { $vCard->remove($key); foreach ($value as $entry) { - if (($key === "ADR" || $key === "PHOTO") && is_string($entry["value"])) { - $entry["value"] = stripslashes($entry["value"]); - $entry["value"] = explode(';', $entry["value"]); - } - $property = $vCard->createProperty($key, $entry["value"]); - if (isset($entry["type"])) { - $property->add('TYPE', $entry["type"]); + if (is_string($entry)) { + $property = $vCard->createProperty($key, $entry); + } else { + if (($key === 'ADR' || $key === 'PHOTO') && is_string($entry['value'])) { + $entry['value'] = stripslashes($entry['value']); + $entry['value'] = explode(';', $entry['value']); + } + $property = $vCard->createProperty($key, $entry['value']); + if (isset($entry['type'])) { + $property->add('TYPE', $entry['type']); + } } $vCard->add($property); } @@ -182,6 +152,10 @@ class AddressBookImpl implements IAddressBook { $permissions = $this->addressBook->getACL(); $result = 0; foreach ($permissions as $permission) { + if ($this->addressBookInfo['principaluri'] !== $permission['principal']) { + continue; + } + switch ($permission['privilege']) { case '{DAV:}read': $result |= Constants::PERMISSION_READ; @@ -200,7 +174,7 @@ class AddressBookImpl implements IAddressBook { } /** - * @param object $id the unique identifier to a contact + * @param int $id the unique identifier to a contact * @return bool successful or not * @since 5.0.0 */ @@ -266,7 +240,7 @@ class AddressBookImpl implements IAddressBook { ]; foreach ($vCard->children() as $property) { - if ($property->name === 'PHOTO' && $property->getValueType() === 'BINARY') { + if ($property->name === 'PHOTO' && in_array($property->getValueType(), ['BINARY', 'URI'])) { $url = $this->urlGenerator->getAbsoluteURL( $this->urlGenerator->linkTo('', 'remote.php') . '/dav/'); $url .= implode('/', [ @@ -337,8 +311,29 @@ class AddressBookImpl implements IAddressBook { */ 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() ); } + + public function isEnabled(): bool { + if (!$this->userId) { + return true; + } + + if ($this->isSystemAddressBook()) { + $user = $this->userId ; + $uri = 'z-server-generated--system'; + } else { + $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) { + return (bool)$properties[0]->getPropertyvalue(); + } + return true; + } } diff --git a/apps/dav/lib/CardDAV/AddressBookRoot.php b/apps/dav/lib/CardDAV/AddressBookRoot.php index 57fc6b71010..5679a03545e 100644 --- a/apps/dav/lib/CardDAV/AddressBookRoot.php +++ b/apps/dav/lib/CardDAV/AddressBookRoot.php @@ -1,48 +1,32 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CardDAV; use OCA\DAV\AppInfo\PluginManager; +use OCP\IGroupManager; +use OCP\IUser; class AddressBookRoot extends \Sabre\CardDAV\AddressBookRoot { - /** @var PluginManager */ - private $pluginManager; - /** * @param \Sabre\DAVACL\PrincipalBackend\BackendInterface $principalBackend * @param \Sabre\CardDAV\Backend\BackendInterface $carddavBackend * @param string $principalPrefix */ - public function __construct(\Sabre\DAVACL\PrincipalBackend\BackendInterface $principalBackend, - \Sabre\CardDAV\Backend\BackendInterface $carddavBackend, - PluginManager $pluginManager, - $principalPrefix = 'principals') { + public function __construct( + \Sabre\DAVACL\PrincipalBackend\BackendInterface $principalBackend, + \Sabre\CardDAV\Backend\BackendInterface $carddavBackend, + private PluginManager $pluginManager, + private ?IUser $user, + private ?IGroupManager $groupManager, + string $principalPrefix = 'principals', + ) { parent::__construct($principalBackend, $carddavBackend, $principalPrefix); - $this->pluginManager = $pluginManager; } /** @@ -57,7 +41,7 @@ class AddressBookRoot extends \Sabre\CardDAV\AddressBookRoot { * @return \Sabre\DAV\INode */ public function getChildForPrincipal(array $principal) { - return new UserAddressBooks($this->carddavBackend, $principal['uri'], $this->pluginManager); + return new UserAddressBooks($this->carddavBackend, $principal['uri'], $this->pluginManager, $this->user, $this->groupManager); } public function getName() { diff --git a/apps/dav/lib/CardDAV/Card.php b/apps/dav/lib/CardDAV/Card.php new file mode 100644 index 00000000000..8cd4fd7e5ee --- /dev/null +++ b/apps/dav/lib/CardDAV/Card.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\CardDAV; + +class Card extends \Sabre\CardDAV\Card { + public function getId(): int { + return (int)$this->cardData['id']; + } + + public function getUri(): string { + return $this->cardData['uri']; + } + + protected function isShared(): bool { + if (!isset($this->cardData['{http://owncloud.org/ns}owner-principal'])) { + return false; + } + + return $this->cardData['{http://owncloud.org/ns}owner-principal'] !== $this->cardData['principaluri']; + } + + public function getAddressbookId(): int { + return (int)$this->cardData['addressbookid']; + } + + public function getPrincipalUri(): string { + return $this->addressBookInfo['principaluri']; + } + + public function getOwner(): ?string { + if (isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { + return $this->addressBookInfo['{http://owncloud.org/ns}owner-principal']; + } + return parent::getOwner(); + } +} diff --git a/apps/dav/lib/CardDAV/CardDavBackend.php b/apps/dav/lib/CardDAV/CardDavBackend.php index a26c6c24a8e..a78686eb61d 100644 --- a/apps/dav/lib/CardDAV/CardDavBackend.php +++ b/apps/dav/lib/CardDAV/CardDavBackend.php @@ -1,40 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arne Hamann <kontakt+github@arne.email> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> - * @author matt <34400929+call-me-matt@users.noreply.github.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Stefan Weil <sw@weilnetz.de> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CardDAV; +use OC\Search\Filter\DateTimeFilter; use OCA\DAV\Connector\Sabre\Principal; use OCA\DAV\DAV\Sharing\Backend; use OCA\DAV\DAV\Sharing\IShareable; @@ -44,12 +17,14 @@ use OCA\DAV\Events\AddressBookShareUpdatedEvent; use OCA\DAV\Events\AddressBookUpdatedEvent; use OCA\DAV\Events\CardCreatedEvent; use OCA\DAV\Events\CardDeletedEvent; +use OCA\DAV\Events\CardMovedEvent; use OCA\DAV\Events\CardUpdatedEvent; +use OCP\AppFramework\Db\TTransactional; +use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; use OCP\IDBConnection; -use OCP\IGroupManager; -use OCP\IUser; use OCP\IUserManager; use PDO; use Sabre\CardDAV\Backend\BackendInterface; @@ -58,30 +33,17 @@ use Sabre\CardDAV\Plugin; use Sabre\DAV\Exception\BadRequest; use Sabre\VObject\Component\VCard; use Sabre\VObject\Reader; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\EventDispatcher\GenericEvent; class CardDavBackend implements BackendInterface, SyncSupport { + use TTransactional; public const PERSONAL_ADDRESSBOOK_URI = 'contacts'; public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts'; - /** @var Principal */ - private $principalBackend; - - /** @var string */ - private $dbCardsTable = 'cards'; - - /** @var string */ - private $dbCardsPropertiesTable = 'cards_properties'; - - /** @var IDBConnection */ - private $db; - - /** @var Backend */ - private $sharingBackend; + private string $dbCardsTable = 'cards'; + private string $dbCardsPropertiesTable = 'cards_properties'; /** @var array properties to index */ - public static $indexProperties = [ + public static array $indexProperties = [ 'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME', 'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO', 'CLOUD', 'X-SOCIALPROFILE']; @@ -89,41 +51,17 @@ class CardDavBackend implements BackendInterface, SyncSupport { /** * @var string[] Map of uid => display name */ - protected $userDisplayNames; - - /** @var IUserManager */ - private $userManager; - - /** @var IEventDispatcher */ - private $dispatcher; - - /** @var EventDispatcherInterface */ - private $legacyDispatcher; - - private $etagCache = []; - - /** - * CardDavBackend constructor. - * - * @param IDBConnection $db - * @param Principal $principalBackend - * @param IUserManager $userManager - * @param IGroupManager $groupManager - * @param IEventDispatcher $dispatcher - * @param EventDispatcherInterface $legacyDispatcher - */ - public function __construct(IDBConnection $db, - Principal $principalBackend, - IUserManager $userManager, - IGroupManager $groupManager, - IEventDispatcher $dispatcher, - EventDispatcherInterface $legacyDispatcher) { - $this->db = $db; - $this->principalBackend = $principalBackend; - $this->userManager = $userManager; - $this->dispatcher = $dispatcher; - $this->legacyDispatcher = $legacyDispatcher; - $this->sharingBackend = new Backend($this->db, $this->userManager, $groupManager, $principalBackend, 'addressbook'); + protected array $userDisplayNames; + private array $etagCache = []; + + public function __construct( + private IDBConnection $db, + private Principal $principalBackend, + private IUserManager $userManager, + private IEventDispatcher $dispatcher, + private Sharing\Backend $sharingBackend, + private IConfig $config, + ) { } /** @@ -139,8 +77,8 @@ class CardDavBackend implements BackendInterface, SyncSupport { ->from('addressbooks') ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); - $result = $query->execute(); - $column = (int) $result->fetchOne(); + $result = $query->executeQuery(); + $column = (int)$result->fetchOne(); $result->closeCursor(); return $column; } @@ -163,87 +101,95 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @return array */ public function getAddressBooksForUser($principalUri) { - $principalUriOriginal = $principalUri; - $principalUri = $this->convertPrincipal($principalUri, true); - $query = $this->db->getQueryBuilder(); - $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken']) - ->from('addressbooks') - ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); + return $this->atomic(function () use ($principalUri) { + $principalUriOriginal = $principalUri; + $principalUri = $this->convertPrincipal($principalUri, true); + $select = $this->db->getQueryBuilder(); + $select->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken']) + ->from('addressbooks') + ->where($select->expr()->eq('principaluri', $select->createNamedParameter($principalUri))); - $addressBooks = []; + $addressBooks = []; - $result = $query->execute(); - while ($row = $result->fetch()) { - $addressBooks[$row['id']] = [ - 'id' => $row['id'], - 'uri' => $row['uri'], - 'principaluri' => $this->convertPrincipal($row['principaluri'], false), - '{DAV:}displayname' => $row['displayname'], - '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], - '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], - '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', - ]; + $result = $select->executeQuery(); + while ($row = $result->fetch()) { + $addressBooks[$row['id']] = [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'principaluri' => $this->convertPrincipal($row['principaluri'], false), + '{DAV:}displayname' => $row['displayname'], + '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], + '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', + ]; + + $this->addOwnerPrincipal($addressBooks[$row['id']]); + } + $result->closeCursor(); - $this->addOwnerPrincipal($addressBooks[$row['id']]); - } - $result->closeCursor(); + // query for shared addressbooks + $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true); - // query for shared addressbooks - $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true); - $principals = array_merge($principals, $this->principalBackend->getCircleMembership($principalUriOriginal)); + $principals[] = $principalUri; - $principals[] = $principalUri; + $select = $this->db->getQueryBuilder(); + $subSelect = $this->db->getQueryBuilder(); - $query = $this->db->getQueryBuilder(); - $result = $query->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access']) - ->from('dav_shares', 's') - ->join('s', 'addressbooks', 'a', $query->expr()->eq('s.resourceid', 'a.id')) - ->where($query->expr()->in('s.principaluri', $query->createParameter('principaluri'))) - ->andWhere($query->expr()->eq('s.type', $query->createParameter('type'))) - ->setParameter('type', 'addressbook') - ->setParameter('principaluri', $principals, IQueryBuilder::PARAM_STR_ARRAY) - ->execute(); - - $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'; - while ($row = $result->fetch()) { - if ($row['principaluri'] === $principalUri) { - continue; - } + $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)); - $readOnly = (int)$row['access'] === Backend::ACCESS_READ; - if (isset($addressBooks[$row['id']])) { - if ($readOnly) { - // New share can not have more permissions then the old one. - continue; - } - if (isset($addressBooks[$row['id']][$readOnlyPropertyName]) && - $addressBooks[$row['id']][$readOnlyPropertyName] === 0) { - // Old share is already read-write, no more permissions can be gained + + $select->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access']) + ->from('dav_shares', 's') + ->join('s', 'addressbooks', 'a', $select->expr()->eq('s.resourceid', 'a.id')) + ->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('addressbook', IQueryBuilder::PARAM_STR))) + ->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY)); + $result = $select->executeQuery(); + + $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'; + while ($row = $result->fetch()) { + if ($row['principaluri'] === $principalUri) { continue; } - } - list(, $name) = \Sabre\Uri\split($row['principaluri']); - $uri = $row['uri'] . '_shared_by_' . $name; - $displayName = $row['displayname'] . ' (' . $this->getUserDisplayName($name) . ')'; - - $addressBooks[$row['id']] = [ - 'id' => $row['id'], - 'uri' => $uri, - 'principaluri' => $principalUriOriginal, - '{DAV:}displayname' => $displayName, - '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], - '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], - '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', - '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'], - $readOnlyPropertyName => $readOnly, - ]; + $readOnly = (int)$row['access'] === Backend::ACCESS_READ; + if (isset($addressBooks[$row['id']])) { + if ($readOnly) { + // New share can not have more permissions then the old one. + continue; + } + if (isset($addressBooks[$row['id']][$readOnlyPropertyName]) + && $addressBooks[$row['id']][$readOnlyPropertyName] === 0) { + // Old share is already read-write, no more permissions can be gained + continue; + } + } - $this->addOwnerPrincipal($addressBooks[$row['id']]); - } - $result->closeCursor(); + [, $name] = \Sabre\Uri\split($row['principaluri']); + $uri = $row['uri'] . '_shared_by_' . $name; + $displayName = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? $name ?? '') . ')'; + + $addressBooks[$row['id']] = [ + 'id' => $row['id'], + 'uri' => $uri, + 'principaluri' => $principalUriOriginal, + '{DAV:}displayname' => $displayName, + '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], + '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'], + $readOnlyPropertyName => $readOnly, + ]; + + $this->addOwnerPrincipal($addressBooks[$row['id']]); + } + $result->closeCursor(); - return array_values($addressBooks); + return array_values($addressBooks); + }, $this->db); } public function getUsersOwnAddressBooks($principalUri) { @@ -255,7 +201,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { $addressBooks = []; - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { $addressBooks[$row['id']] = [ 'id' => $row['id'], @@ -274,33 +220,18 @@ class CardDavBackend implements BackendInterface, SyncSupport { return array_values($addressBooks); } - private function getUserDisplayName($uid) { - if (!isset($this->userDisplayNames[$uid])) { - $user = $this->userManager->get($uid); - - if ($user instanceof IUser) { - $this->userDisplayNames[$uid] = $user->getDisplayName(); - } else { - $this->userDisplayNames[$uid] = $uid; - } - } - - return $this->userDisplayNames[$uid]; - } - /** * @param int $addressBookId */ - public function getAddressBookById($addressBookId) { + public function getAddressBookById(int $addressBookId): ?array { $query = $this->db->getQueryBuilder(); $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken']) ->from('addressbooks') - ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))) - ->execute(); - + ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT))) + ->executeQuery(); $row = $result->fetch(); $result->closeCursor(); - if ($row === false) { + if (!$row) { return null; } @@ -319,18 +250,14 @@ class CardDavBackend implements BackendInterface, SyncSupport { return $addressBook; } - /** - * @param $addressBookUri - * @return array|null - */ - public function getAddressBooksByUri($principal, $addressBookUri) { + public function getAddressBooksByUri(string $principal, string $addressBookUri): ?array { $query = $this->db->getQueryBuilder(); $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken']) ->from('addressbooks') ->where($query->expr()->eq('uri', $query->createNamedParameter($addressBookUri))) ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal))) ->setMaxResults(1) - ->execute(); + ->executeQuery(); $row = $result->fetch(); $result->closeCursor(); @@ -346,8 +273,15 @@ class CardDavBackend implements BackendInterface, SyncSupport { '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', + ]; + // system address books are always read only + if ($principal === 'principals/system/system') { + $addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] = $row['principaluri']; + $addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'] = true; + } + $this->addOwnerPrincipal($addressBook); return $addressBook; @@ -387,19 +321,23 @@ class CardDavBackend implements BackendInterface, SyncSupport { break; } } - $query = $this->db->getQueryBuilder(); - $query->update('addressbooks'); + [$addressBookRow, $shares] = $this->atomic(function () use ($addressBookId, $updates) { + $query = $this->db->getQueryBuilder(); + $query->update('addressbooks'); - foreach ($updates as $key => $value) { - $query->set($key, $query->createNamedParameter($value)); - } - $query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))) - ->execute(); + foreach ($updates as $key => $value) { + $query->set($key, $query->createNamedParameter($value)); + } + $query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))) + ->executeStatement(); - $this->addChange($addressBookId, "", 2); + $this->addChange($addressBookId, '', 2); + + $addressBookRow = $this->getAddressBookById((int)$addressBookId); + $shares = $this->getShares((int)$addressBookId); + return [$addressBookRow, $shares]; + }, $this->db); - $addressBookRow = $this->getAddressBookById((int)$addressBookId); - $shares = $this->getShares($addressBookId); $this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations)); return true; @@ -414,8 +352,13 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @param array $properties * @return int * @throws BadRequest + * @throws Exception */ public function createAddressBook($principalUri, $url, array $properties) { + if (strlen($url) > 255) { + throw new BadRequest('URI too long. Address book not created'); + } + $values = [ 'displayname' => null, 'description' => null, @@ -443,21 +386,27 @@ class CardDavBackend implements BackendInterface, SyncSupport { $values['displayname'] = $url; } - $query = $this->db->getQueryBuilder(); - $query->insert('addressbooks') - ->values([ - 'uri' => $query->createParameter('uri'), - 'displayname' => $query->createParameter('displayname'), - 'description' => $query->createParameter('description'), - 'principaluri' => $query->createParameter('principaluri'), - 'synctoken' => $query->createParameter('synctoken'), - ]) - ->setParameters($values) - ->execute(); - - $addressBookId = $query->getLastInsertId(); - $addressBookRow = $this->getAddressBookById($addressBookId); - $this->dispatcher->dispatchTyped(new AddressBookCreatedEvent((int)$addressBookId, $addressBookRow)); + [$addressBookId, $addressBookRow] = $this->atomic(function () use ($values) { + $query = $this->db->getQueryBuilder(); + $query->insert('addressbooks') + ->values([ + 'uri' => $query->createParameter('uri'), + 'displayname' => $query->createParameter('displayname'), + 'description' => $query->createParameter('description'), + 'principaluri' => $query->createParameter('principaluri'), + 'synctoken' => $query->createParameter('synctoken'), + ]) + ->setParameters($values) + ->executeStatement(); + + $addressBookId = $query->getLastInsertId(); + return [ + $addressBookId, + $this->getAddressBookById($addressBookId), + ]; + }, $this->db); + + $this->dispatcher->dispatchTyped(new AddressBookCreatedEvent($addressBookId, $addressBookRow)); return $addressBookId; } @@ -469,34 +418,40 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @return void */ public function deleteAddressBook($addressBookId) { - $addressBookData = $this->getAddressBookById($addressBookId); - $shares = $this->getShares($addressBookId); + $this->atomic(function () use ($addressBookId): void { + $addressBookId = (int)$addressBookId; + $addressBookData = $this->getAddressBookById($addressBookId); + $shares = $this->getShares($addressBookId); - $query = $this->db->getQueryBuilder(); - $query->delete($this->dbCardsTable) - ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid'))) - ->setParameter('addressbookid', $addressBookId) - ->execute(); + $query = $this->db->getQueryBuilder(); + $query->delete($this->dbCardsTable) + ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid'))) + ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT) + ->executeStatement(); - $query->delete('addressbookchanges') - ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid'))) - ->setParameter('addressbookid', $addressBookId) - ->execute(); + $query = $this->db->getQueryBuilder(); + $query->delete('addressbookchanges') + ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid'))) + ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT) + ->executeStatement(); - $query->delete('addressbooks') - ->where($query->expr()->eq('id', $query->createParameter('id'))) - ->setParameter('id', $addressBookId) - ->execute(); + $query = $this->db->getQueryBuilder(); + $query->delete('addressbooks') + ->where($query->expr()->eq('id', $query->createParameter('id'))) + ->setParameter('id', $addressBookId, IQueryBuilder::PARAM_INT) + ->executeStatement(); - $this->sharingBackend->deleteAllShares($addressBookId); + $this->sharingBackend->deleteAllShares($addressBookId); - $query->delete($this->dbCardsPropertiesTable) - ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) - ->execute(); + $query = $this->db->getQueryBuilder(); + $query->delete($this->dbCardsPropertiesTable) + ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); - if ($addressBookData) { - $this->dispatcher->dispatchTyped(new AddressBookDeletedEvent((int) $addressBookId, $addressBookData, $shares)); - } + if ($addressBookData) { + $this->dispatcher->dispatchTyped(new AddressBookDeletedEvent($addressBookId, $addressBookData, $shares)); + } + }, $this->db); } /** @@ -512,21 +467,21 @@ class CardDavBackend implements BackendInterface, SyncSupport { * * size - The size of the card in bytes. * * If these last two properties are provided, less time will be spent - * calculating them. If they are specified, you can also ommit carddata. + * calculating them. If they are specified, you can also omit carddata. * This may speed up certain requests, especially with large cards. * - * @param mixed $addressBookId + * @param mixed $addressbookId * @return array */ - public function getCards($addressBookId) { + public function getCards($addressbookId) { $query = $this->db->getQueryBuilder(); - $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) + $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) ->from($this->dbCardsTable) - ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); + ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId))); $cards = []; - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { $row['etag'] = '"' . $row['etag'] . '"'; @@ -557,13 +512,13 @@ class CardDavBackend implements BackendInterface, SyncSupport { */ public function getCard($addressBookId, $cardUri) { $query = $this->db->getQueryBuilder(); - $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) + $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) ->from($this->dbCardsTable) ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) ->setMaxResults(1); - $result = $query->execute(); + $result = $query->executeQuery(); $row = $result->fetch(); if (!$row) { return false; @@ -588,7 +543,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { * If the backend supports this, it may allow for some speed-ups. * * @param mixed $addressBookId - * @param string[] $uris + * @param array $uris * @return array */ public function getMultipleCards($addressBookId, array $uris) { @@ -600,14 +555,14 @@ class CardDavBackend implements BackendInterface, SyncSupport { $cards = []; $query = $this->db->getQueryBuilder(); - $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) + $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) ->from($this->dbCardsTable) ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) ->andWhere($query->expr()->in('uri', $query->createParameter('uri'))); foreach ($chunks as $uris) { $query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY); - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { $row['etag'] = '"' . $row['etag'] . '"'; @@ -648,55 +603,54 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @param mixed $addressBookId * @param string $cardUri * @param string $cardData + * @param bool $checkAlreadyExists * @return string */ - public function createCard($addressBookId, $cardUri, $cardData) { + public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) { $etag = md5($cardData); $uid = $this->getUID($cardData); + return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) { + if ($checkAlreadyExists) { + $q = $this->db->getQueryBuilder(); + $q->select('uid') + ->from($this->dbCardsTable) + ->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId))) + ->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid))) + ->setMaxResults(1); + $result = $q->executeQuery(); + $count = (bool)$result->fetchOne(); + $result->closeCursor(); + if ($count) { + throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.'); + } + } - $q = $this->db->getQueryBuilder(); - $q->select('uid') - ->from($this->dbCardsTable) - ->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId))) - ->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid))) - ->setMaxResults(1); - $result = $q->execute(); - $count = (bool)$result->fetchOne(); - $result->closeCursor(); - if ($count) { - throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.'); - } + $query = $this->db->getQueryBuilder(); + $query->insert('cards') + ->values([ + 'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB), + 'uri' => $query->createNamedParameter($cardUri), + 'lastmodified' => $query->createNamedParameter(time()), + 'addressbookid' => $query->createNamedParameter($addressBookId), + 'size' => $query->createNamedParameter(strlen($cardData)), + 'etag' => $query->createNamedParameter($etag), + 'uid' => $query->createNamedParameter($uid), + ]) + ->executeStatement(); - $query = $this->db->getQueryBuilder(); - $query->insert('cards') - ->values([ - 'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB), - 'uri' => $query->createNamedParameter($cardUri), - 'lastmodified' => $query->createNamedParameter(time()), - 'addressbookid' => $query->createNamedParameter($addressBookId), - 'size' => $query->createNamedParameter(strlen($cardData)), - 'etag' => $query->createNamedParameter($etag), - 'uid' => $query->createNamedParameter($uid), - ]) - ->execute(); - - $etagCacheKey = "$addressBookId#$cardUri"; - $this->etagCache[$etagCacheKey] = $etag; - - $this->addChange($addressBookId, $cardUri, 1); - $this->updateProperties($addressBookId, $cardUri, $cardData); - - $addressBookData = $this->getAddressBookById($addressBookId); - $shares = $this->getShares($addressBookId); - $objectRow = $this->getCard($addressBookId, $cardUri); - $this->dispatcher->dispatchTyped(new CardCreatedEvent((int)$addressBookId, $addressBookData, $shares, $objectRow)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CardDAV\CardDavBackend::createCard', - new GenericEvent(null, [ - 'addressBookId' => $addressBookId, - 'cardUri' => $cardUri, - 'cardData' => $cardData])); - - return '"' . $etag . '"'; + $etagCacheKey = "$addressBookId#$cardUri"; + $this->etagCache[$etagCacheKey] = $etag; + + $this->addChange($addressBookId, $cardUri, 1); + $this->updateProperties($addressBookId, $cardUri, $cardData); + + $addressBookData = $this->getAddressBookById($addressBookId); + $shares = $this->getShares($addressBookId); + $objectRow = $this->getCard($addressBookId, $cardUri); + $this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow)); + + return '"' . $etag . '"'; + }, $this->db); } /** @@ -727,40 +681,81 @@ class CardDavBackend implements BackendInterface, SyncSupport { public function updateCard($addressBookId, $cardUri, $cardData) { $uid = $this->getUID($cardData); $etag = md5($cardData); - $query = $this->db->getQueryBuilder(); - // check for recently stored etag and stop if it is the same - $etagCacheKey = "$addressBookId#$cardUri"; - if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) { + return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $uid, $etag) { + $query = $this->db->getQueryBuilder(); + + // check for recently stored etag and stop if it is the same + $etagCacheKey = "$addressBookId#$cardUri"; + if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) { + return '"' . $etag . '"'; + } + + $query->update($this->dbCardsTable) + ->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB)) + ->set('lastmodified', $query->createNamedParameter(time())) + ->set('size', $query->createNamedParameter(strlen($cardData))) + ->set('etag', $query->createNamedParameter($etag)) + ->set('uid', $query->createNamedParameter($uid)) + ->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) + ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) + ->executeStatement(); + + $this->etagCache[$etagCacheKey] = $etag; + + $this->addChange($addressBookId, $cardUri, 2); + $this->updateProperties($addressBookId, $cardUri, $cardData); + + $addressBookData = $this->getAddressBookById($addressBookId); + $shares = $this->getShares($addressBookId); + $objectRow = $this->getCard($addressBookId, $cardUri); + $this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow)); return '"' . $etag . '"'; - } + }, $this->db); + } - $query->update($this->dbCardsTable) - ->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB)) - ->set('lastmodified', $query->createNamedParameter(time())) - ->set('size', $query->createNamedParameter(strlen($cardData))) - ->set('etag', $query->createNamedParameter($etag)) - ->set('uid', $query->createNamedParameter($uid)) - ->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) - ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) - ->execute(); - - $this->etagCache[$etagCacheKey] = $etag; - - $this->addChange($addressBookId, $cardUri, 2); - $this->updateProperties($addressBookId, $cardUri, $cardData); - - $addressBookData = $this->getAddressBookById($addressBookId); - $shares = $this->getShares($addressBookId); - $objectRow = $this->getCard($addressBookId, $cardUri); - $this->dispatcher->dispatchTyped(new CardUpdatedEvent((int)$addressBookId, $addressBookData, $shares, $objectRow)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CardDAV\CardDavBackend::updateCard', - new GenericEvent(null, [ - 'addressBookId' => $addressBookId, - 'cardUri' => $cardUri, - 'cardData' => $cardData])); - - return '"' . $etag . '"'; + /** + * @throws Exception + */ + public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int $targetAddressBookId, string $tragetObjectUri): bool { + return $this->atomic(function () use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) { + $card = $this->getCard($sourceAddressBookId, $sourceObjectUri); + if (empty($card)) { + return false; + } + $sourceObjectId = (int)$card['id']; + + $query = $this->db->getQueryBuilder(); + $query->update('cards') + ->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT)) + ->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR)) + ->where($query->expr()->eq('uri', $query->createNamedParameter($sourceObjectUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)) + ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($sourceAddressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) + ->executeStatement(); + + $this->purgeProperties($sourceAddressBookId, $sourceObjectId); + $this->updateProperties($targetAddressBookId, $tragetObjectUri, $card['carddata']); + + $this->addChange($sourceAddressBookId, $sourceObjectUri, 3); + $this->addChange($targetAddressBookId, $tragetObjectUri, 1); + + $card = $this->getCard($targetAddressBookId, $tragetObjectUri); + // Card wasn't found - possibly because it was deleted in the meantime by a different client + if (empty($card)) { + return false; + } + $targetAddressBookRow = $this->getAddressBookById($targetAddressBookId); + // the address book this card is being moved to does not exist any longer + if (empty($targetAddressBookRow)) { + return false; + } + + $sourceShares = $this->getShares($sourceAddressBookId); + $targetShares = $this->getShares($targetAddressBookId); + $sourceAddressBookRow = $this->getAddressBookById($sourceAddressBookId); + $this->dispatcher->dispatchTyped(new CardMovedEvent($sourceAddressBookId, $sourceAddressBookRow, $targetAddressBookId, $targetAddressBookRow, $sourceShares, $targetShares, $card)); + return true; + }, $this->db); } /** @@ -771,37 +766,34 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @return bool */ public function deleteCard($addressBookId, $cardUri) { - $addressBookData = $this->getAddressBookById($addressBookId); - $shares = $this->getShares($addressBookId); - $objectRow = $this->getCard($addressBookId, $cardUri); - - try { - $cardId = $this->getCardId($addressBookId, $cardUri); - } catch (\InvalidArgumentException $e) { - $cardId = null; - } - $query = $this->db->getQueryBuilder(); - $ret = $query->delete($this->dbCardsTable) - ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) - ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) - ->execute(); + return $this->atomic(function () use ($addressBookId, $cardUri) { + $addressBookData = $this->getAddressBookById($addressBookId); + $shares = $this->getShares($addressBookId); + $objectRow = $this->getCard($addressBookId, $cardUri); - $this->addChange($addressBookId, $cardUri, 3); + try { + $cardId = $this->getCardId($addressBookId, $cardUri); + } catch (\InvalidArgumentException $e) { + $cardId = null; + } + $query = $this->db->getQueryBuilder(); + $ret = $query->delete($this->dbCardsTable) + ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) + ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) + ->executeStatement(); - if ($ret === 1) { - if ($cardId !== null) { - $this->dispatcher->dispatchTyped(new CardDeletedEvent((int)$addressBookId, $addressBookData, $shares, $objectRow)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CardDAV\CardDavBackend::deleteCard', - new GenericEvent(null, [ - 'addressBookId' => $addressBookId, - 'cardUri' => $cardUri])); + $this->addChange($addressBookId, $cardUri, 3); - $this->purgeProperties($addressBookId, $cardId); + if ($ret === 1) { + if ($cardId !== null) { + $this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow)); + $this->purgeProperties($addressBookId, $cardId); + } + return true; } - return true; - } - return false; + return false; + }, $this->db); } /** @@ -861,82 +853,147 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @return array */ public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) { + $maxLimit = $this->config->getSystemValueInt('carddav_sync_request_truncation', 2500); + $limit = ($limit === null) ? $maxLimit : min($limit, $maxLimit); // Current synctoken - $qb = $this->db->getQueryBuilder(); - $qb->select('synctoken') - ->from('addressbooks') - ->where( - $qb->expr()->eq('id', $qb->createNamedParameter($addressBookId)) - ); - $stmt = $qb->execute(); - $currentToken = $stmt->fetchOne(); - $stmt->closeCursor(); - - if (is_null($currentToken)) { - return null; - } - - $result = [ - 'syncToken' => $currentToken, - 'added' => [], - 'modified' => [], - 'deleted' => [], - ]; - - if ($syncToken) { + return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) { $qb = $this->db->getQueryBuilder(); - $qb->select('uri', 'operation') - ->from('addressbookchanges') + $qb->select('synctoken') + ->from('addressbooks') ->where( - $qb->expr()->andX( - $qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)), - $qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)), - $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)) - ) - )->orderBy('synctoken'); + $qb->expr()->eq('id', $qb->createNamedParameter($addressBookId)) + ); + $stmt = $qb->executeQuery(); + $currentToken = $stmt->fetchOne(); + $stmt->closeCursor(); - if (is_int($limit) && $limit > 0) { - $qb->setMaxResults($limit); + if (is_null($currentToken)) { + return []; } - // Fetching all changes - $stmt = $qb->execute(); + $result = [ + 'syncToken' => $currentToken, + 'added' => [], + 'modified' => [], + 'deleted' => [], + ]; + if (str_starts_with($syncToken, 'init_')) { + $syncValues = explode('_', $syncToken); + $lastID = $syncValues[1]; + $initialSyncToken = $syncValues[2]; + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'uri') + ->from('cards') + ->where( + $qb->expr()->andX( + $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)), + $qb->expr()->gt('id', $qb->createNamedParameter($lastID))) + )->orderBy('id') + ->setMaxResults($limit); + $stmt = $qb->executeQuery(); + $values = $stmt->fetchAll(\PDO::FETCH_ASSOC); + $stmt->closeCursor(); + if (count($values) === 0) { + $result['syncToken'] = $initialSyncToken; + $result['result_truncated'] = false; + $result['added'] = []; + } else { + $lastID = $values[array_key_last($values)]['id']; + $result['added'] = array_column($values, 'uri'); + $result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken ; + $result['result_truncated'] = count($result['added']) >= $limit; + } + } elseif ($syncToken) { + $qb = $this->db->getQueryBuilder(); + $qb->select('uri', 'operation', 'synctoken') + ->from('addressbookchanges') + ->where( + $qb->expr()->andX( + $qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)), + $qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)), + $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)) + ) + )->orderBy('synctoken'); + + if ($limit > 0) { + $qb->setMaxResults($limit); + } - $changes = []; + // Fetching all changes + $stmt = $qb->executeQuery(); + $rowCount = $stmt->rowCount(); - // This loop ensures that any duplicates are overwritten, only the - // last change on a node is relevant. - while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { - $changes[$row['uri']] = $row['operation']; - } - $stmt->closeCursor(); + $changes = []; + $highestSyncToken = 0; - foreach ($changes as $uri => $operation) { - switch ($operation) { - case 1: - $result['added'][] = $uri; - break; - case 2: - $result['modified'][] = $uri; - break; - case 3: - $result['deleted'][] = $uri; - break; + // This loop ensures that any duplicates are overwritten, only the + // last change on a node is relevant. + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $changes[$row['uri']] = $row['operation']; + $highestSyncToken = $row['synctoken']; } + + $stmt->closeCursor(); + + // No changes found, use current token + if (empty($changes)) { + $result['syncToken'] = $currentToken; + } + + foreach ($changes as $uri => $operation) { + switch ($operation) { + case 1: + $result['added'][] = $uri; + break; + case 2: + $result['modified'][] = $uri; + break; + case 3: + $result['deleted'][] = $uri; + break; + } + } + + /* + * The synctoken in oc_addressbooks is always the highest synctoken in oc_addressbookchanges for a given addressbook plus one (see addChange). + * + * For truncated results, it is expected that we return the highest token from the response, so the client can continue from the latest change. + * + * For non-truncated results, it is expected to return the currentToken. If we return the highest token, as with truncated results, the client will always think it is one change behind. + * + * Therefore, we differentiate between truncated and non-truncated results when returning the synctoken. + */ + if ($rowCount === $limit && $highestSyncToken < $currentToken) { + $result['syncToken'] = $highestSyncToken; + $result['result_truncated'] = true; + } + } else { + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'uri') + ->from('cards') + ->where( + $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)) + ); + // No synctoken supplied, this is the initial sync. + $qb->setMaxResults($limit); + $stmt = $qb->executeQuery(); + $values = $stmt->fetchAll(\PDO::FETCH_ASSOC); + if (empty($values)) { + $result['added'] = []; + return $result; + } + $lastID = $values[array_key_last($values)]['id']; + if (count($values) >= $limit) { + $result['syncToken'] = 'init_' . $lastID . '_' . $currentToken; + $result['result_truncated'] = true; + } + + $result['added'] = array_column($values, 'uri'); + + $stmt->closeCursor(); } - } else { - $qb = $this->db->getQueryBuilder(); - $qb->select('uri') - ->from('cards') - ->where( - $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)) - ); - // No synctoken supplied, this is the initial sync. - $stmt = $qb->execute(); - $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN); - $stmt->closeCursor(); - } - return $result; + return $result; + }, $this->db); } /** @@ -947,19 +1004,33 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @param int $operation 1 = add, 2 = modify, 3 = delete * @return void */ - protected function addChange($addressBookId, $objectUri, $operation) { - $sql = 'INSERT INTO `*PREFIX*addressbookchanges`(`uri`, `synctoken`, `addressbookid`, `operation`) SELECT ?, `synctoken`, ?, ? FROM `*PREFIX*addressbooks` WHERE `id` = ?'; - $stmt = $this->db->prepare($sql); - $stmt->execute([ - $objectUri, - $addressBookId, - $operation, - $addressBookId - ]); - $stmt = $this->db->prepare('UPDATE `*PREFIX*addressbooks` SET `synctoken` = `synctoken` + 1 WHERE `id` = ?'); - $stmt->execute([ - $addressBookId - ]); + protected function addChange(int $addressBookId, string $objectUri, int $operation): void { + $this->atomic(function () use ($addressBookId, $objectUri, $operation): void { + $query = $this->db->getQueryBuilder(); + $query->select('synctoken') + ->from('addressbooks') + ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))); + $result = $query->executeQuery(); + $syncToken = (int)$result->fetchOne(); + $result->closeCursor(); + + $query = $this->db->getQueryBuilder(); + $query->insert('addressbookchanges') + ->values([ + 'uri' => $query->createNamedParameter($objectUri), + 'synctoken' => $query->createNamedParameter($syncToken), + 'addressbookid' => $query->createNamedParameter($addressBookId), + 'operation' => $query->createNamedParameter($operation), + 'created_at' => time(), + ]) + ->executeStatement(); + + $query = $this->db->getQueryBuilder(); + $query->update('addressbooks') + ->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))) + ->executeStatement(); + }, $this->db); } /** @@ -972,13 +1043,19 @@ class CardDavBackend implements BackendInterface, SyncSupport { $cardData = stream_get_contents($cardData); } + // Micro optimisation + // don't loop through + if (str_starts_with($cardData, 'PHOTO:data:')) { + return $cardData; + } + $cardDataArray = explode("\r\n", $cardData); $cardDataFiltered = []; $removingPhoto = false; foreach ($cardDataArray as $line) { - if (strpos($line, 'PHOTO:data:') === 0 - && strpos($line, 'PHOTO:data:image/') !== 0) { + if (str_starts_with($line, 'PHOTO:data:') + && !str_starts_with($line, 'PHOTO:data:image/')) { // Filter out PHOTO data of non-images $removingPhoto = true; $modified = true; @@ -986,7 +1063,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { } if ($removingPhoto) { - if (strpos($line, ' ') === 0) { + if (str_starts_with($line, ' ')) { continue; } // No leading space means this is a new property @@ -995,23 +1072,23 @@ class CardDavBackend implements BackendInterface, SyncSupport { $cardDataFiltered[] = $line; } - return implode("\r\n", $cardDataFiltered); } /** - * @param IShareable $shareable - * @param string[] $add - * @param string[] $remove + * @param list<array{href: string, commonName: string, readOnly: bool}> $add + * @param list<string> $remove */ - public function updateShares(IShareable $shareable, $add, $remove) { - $addressBookId = $shareable->getResourceId(); - $addressBookData = $this->getAddressBookById($addressBookId); - $oldShares = $this->getShares($addressBookId); + public function updateShares(IShareable $shareable, array $add, array $remove): void { + $this->atomic(function () use ($shareable, $add, $remove): void { + $addressBookId = $shareable->getResourceId(); + $addressBookData = $this->getAddressBookById($addressBookId); + $oldShares = $this->getShares($addressBookId); - $this->sharingBackend->updateShares($shareable, $add, $remove); + $this->sharingBackend->updateShares($shareable, $add, $remove, $oldShares); - $this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove)); + $this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove)); + }, $this->db); } /** @@ -1021,13 +1098,18 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @param string $pattern which should match within the $searchProperties * @param array $searchProperties defines the properties within the query pattern should match * @param array $options = array() to define the search behavior - * - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are - * - 'limit' - Set a numeric limit for the search results - * - 'offset' - Set the offset for the limited search results + * - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array + * - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are + * - 'limit' - Set a numeric limit for the search results + * - 'offset' - Set the offset for the limited search results + * - 'wildcard' - Whether the search should use wildcards + * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options * @return array an array of contacts which are arrays of key-value-pairs */ public function search($addressBookId, $pattern, $searchProperties, $options = []): array { - return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options); + return $this->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) { + return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options); + }, $this->db); } /** @@ -1040,76 +1122,82 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @return array */ public function searchPrincipalUri(string $principalUri, - string $pattern, - array $searchProperties, - array $options = []): array { - $addressBookIds = array_map(static function ($row):int { - return (int) $row['id']; - }, $this->getAddressBooksForUser($principalUri)); - - return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options); + string $pattern, + array $searchProperties, + array $options = []): array { + return $this->atomic(function () use ($principalUri, $pattern, $searchProperties, $options) { + $addressBookIds = array_map(static function ($row):int { + return (int)$row['id']; + }, $this->getAddressBooksForUser($principalUri)); + + return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options); + }, $this->db); } /** - * @param array $addressBookIds + * @param int[] $addressBookIds * @param string $pattern * @param array $searchProperties * @param array $options + * @psalm-param array{ + * types?: bool, + * escape_like_param?: bool, + * limit?: int, + * offset?: int, + * wildcard?: bool, + * since?: DateTimeFilter|null, + * until?: DateTimeFilter|null, + * person?: string + * } $options * @return array */ private function searchByAddressBookIds(array $addressBookIds, - string $pattern, - array $searchProperties, - array $options = []): array { - $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false; - - $query2 = $this->db->getQueryBuilder(); - - $addressBookOr = $query2->expr()->orX(); - foreach ($addressBookIds as $addressBookId) { - $addressBookOr->add($query2->expr()->eq('cp.addressbookid', $query2->createNamedParameter($addressBookId))); - } - - if ($addressBookOr->count() === 0) { + string $pattern, + array $searchProperties, + array $options = []): array { + if (empty($addressBookIds)) { return []; } + $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false; + $useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false; - $propertyOr = $query2->expr()->orX(); - foreach ($searchProperties as $property) { - if ($escapePattern) { - if ($property === 'EMAIL' && strpos($pattern, ' ') !== false) { + if ($escapePattern) { + $searchProperties = array_filter($searchProperties, function ($property) use ($pattern) { + if ($property === 'EMAIL' && str_contains($pattern, ' ')) { // There can be no spaces in emails - continue; + return false; } if ($property === 'CLOUD' && preg_match('/[^a-zA-Z0-9 :_.@\/\-\']/', $pattern) === 1) { // There can be no chars in cloud ids which are not valid for user ids plus :/ // worst case: CA61590A-BBBC-423E-84AF-E6DF01455A53@https://my.nxt/srv/ - continue; + return false; } - } - $propertyOr->add($query2->expr()->eq('cp.name', $query2->createNamedParameter($property))); + return true; + }); } - if ($propertyOr->count() === 0) { + if (empty($searchProperties)) { return []; } + $query2 = $this->db->getQueryBuilder(); $query2->selectDistinct('cp.cardid') ->from($this->dbCardsPropertiesTable, 'cp') - ->andWhere($addressBookOr) - ->andWhere($propertyOr); + ->where($query2->expr()->in('cp.addressbookid', $query2->createNamedParameter($addressBookIds, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY)) + ->andWhere($query2->expr()->in('cp.name', $query2->createNamedParameter($searchProperties, IQueryBuilder::PARAM_STR_ARRAY))); // No need for like when the pattern is empty - if ('' !== $pattern) { - if (!$escapePattern) { + if ($pattern !== '') { + if (!$useWildcards) { + $query2->andWhere($query2->expr()->eq('cp.value', $query2->createNamedParameter($pattern))); + } elseif (!$escapePattern) { $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter($pattern))); } else { $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%'))); } } - if (isset($options['limit'])) { $query2->setMaxResults($options['limit']); } @@ -1117,25 +1205,54 @@ class CardDavBackend implements BackendInterface, SyncSupport { $query2->setFirstResult($options['offset']); } - $result = $query2->execute(); + if (isset($options['person'])) { + $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($options['person']) . '%'))); + } + if (isset($options['since']) || isset($options['until'])) { + $query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid'); + $query2->andWhere($query2->expr()->eq('cp_bday.name', $query2->createNamedParameter('BDAY'))); + /** + * FIXME Find a way to match only 4 last digits + * BDAY can be --1018 without year or 20001019 with it + * $bDayOr = []; + * if ($options['since'] instanceof DateTimeFilter) { + * $bDayOr[] = + * $query2->expr()->gte('SUBSTR(cp_bday.value, -4)', + * $query2->createNamedParameter($options['since']->get()->format('md')) + * ); + * } + * if ($options['until'] instanceof DateTimeFilter) { + * $bDayOr[] = + * $query2->expr()->lte('SUBSTR(cp_bday.value, -4)', + * $query2->createNamedParameter($options['until']->get()->format('md')) + * ); + * } + * $query2->andWhere($query2->expr()->orX(...$bDayOr)); + */ + } + + $result = $query2->executeQuery(); $matches = $result->fetchAll(); $result->closeCursor(); $matches = array_map(function ($match) { return (int)$match['cardid']; }, $matches); + $cards = []; $query = $this->db->getQueryBuilder(); $query->select('c.addressbookid', 'c.carddata', 'c.uri') ->from($this->dbCardsTable, 'c') - ->where($query->expr()->in('c.id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY))); + ->where($query->expr()->in('c.id', $query->createParameter('matches'))); - $result = $query->execute(); - $cards = $result->fetchAll(); - - $result->closeCursor(); + foreach (array_chunk($matches, 1000) as $matchesChunk) { + $query->setParameter('matches', $matchesChunk, IQueryBuilder::PARAM_INT_ARRAY); + $result = $query->executeQuery(); + $cards = array_merge($cards, $result->fetchAll()); + $result->closeCursor(); + } return array_map(function ($array) { - $array['addressbookid'] = (int) $array['addressbookid']; + $array['addressbookid'] = (int)$array['addressbookid']; $modified = false; $array['carddata'] = $this->readBlob($array['carddata'], $modified); if ($modified) { @@ -1156,7 +1273,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { ->from($this->dbCardsPropertiesTable) ->where($query->expr()->eq('name', $query->createNamedParameter($name))) ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId))) - ->execute(); + ->executeQuery(); $all = $result->fetchAll(PDO::FETCH_COLUMN); $result->closeCursor(); @@ -1176,7 +1293,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { ->where($query->expr()->eq('id', $query->createParameter('id'))) ->setParameter('id', $id); - $result = $query->execute(); + $result = $query->executeQuery(); $uri = $result->fetch(); $result->closeCursor(); @@ -1200,7 +1317,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { $query->select('*')->from($this->dbCardsTable) ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); - $queryResult = $query->execute(); + $queryResult = $query->executeQuery(); $contact = $queryResult->fetch(); $queryResult->closeCursor(); @@ -1226,11 +1343,10 @@ class CardDavBackend implements BackendInterface, SyncSupport { * * commonName - Optional, for example a first + last name * * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants. * * readOnly - boolean - * * summary - Optional, a description for the share * - * @return array + * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> */ - public function getShares($addressBookId) { + public function getShares(int $addressBookId): array { return $this->sharingBackend->getShares($addressBookId); } @@ -1242,39 +1358,41 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @param string $vCardSerialized */ protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) { - $cardId = $this->getCardId($addressBookId, $cardUri); - $vCard = $this->readCard($vCardSerialized); + $this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void { + $cardId = $this->getCardId($addressBookId, $cardUri); + $vCard = $this->readCard($vCardSerialized); - $this->purgeProperties($addressBookId, $cardId); + $this->purgeProperties($addressBookId, $cardId); - $query = $this->db->getQueryBuilder(); - $query->insert($this->dbCardsPropertiesTable) - ->values( - [ - 'addressbookid' => $query->createNamedParameter($addressBookId), - 'cardid' => $query->createNamedParameter($cardId), - 'name' => $query->createParameter('name'), - 'value' => $query->createParameter('value'), - 'preferred' => $query->createParameter('preferred') - ] - ); + $query = $this->db->getQueryBuilder(); + $query->insert($this->dbCardsPropertiesTable) + ->values( + [ + 'addressbookid' => $query->createNamedParameter($addressBookId), + 'cardid' => $query->createNamedParameter($cardId), + 'name' => $query->createParameter('name'), + 'value' => $query->createParameter('value'), + 'preferred' => $query->createParameter('preferred') + ] + ); - foreach ($vCard->children() as $property) { - if (!in_array($property->name, self::$indexProperties)) { - continue; - } - $preferred = 0; - foreach ($property->parameters as $parameter) { - if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') { - $preferred = 1; - break; + foreach ($vCard->children() as $property) { + if (!in_array($property->name, self::$indexProperties)) { + continue; + } + $preferred = 0; + foreach ($property->parameters as $parameter) { + if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') { + $preferred = 1; + break; + } } + $query->setParameter('name', $property->name); + $query->setParameter('value', mb_strcut($property->getValue(), 0, 254)); + $query->setParameter('preferred', $preferred); + $query->executeStatement(); } - $query->setParameter('name', $property->name); - $query->setParameter('value', mb_substr($property->getValue(), 0, 254)); - $query->setParameter('preferred', $preferred); - $query->execute(); - } + }, $this->db); } /** @@ -1298,23 +1416,19 @@ class CardDavBackend implements BackendInterface, SyncSupport { $query->delete($this->dbCardsPropertiesTable) ->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId))) ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); - $query->execute(); + $query->executeStatement(); } /** - * get ID from a given contact - * - * @param int $addressBookId - * @param string $uri - * @return int + * Get ID from a given contact */ - protected function getCardId($addressBookId, $uri) { + protected function getCardId(int $addressBookId, string $uri): int { $query = $this->db->getQueryBuilder(); $query->select('id')->from($this->dbCardsTable) ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); - $result = $query->execute(); + $result = $query->executeQuery(); $cardIds = $result->fetch(); $result->closeCursor(); @@ -1328,17 +1442,46 @@ class CardDavBackend implements BackendInterface, SyncSupport { /** * For shared address books the sharee is set in the ACL of the address book * - * @param $addressBookId - * @param $acl - * @return array + * @param int $addressBookId + * @param list<array{privilege: string, principal: string, protected: bool}> $acl + * @return list<array{privilege: string, principal: string, protected: bool}> */ - public function applyShareAcl($addressBookId, $acl) { - return $this->sharingBackend->applyShareAcl($addressBookId, $acl); + public function applyShareAcl(int $addressBookId, array $acl): array { + $shares = $this->sharingBackend->getShares($addressBookId); + return $this->sharingBackend->applyShareAcl($shares, $acl); + } + + /** + * @throws \InvalidArgumentException + */ + public function pruneOutdatedSyncTokens(int $keep, int $retention): int { + if ($keep < 0) { + throw new \InvalidArgumentException(); + } + + $query = $this->db->getQueryBuilder(); + $query->select($query->func()->max('id')) + ->from('addressbookchanges'); + + $result = $query->executeQuery(); + $maxId = (int)$result->fetchOne(); + $result->closeCursor(); + if (!$maxId || $maxId < $keep) { + return 0; + } + + $query = $this->db->getQueryBuilder(); + $query->delete('addressbookchanges') + ->where( + $query->expr()->lte('id', $query->createNamedParameter($maxId - $keep, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + $query->expr()->lte('created_at', $query->createNamedParameter($retention)), + ); + return $query->executeStatement(); } - private function convertPrincipal($principalUri, $toV2) { + private function convertPrincipal(string $principalUri, bool $toV2): string { if ($this->principalBackend->getPrincipalPrefix() === 'principals') { - list(, $name) = \Sabre\Uri\split($principalUri); + [, $name] = \Sabre\Uri\split($principalUri); if ($toV2 === true) { return "principals/users/$name"; } @@ -1347,7 +1490,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { return $principalUri; } - private function addOwnerPrincipal(&$addressbookInfo) { + private function addOwnerPrincipal(array &$addressbookInfo): void { $ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'; $displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname'; if (isset($addressbookInfo[$ownerPrincipalKey])) { @@ -1367,10 +1510,10 @@ class CardDavBackend implements BackendInterface, SyncSupport { * * @param string $cardData the vcard raw data * @return string the uid - * @throws BadRequest if no UID is available + * @throws BadRequest if no UID is available or vcard is empty */ - private function getUID($cardData) { - if ($cardData != '') { + private function getUID(string $cardData): string { + if ($cardData !== '') { $vCard = Reader::read($cardData); if ($vCard->UID) { $uid = $vCard->UID->getValue(); diff --git a/apps/dav/lib/CardDAV/ContactsManager.php b/apps/dav/lib/CardDAV/ContactsManager.php index 20616a65edc..b35137c902d 100644 --- a/apps/dav/lib/CardDAV/ContactsManager.php +++ b/apps/dav/lib/CardDAV/ContactsManager.php @@ -1,51 +1,29 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tobia De Koninck <tobia@ledfan.be> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CardDAV; +use OCA\DAV\Db\PropertyMapper; use OCP\Contacts\IManager; use OCP\IL10N; use OCP\IURLGenerator; class ContactsManager { - /** @var CardDavBackend */ - private $backend; - - /** @var IL10N */ - private $l10n; - /** * ContactsManager constructor. * * @param CardDavBackend $backend * @param IL10N $l10n */ - public function __construct(CardDavBackend $backend, IL10N $l10n) { - $this->backend = $backend; - $this->l10n = $l10n; + public function __construct( + private CardDavBackend $backend, + private IL10N $l10n, + private PropertyMapper $propertyMapper, + ) { } /** @@ -55,33 +33,37 @@ class ContactsManager { */ public function setupContactsProvider(IManager $cm, $userId, IURLGenerator $urlGenerator) { $addressBooks = $this->backend->getAddressBooksForUser("principals/users/$userId"); - $this->register($cm, $addressBooks, $urlGenerator); - $this->setupSystemContactsProvider($cm, $urlGenerator); + $this->register($cm, $addressBooks, $urlGenerator, $userId); + $this->setupSystemContactsProvider($cm, $userId, $urlGenerator); } /** * @param IManager $cm + * @param ?string $userId * @param IURLGenerator $urlGenerator */ - public function setupSystemContactsProvider(IManager $cm, IURLGenerator $urlGenerator) { - $addressBooks = $this->backend->getAddressBooksForUser("principals/system/system"); - $this->register($cm, $addressBooks, $urlGenerator); + public function setupSystemContactsProvider(IManager $cm, ?string $userId, IURLGenerator $urlGenerator) { + $addressBooks = $this->backend->getAddressBooksForUser('principals/system/system'); + $this->register($cm, $addressBooks, $urlGenerator, $userId); } /** * @param IManager $cm * @param $addressBooks * @param IURLGenerator $urlGenerator + * @param ?string $userId */ - private function register(IManager $cm, $addressBooks, $urlGenerator) { + private function register(IManager $cm, $addressBooks, $urlGenerator, ?string $userId) { foreach ($addressBooks as $addressBookInfo) { - $addressBook = new \OCA\DAV\CardDAV\AddressBook($this->backend, $addressBookInfo, $this->l10n); + $addressBook = new AddressBook($this->backend, $addressBookInfo, $this->l10n); $cm->registerAddressBook( new AddressBookImpl( $addressBook, $addressBookInfo, $this->backend, - $urlGenerator + $urlGenerator, + $this->propertyMapper, + $userId, ) ); } diff --git a/apps/dav/lib/CardDAV/Converter.php b/apps/dav/lib/CardDAV/Converter.php index 59e5401d058..30dba99839e 100644 --- a/apps/dav/lib/CardDAV/Converter.php +++ b/apps/dav/lib/CardDAV/Converter.php @@ -1,59 +1,35 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CardDAV; -use OC\Accounts\AccountManager; +use DateTimeImmutable; +use Exception; use OCP\Accounts\IAccountManager; use OCP\IImage; +use OCP\IURLGenerator; use OCP\IUser; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; use Sabre\VObject\Component\VCard; use Sabre\VObject\Property\Text; +use Sabre\VObject\Property\VCard\Date; class Converter { - - /** @var AccountManager */ - private $accountManager; - - /** - * Converter constructor. - * - * @param AccountManager $accountManager - */ - public function __construct(AccountManager $accountManager) { - $this->accountManager = $accountManager; + public function __construct( + private IAccountManager $accountManager, + private IUserManager $userManager, + private IURLGenerator $urlGenerator, + private LoggerInterface $logger, + ) { } - /** - * @param IUser $user - * @return VCard|null - */ - public function createCardFromUser(IUser $user) { - $userData = $this->accountManager->getUser($user); + public function createCardFromUser(IUser $user): ?VCard { + $userProperties = $this->accountManager->getAccount($user)->getAllProperties(); $uid = $user->getUID(); $cloudId = $user->getCloudId(); @@ -65,45 +41,109 @@ class Converter { $publish = false; - if ($image !== null && isset($userData[IAccountManager::PROPERTY_AVATAR])) { - $userData[IAccountManager::PROPERTY_AVATAR]['value'] = true; - } - - foreach ($userData as $property => $value) { - $shareWithTrustedServers = - $value['scope'] === AccountManager::VISIBILITY_CONTACTS_ONLY || - $value['scope'] === AccountManager::VISIBILITY_PUBLIC; + foreach ($userProperties as $property) { + if ($property->getName() !== IAccountManager::PROPERTY_AVATAR && empty($property->getValue())) { + continue; + } - $emptyValue = !isset($value['value']) || $value['value'] === ''; + $scope = $property->getScope(); + // Do not write private data to the system address book at all + if ($scope === IAccountManager::SCOPE_PRIVATE || empty($scope)) { + continue; + } - if ($shareWithTrustedServers && !$emptyValue) { - $publish = true; - switch ($property) { - case IAccountManager::PROPERTY_DISPLAYNAME: - $vCard->add(new Text($vCard, 'FN', $value['value'])); - $vCard->add(new Text($vCard, 'N', $this->splitFullName($value['value']))); - break; - case IAccountManager::PROPERTY_AVATAR: - if ($image !== null) { - $vCard->add('PHOTO', $image->data(), ['ENCODING' => 'b', 'TYPE' => $image->mimeType()]); - } + $publish = true; + switch ($property->getName()) { + case IAccountManager::PROPERTY_DISPLAYNAME: + $vCard->add(new Text($vCard, 'FN', $property->getValue(), ['X-NC-SCOPE' => $scope])); + $vCard->add(new Text($vCard, 'N', $this->splitFullName($property->getValue()), ['X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_AVATAR: + if ($image !== null) { + $vCard->add('PHOTO', $image->data(), ['ENCODING' => 'b', 'TYPE' => $image->mimeType(), ['X-NC-SCOPE' => $scope]]); + } + break; + case IAccountManager::COLLECTION_EMAIL: + case IAccountManager::PROPERTY_EMAIL: + $vCard->add(new Text($vCard, 'EMAIL', $property->getValue(), ['TYPE' => 'OTHER', 'X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_WEBSITE: + $vCard->add(new Text($vCard, 'URL', $property->getValue(), ['X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_PROFILE_ENABLED: + if ($property->getValue()) { + $vCard->add( + new Text( + $vCard, + 'X-SOCIALPROFILE', + $this->urlGenerator->linkToRouteAbsolute('profile.ProfilePage.index', ['targetUserId' => $user->getUID()]), + [ + 'TYPE' => 'NEXTCLOUD', + 'X-NC-SCOPE' => IAccountManager::SCOPE_PUBLISHED + ] + ) + ); + } + break; + case IAccountManager::PROPERTY_PHONE: + $vCard->add(new Text($vCard, 'TEL', $property->getValue(), ['TYPE' => 'VOICE', 'X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_ADDRESS: + // structured prop: https://www.rfc-editor.org/rfc/rfc6350.html#section-6.3.1 + // post office box;extended address;street address;locality;region;postal code;country + $vCard->add( + new Text( + $vCard, + 'ADR', + [ '', '', '', $property->getValue(), '', '', '' ], + [ + 'TYPE' => 'OTHER', + 'X-NC-SCOPE' => $scope, + ] + ) + ); + break; + case IAccountManager::PROPERTY_TWITTER: + $vCard->add(new Text($vCard, 'X-SOCIALPROFILE', $property->getValue(), ['TYPE' => 'TWITTER', 'X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_ORGANISATION: + $vCard->add(new Text($vCard, 'ORG', $property->getValue(), ['X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_ROLE: + $vCard->add(new Text($vCard, 'TITLE', $property->getValue(), ['X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_BIOGRAPHY: + $vCard->add(new Text($vCard, 'NOTE', $property->getValue(), ['X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_BIRTHDATE: + try { + $birthdate = new DateTimeImmutable($property->getValue()); + } catch (Exception $e) { + // Invalid date -> just skip the property + $this->logger->info("Failed to parse user's birthdate for the SAB: " . $property->getValue(), [ + 'exception' => $e, + 'userId' => $user->getUID(), + ]); break; - case IAccountManager::PROPERTY_EMAIL: - $vCard->add(new Text($vCard, 'EMAIL', $value['value'], ['TYPE' => 'OTHER'])); - break; - case IAccountManager::PROPERTY_WEBSITE: - $vCard->add(new Text($vCard, 'URL', $value['value'])); - break; - case IAccountManager::PROPERTY_PHONE: - $vCard->add(new Text($vCard, 'TEL', $value['value'], ['TYPE' => 'OTHER'])); - break; - case IAccountManager::PROPERTY_ADDRESS: - $vCard->add(new Text($vCard, 'ADR', $value['value'], ['TYPE' => 'OTHER'])); - break; - case IAccountManager::PROPERTY_TWITTER: - $vCard->add(new Text($vCard, 'X-SOCIALPROFILE', $value['value'], ['TYPE' => 'TWITTER'])); - break; - } + } + $dateProperty = new Date($vCard, 'BDAY', null, ['X-NC-SCOPE' => $scope]); + $dateProperty->setDateTime($birthdate); + $vCard->add($dateProperty); + break; + } + } + + // Local properties + $managers = $user->getManagerUids(); + // X-MANAGERSNAME only allows a single value, so we take the first manager + if (isset($managers[0])) { + $displayName = $this->userManager->getDisplayName($managers[0]); + // Only set the manager if a user object is found + if ($displayName !== null) { + $vCard->add(new Text($vCard, 'X-MANAGERSNAME', $displayName, [ + 'uid' => $managers[0], + 'X-NC-SCOPE' => IAccountManager::SCOPE_LOCAL, + ])); } } @@ -116,11 +156,7 @@ class Converter { return null; } - /** - * @param string $fullName - * @return string[] - */ - public function splitFullName($fullName) { + public function splitFullName(string $fullName): array { // Very basic western style parsing. I'm not gonna implement // https://github.com/android/platform_packages_providers_contactsprovider/blob/master/src/com/android/providers/contacts/NameSplitter.java ;) @@ -140,14 +176,10 @@ class Converter { return $result; } - /** - * @param IUser $user - * @return null|IImage - */ - private function getAvatarImage(IUser $user) { + private function getAvatarImage(IUser $user): ?IImage { try { - return $user->getAvatarImage(-1); - } catch (\Exception $ex) { + return $user->getAvatarImage(512); + } catch (Exception $ex) { return null; } } diff --git a/apps/dav/lib/CardDAV/HasPhotoPlugin.php b/apps/dav/lib/CardDAV/HasPhotoPlugin.php index a77a75c6cc6..6e2e0423910 100644 --- a/apps/dav/lib/CardDAV/HasPhotoPlugin.php +++ b/apps/dav/lib/CardDAV/HasPhotoPlugin.php @@ -3,29 +3,9 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\DAV\CardDAV; use Sabre\CardDAV\Card; @@ -67,8 +47,8 @@ class HasPhotoPlugin extends ServerPlugin { return $vcard instanceof VCard && $vcard->PHOTO // Either the PHOTO is a url (doesn't start with data:) or the mimetype has to start with image/ - && (strpos($vcard->PHOTO->getValue(), 'data:') !== 0 - || strpos($vcard->PHOTO->getValue(), 'data:image/') === 0) + && (!str_starts_with($vcard->PHOTO->getValue(), 'data:') + || str_starts_with($vcard->PHOTO->getValue(), 'data:image/')) ; }); } diff --git a/apps/dav/lib/CardDAV/ImageExportPlugin.php b/apps/dav/lib/CardDAV/ImageExportPlugin.php index 17e544a85bc..74a8b032e42 100644 --- a/apps/dav/lib/CardDAV/ImageExportPlugin.php +++ b/apps/dav/lib/CardDAV/ImageExportPlugin.php @@ -1,30 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Jacob Neplokh <me@jacobneplokh.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CardDAV; +use OCP\AppFramework\Http; use OCP\Files\NotFoundException; use Sabre\CardDAV\Card; use Sabre\DAV\Server; @@ -36,16 +19,15 @@ class ImageExportPlugin extends ServerPlugin { /** @var Server */ protected $server; - /** @var PhotoCache */ - private $cache; /** * ImageExportPlugin constructor. * * @param PhotoCache $cache */ - public function __construct(PhotoCache $cache) { - $this->cache = $cache; + public function __construct( + private PhotoCache $cache, + ) { } /** @@ -78,7 +60,7 @@ class ImageExportPlugin extends ServerPlugin { $path = $request->getPath(); $node = $this->server->tree->getNodeForPath($path); - if (!($node instanceof Card)) { + if (!$node instanceof Card) { return true; } @@ -99,18 +81,17 @@ class ImageExportPlugin extends ServerPlugin { $response->setHeader('Cache-Control', 'private, max-age=3600, must-revalidate'); $response->setHeader('Etag', $node->getETag()); - $response->setHeader('Pragma', 'public'); try { $file = $this->cache->get($addressbook->getResourceId(), $node->getName(), $size, $node); $response->setHeader('Content-Type', $file->getMimeType()); $fileName = $node->getName() . '.' . PhotoCache::ALLOWED_CONTENT_TYPES[$file->getMimeType()]; $response->setHeader('Content-Disposition', "attachment; filename=$fileName"); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); $response->setBody($file->getContent()); } catch (NotFoundException $e) { - $response->setStatus(404); + $response->setStatus(Http::STATUS_NO_CONTENT); } return false; diff --git a/apps/dav/lib/CardDAV/Integration/ExternalAddressBook.php b/apps/dav/lib/CardDAV/Integration/ExternalAddressBook.php index 3f276dc7a14..372906a6ae8 100644 --- a/apps/dav/lib/CardDAV/Integration/ExternalAddressBook.php +++ b/apps/dav/lib/CardDAV/Integration/ExternalAddressBook.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV\Integration; @@ -50,19 +32,10 @@ abstract class ExternalAddressBook implements IAddressBook, DAV\IProperties { */ private const DELIMITER = '--'; - /** @var string */ - private $appId; - - /** @var string */ - private $uri; - - /** - * @param string $appId - * @param string $uri - */ - public function __construct(string $appId, string $uri) { - $this->appId = $appId; - $this->uri = $uri; + public function __construct( + private string $appId, + private string $uri, + ) { } /** @@ -98,7 +71,7 @@ abstract class ExternalAddressBook implements IAddressBook, DAV\IProperties { * @return bool */ public static function isAppGeneratedAddressBook(string $uri): bool { - return strpos($uri, self::PREFIX) === 0 && substr_count($uri, self::DELIMITER) >= 2; + return str_starts_with($uri, self::PREFIX) && substr_count($uri, self::DELIMITER) >= 2; } /** @@ -128,6 +101,6 @@ abstract class ExternalAddressBook implements IAddressBook, DAV\IProperties { * @return bool */ public static function doesViolateReservedName(string $uri): bool { - return strpos($uri, self::PREFIX) === 0; + return str_starts_with($uri, self::PREFIX); } } diff --git a/apps/dav/lib/CardDAV/Integration/IAddressBookProvider.php b/apps/dav/lib/CardDAV/Integration/IAddressBookProvider.php index 4fb3ccf5337..a8fa074f635 100644 --- a/apps/dav/lib/CardDAV/Integration/IAddressBookProvider.php +++ b/apps/dav/lib/CardDAV/Integration/IAddressBookProvider.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV\Integration; diff --git a/apps/dav/lib/CardDAV/MultiGetExportPlugin.php b/apps/dav/lib/CardDAV/MultiGetExportPlugin.php index 94a169602b8..9d6b0df838e 100644 --- a/apps/dav/lib/CardDAV/MultiGetExportPlugin.php +++ b/apps/dav/lib/CardDAV/MultiGetExportPlugin.php @@ -3,30 +3,12 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\DAV\CardDAV; +use OCP\AppFramework\Http; use Sabre\DAV; use Sabre\DAV\Server; use Sabre\HTTP\RequestInterface; @@ -62,8 +44,8 @@ class MultiGetExportPlugin extends DAV\ServerPlugin { } // Only handling xml - $contentType = $response->getHeader('Content-Type'); - if (strpos($contentType, 'application/xml') === false && strpos($contentType, 'text/xml') === false) { + $contentType = (string)$response->getHeader('Content-Type'); + if (!str_contains($contentType, 'application/xml') && !str_contains($contentType, 'text/xml')) { return; } @@ -84,7 +66,7 @@ class MultiGetExportPlugin extends DAV\ServerPlugin { $response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); $response->setHeader('Content-Type', 'text/vcard'); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); $response->setBody($output); return true; diff --git a/apps/dav/lib/CardDAV/PhotoCache.php b/apps/dav/lib/CardDAV/PhotoCache.php index 3d88eef5789..03c71f7e4a3 100644 --- a/apps/dav/lib/CardDAV/PhotoCache.php +++ b/apps/dav/lib/CardDAV/PhotoCache.php @@ -1,83 +1,49 @@ <?php + /** - * - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Jacob Neplokh <me@jacobneplokh.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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; use OCP\Files\SimpleFS\ISimpleFile; use OCP\Files\SimpleFS\ISimpleFolder; -use OCP\ILogger; +use OCP\Image; +use Psr\Log\LoggerInterface; use Sabre\CardDAV\Card; +use Sabre\VObject\Document; use Sabre\VObject\Parameter; use Sabre\VObject\Property\Binary; use Sabre\VObject\Reader; class PhotoCache { + private ?IAppData $photoCacheAppData = null; - /** @var array */ + /** @var array */ public const ALLOWED_CONTENT_TYPES = [ 'image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/vnd.microsoft.icon' => 'ico', + 'image/webp' => 'webp', + 'image/avif' => 'avif', ]; - /** @var IAppData */ - protected $appData; - - /** @var ILogger */ - protected $logger; - - /** - * PhotoCache constructor. - * - * @param IAppData $appData - * @param ILogger $logger - */ - public function __construct(IAppData $appData, ILogger $logger) { - $this->appData = $appData; - $this->logger = $logger; + public function __construct( + private IAppDataFactory $appDataFactory, + private LoggerInterface $logger, + ) { } /** - * @param int $addressBookId - * @param string $cardUri - * @param int $size - * @param Card $card - * - * @return ISimpleFile * @throws NotFoundException */ - public function get($addressBookId, $cardUri, $size, Card $card) { + public function get(int $addressBookId, string $cardUri, int $size, Card $card): ISimpleFile { $folder = $this->getFolder($addressBookId, $cardUri); if ($this->isEmpty($folder)) { @@ -95,17 +61,11 @@ class PhotoCache { return $this->getFile($folder, $size); } - /** - * @param ISimpleFolder $folder - * @return bool - */ - private function isEmpty(ISimpleFolder $folder) { + private function isEmpty(ISimpleFolder $folder): bool { return $folder->getDirectoryListing() === []; } /** - * @param ISimpleFolder $folder - * @param Card $card * @throws NotPermittedException */ private function init(ISimpleFolder $folder, Card $card): void { @@ -128,11 +88,14 @@ class PhotoCache { $file->putContent($data['body']); } - private function hasPhoto(ISimpleFolder $folder) { + private function hasPhoto(ISimpleFolder $folder): bool { return !$folder->fileExists('nophoto'); } - private function getFile(ISimpleFolder $folder, $size) { + /** + * @param float|-1 $size + */ + private function getFile(ISimpleFolder $folder, $size): ISimpleFile { $ext = $this->getExtension($folder); if ($size === -1) { @@ -148,7 +111,7 @@ class PhotoCache { throw new NotFoundException; } - $photo = new \OC_Image(); + $photo = new Image(); /** @var ISimpleFile $file */ $file = $folder->getFile('photo.' . $ext); $photo->loadFromData($file->getContent()); @@ -158,7 +121,7 @@ class PhotoCache { $ratio = 1 / $ratio; } - $size = (int) ($size * $ratio); + $size = (int)($size * $ratio); if ($size !== -1) { $photo->resize($size); } @@ -180,21 +143,18 @@ 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; } } /** * Get the extension of the avatar. If there is no avatar throw Exception * - * @param ISimpleFolder $folder - * @return string * @throws NotFoundException */ private function getExtension(ISimpleFolder $folder): string { @@ -207,9 +167,27 @@ class PhotoCache { throw new NotFoundException('Avatar not found'); } + /** + * @param Card $node + * @return false|array{body: string, Content-Type: string} + */ private function getPhoto(Card $node) { try { $vObject = $this->readCard($node->get()); + return $this->getPhotoFromVObject($vObject); + } catch (\Exception $e) { + $this->logger->error('Exception during vcard photo parsing', [ + 'exception' => $e + ]); + } + return false; + } + + /** + * @return false|array{body: string, Content-Type: string} + */ + public function getPhotoFromVObject(Document $vObject) { + try { if (!$vObject->PHOTO) { return false; } @@ -226,7 +204,7 @@ class PhotoCache { return false; } if (substr_count($parsed['path'], ';') === 1) { - list($type) = explode(';', $parsed['path']); + [$type] = explode(';', $parsed['path']); } $val = file_get_contents($val); } else { @@ -243,18 +221,14 @@ class PhotoCache { 'body' => $val ]; } catch (\Exception $e) { - $this->logger->logException($e, [ - 'message' => 'Exception during vcard photo parsing' + $this->logger->error('Exception during vcard photo parsing', [ + 'exception' => $e ]); } return false; } - /** - * @param string $cardData - * @return \Sabre\VObject\Document - */ - private function readCard($cardData) { + private function readCard(string $cardData): Document { return Reader::read($cardData); } @@ -267,9 +241,9 @@ class PhotoCache { if (isset($params['TYPE']) || isset($params['MEDIATYPE'])) { /** @var Parameter $typeParam */ $typeParam = isset($params['TYPE']) ? $params['TYPE'] : $params['MEDIATYPE']; - $type = $typeParam->getValue(); + $type = (string)$typeParam->getValue(); - if (strpos($type, 'image/') === 0) { + if (str_starts_with($type, 'image/')) { return $type; } else { return 'image/' . strtolower($type); @@ -291,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/CardDAV/Plugin.php b/apps/dav/lib/CardDAV/Plugin.php index 5f6ecb9cd8b..0ec10306ceb 100644 --- a/apps/dav/lib/CardDAV/Plugin.php +++ b/apps/dav/lib/CardDAV/Plugin.php @@ -1,28 +1,10 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CardDAV; use OCA\DAV\CardDAV\Xml\Groups; @@ -44,15 +26,15 @@ class Plugin extends \Sabre\CardDAV\Plugin { */ protected function getAddressbookHomeForPrincipal($principal) { if (strrpos($principal, 'principals/users', -strlen($principal)) !== false) { - list(, $principalId) = \Sabre\Uri\split($principal); + [, $principalId] = \Sabre\Uri\split($principal); return self::ADDRESSBOOK_ROOT . '/users/' . $principalId; } if (strrpos($principal, 'principals/groups', -strlen($principal)) !== false) { - list(, $principalId) = \Sabre\Uri\split($principal); + [, $principalId] = \Sabre\Uri\split($principal); return self::ADDRESSBOOK_ROOT . '/groups/' . $principalId; } if (strrpos($principal, 'principals/system', -strlen($principal)) !== false) { - list(, $principalId) = \Sabre\Uri\split($principal); + [, $principalId] = \Sabre\Uri\split($principal); return self::ADDRESSBOOK_ROOT . '/system/' . $principalId; } } diff --git a/apps/dav/lib/CardDAV/Security/CardDavRateLimitingPlugin.php b/apps/dav/lib/CardDAV/Security/CardDavRateLimitingPlugin.php new file mode 100644 index 00000000000..3e18a1341b0 --- /dev/null +++ b/apps/dav/lib/CardDAV/Security/CardDavRateLimitingPlugin.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +/* + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CardDAV\Security; + +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OC\Security\RateLimiting\Limiter; +use OCA\DAV\CardDAV\CardDavBackend; +use OCA\DAV\Connector\Sabre\Exception\TooManyRequests; +use OCP\IAppConfig; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; +use Sabre\DAV; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\ServerPlugin; +use function count; +use function explode; + +class CardDavRateLimitingPlugin extends ServerPlugin { + public function __construct( + private Limiter $limiter, + private IUserManager $userManager, + private CardDavBackend $cardDavBackend, + private LoggerInterface $logger, + private IAppConfig $config, + private ?string $userId, + ) { + $this->limiter = $limiter; + $this->userManager = $userManager; + $this->cardDavBackend = $cardDavBackend; + $this->config = $config; + $this->logger = $logger; + } + + public function initialize(DAV\Server $server): void { + $server->on('beforeBind', [$this, 'beforeBind'], 1); + } + + public function beforeBind(string $path): void { + if ($this->userId === null) { + // We only care about authenticated users here + return; + } + $user = $this->userManager->get($this->userId); + if ($user === null) { + // We only care about authenticated users here + return; + } + + $pathParts = explode('/', $path); + if (count($pathParts) === 4 && $pathParts[0] === 'addressbooks') { + // Path looks like addressbooks/users/username/addressbooksname so a new addressbook is created + try { + $this->limiter->registerUserRequest( + 'carddav-create-address-book', + $this->config->getValueInt('dav', 'rateLimitAddressBookCreation', 10), + $this->config->getValueInt('dav', 'rateLimitPeriodAddressBookCreation', 3600), + $user + ); + } catch (RateLimitExceededException $e) { + throw new TooManyRequests('Too many addressbooks created', 0, $e); + } + + $addressBookLimit = $this->config->getValueInt('dav', 'maximumAdressbooks', 10); + if ($addressBookLimit === -1) { + return; + } + $numAddressbooks = $this->cardDavBackend->getAddressBooksForUserCount('principals/users/' . $user->getUID()); + + if ($numAddressbooks >= $addressBookLimit) { + $this->logger->warning('Maximum number of address books reached', [ + 'addressbooks' => $numAddressbooks, + 'addressBookLimit' => $addressBookLimit, + ]); + throw new Forbidden('AddressBook limit reached', 0); + } + } + } + +} diff --git a/apps/dav/lib/CardDAV/Sharing/Backend.php b/apps/dav/lib/CardDAV/Sharing/Backend.php new file mode 100644 index 00000000000..557115762fc --- /dev/null +++ b/apps/dav/lib/CardDAV/Sharing/Backend.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CardDAV\Sharing; + +use OCA\DAV\Connector\Sabre\Principal; +use OCA\DAV\DAV\Sharing\Backend as SharingBackend; +use OCP\ICacheFactory; +use OCP\IGroupManager; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +class Backend extends SharingBackend { + public function __construct( + private IUserManager $userManager, + private IGroupManager $groupManager, + private Principal $principalBackend, + private ICacheFactory $cacheFactory, + private Service $service, + private LoggerInterface $logger, + ) { + parent::__construct($this->userManager, $this->groupManager, $this->principalBackend, $this->cacheFactory, $this->service, $this->logger); + } +} diff --git a/apps/dav/lib/CardDAV/Sharing/Service.php b/apps/dav/lib/CardDAV/Sharing/Service.php new file mode 100644 index 00000000000..1ab208f7ec3 --- /dev/null +++ b/apps/dav/lib/CardDAV/Sharing/Service.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CardDAV\Sharing; + +use OCA\DAV\DAV\Sharing\SharingMapper; +use OCA\DAV\DAV\Sharing\SharingService; + +class Service extends SharingService { + protected string $resourceType = 'addressbook'; + public function __construct( + protected SharingMapper $mapper, + ) { + parent::__construct($mapper); + } +} diff --git a/apps/dav/lib/CardDAV/SyncService.php b/apps/dav/lib/CardDAV/SyncService.php index bcb20409524..e6da3ed5923 100644 --- a/apps/dav/lib/CardDAV/SyncService.php +++ b/apps/dav/lib/CardDAV/SyncService.php @@ -1,108 +1,69 @@ <?php + + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CardDAV; -use OC\Accounts\AccountManager; +use OCP\AppFramework\Db\TTransactional; use OCP\AppFramework\Http; -use OCP\ILogger; +use OCP\DB\Exception; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IDBConnection; use OCP\IUser; use OCP\IUserManager; -use Sabre\DAV\Client; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Log\LoggerInterface; use Sabre\DAV\Xml\Response\MultiStatus; use Sabre\DAV\Xml\Service; -use Sabre\HTTP\ClientHttpException; use Sabre\VObject\Reader; +use Sabre\Xml\ParseException; +use function is_null; class SyncService { - /** @var CardDavBackend */ - private $backend; - - /** @var IUserManager */ - private $userManager; - - /** @var ILogger */ - private $logger; - - /** @var array */ - private $localSystemAddressBook; - - /** @var AccountManager */ - private $accountManager; - - /** @var string */ - protected $certPath; - - /** - * SyncService constructor. - * - * @param CardDavBackend $backend - * @param IUserManager $userManager - * @param ILogger $logger - * @param AccountManager $accountManager - */ - public function __construct(CardDavBackend $backend, IUserManager $userManager, ILogger $logger, AccountManager $accountManager) { - $this->backend = $backend; - $this->userManager = $userManager; - $this->logger = $logger; - $this->accountManager = $accountManager; + use TTransactional; + private ?array $localSystemAddressBook = null; + protected string $certPath; + + public function __construct( + private CardDavBackend $backend, + private IUserManager $userManager, + private IDBConnection $dbConnection, + private LoggerInterface $logger, + private Converter $converter, + private IClientService $clientService, + private IConfig $config, + ) { $this->certPath = ''; } /** - * @param string $url - * @param string $userName - * @param string $addressBookUrl - * @param string $sharedSecret - * @param string $syncToken - * @param int $targetBookId - * @param string $targetPrincipal - * @param array $targetProperties - * @return string + * @psalm-return list{0: ?string, 1: boolean} * @throws \Exception */ - public function syncRemoteAddressBook($url, $userName, $addressBookUrl, $sharedSecret, $syncToken, $targetBookId, $targetPrincipal, $targetProperties) { + public function syncRemoteAddressBook(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken, string $targetBookHash, string $targetPrincipal, array $targetProperties): array { // 1. create addressbook - $book = $this->ensureSystemAddressBookExists($targetPrincipal, $targetBookId, $targetProperties); + $book = $this->ensureSystemAddressBookExists($targetPrincipal, $targetBookHash, $targetProperties); $addressBookId = $book['id']; // 2. query changes try { $response = $this->requestSyncReport($url, $userName, $addressBookUrl, $sharedSecret, $syncToken); - } catch (ClientHttpException $ex) { + } catch (ClientExceptionInterface $ex) { if ($ex->getCode() === Http::STATUS_UNAUTHORIZED) { // remote server revoked access to the address book, remove it $this->backend->deleteAddressBook($addressBookId); - $this->logger->info('Authorization failed, remove address book: ' . $url, ['app' => 'dav']); + $this->logger->error('Authorization failed, remove address book: ' . $url, ['app' => 'dav']); throw $ex; } + $this->logger->error('Client exception:', ['app' => 'dav', 'exception' => $ex]); + throw $ex; } // 3. apply changes @@ -111,123 +72,147 @@ class SyncService { $cardUri = basename($resource); if (isset($status[200])) { $vCard = $this->download($url, $userName, $sharedSecret, $resource); - $existingCard = $this->backend->getCard($addressBookId, $cardUri); - if ($existingCard === false) { - $this->backend->createCard($addressBookId, $cardUri, $vCard['body']); - } else { - $this->backend->updateCard($addressBookId, $cardUri, $vCard['body']); - } + $this->atomic(function () use ($addressBookId, $cardUri, $vCard): void { + $existingCard = $this->backend->getCard($addressBookId, $cardUri); + if ($existingCard === false) { + $this->backend->createCard($addressBookId, $cardUri, $vCard); + } else { + $this->backend->updateCard($addressBookId, $cardUri, $vCard); + } + }, $this->dbConnection); } else { $this->backend->deleteCard($addressBookId, $cardUri); } } - return $response['token']; + return [ + $response['token'], + $response['truncated'], + ]; } /** - * @param string $principal - * @param string $id - * @param array $properties - * @return array|null * @throws \Sabre\DAV\Exception\BadRequest */ - public function ensureSystemAddressBookExists($principal, $id, $properties) { - $book = $this->backend->getAddressBooksByUri($principal, $id); - if (!is_null($book)) { - return $book; - } - $this->backend->createAddressBook($principal, $id, $properties); - - return $this->backend->getAddressBooksByUri($principal, $id); - } - - /** - * Check if there is a valid certPath we should use - * - * @return string - */ - protected function getCertPath() { + public function ensureSystemAddressBookExists(string $principal, string $uri, array $properties): ?array { + try { + return $this->atomic(function () use ($principal, $uri, $properties) { + $book = $this->backend->getAddressBooksByUri($principal, $uri); + if (!is_null($book)) { + return $book; + } + $this->backend->createAddressBook($principal, $uri, $properties); + + return $this->backend->getAddressBooksByUri($principal, $uri); + }, $this->dbConnection); + } catch (Exception $e) { + // READ COMMITTED doesn't prevent a nonrepeatable read above, so + // two processes might create an address book here. Ignore our + // failure and continue loading the entry written by the other process + if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } - // we already have a valid certPath - if ($this->certPath !== '') { - return $this->certPath; + // If this fails we might have hit a replication node that does not + // have the row written in the other process. + // TODO: find an elegant way to handle this + $ab = $this->backend->getAddressBooksByUri($principal, $uri); + if ($ab === null) { + throw new Exception('Could not create system address book', $e->getCode(), $e); + } + return $ab; } + } - $certManager = \OC::$server->getCertificateManager(); - $certPath = $certManager->getAbsoluteBundlePath(); - if (file_exists($certPath)) { - $this->certPath = $certPath; - } + public function ensureLocalSystemAddressBookExists(): ?array { + return $this->ensureSystemAddressBookExists('principals/system/system', 'system', [ + '{' . Plugin::NS_CARDDAV . '}addressbook-description' => 'System addressbook which holds all users of this instance' + ]); + } - return $this->certPath; + private function prepareUri(string $host, string $path): string { + /* + * The trailing slash is important for merging the uris. + * + * $host is stored in oc_trusted_servers.url and usually without a trailing slash. + * + * Example for a report request + * + * $host = 'https://server.internal/cloud' + * $path = 'remote.php/dav/addressbooks/system/system/system' + * + * Without the trailing slash, the webroot is missing: + * https://server.internal/remote.php/dav/addressbooks/system/system/system + * + * Example for a download request + * + * $host = 'https://server.internal/cloud' + * $path = '/cloud/remote.php/dav/addressbooks/system/system/system/Database:alice.vcf' + * + * The response from the remote usually contains the webroot already and must be normalized to: + * https://server.internal/cloud/remote.php/dav/addressbooks/system/system/system/Database:alice.vcf + */ + $host = rtrim($host, '/') . '/'; + + $uri = \GuzzleHttp\Psr7\UriResolver::resolve( + \GuzzleHttp\Psr7\Utils::uriFor($host), + \GuzzleHttp\Psr7\Utils::uriFor($path) + ); + + return (string)$uri; } /** - * @param string $url - * @param string $userName - * @param string $addressBookUrl - * @param string $sharedSecret - * @return Client + * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool} + * @throws ClientExceptionInterface + * @throws ParseException */ - protected function getClient($url, $userName, $sharedSecret) { - $settings = [ - 'baseUri' => $url . '/', - 'userName' => $userName, - 'password' => $sharedSecret, + protected function requestSyncReport(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken): array { + $client = $this->clientService->newClient(); + $uri = $this->prepareUri($url, $addressBookUrl); + + $options = [ + 'auth' => [$userName, $sharedSecret], + 'body' => $this->buildSyncCollectionRequestBody($syncToken), + 'headers' => ['Content-Type' => 'application/xml'], + 'timeout' => $this->config->getSystemValueInt('carddav_sync_request_timeout', IClient::DEFAULT_REQUEST_TIMEOUT), + 'verify' => !$this->config->getSystemValue('sharing.federation.allowSelfSignedCertificates', false), ]; - $client = new Client($settings); - $certPath = $this->getCertPath(); - $client->setThrowExceptions(true); - if ($certPath !== '' && strpos($url, 'http://') !== 0) { - $client->addCurlSetting(CURLOPT_CAINFO, $this->certPath); - } + $response = $client->request( + 'REPORT', + $uri, + $options + ); - return $client; - } + $body = $response->getBody(); + assert(is_string($body)); - /** - * @param string $url - * @param string $userName - * @param string $addressBookUrl - * @param string $sharedSecret - * @param string $syncToken - * @return array - */ - protected function requestSyncReport($url, $userName, $addressBookUrl, $sharedSecret, $syncToken) { - $client = $this->getClient($url, $userName, $sharedSecret); + return $this->parseMultiStatus($body, $addressBookUrl); + } - $body = $this->buildSyncCollectionRequestBody($syncToken); + protected function download(string $url, string $userName, string $sharedSecret, string $resourcePath): string { + $client = $this->clientService->newClient(); + $uri = $this->prepareUri($url, $resourcePath); - $response = $client->request('REPORT', $addressBookUrl, $body, [ - 'Content-Type' => 'application/xml' - ]); + $options = [ + 'auth' => [$userName, $sharedSecret], + 'verify' => !$this->config->getSystemValue('sharing.federation.allowSelfSignedCertificates', false), + ]; - return $this->parseMultiStatus($response['body']); - } + $response = $client->get( + $uri, + $options + ); - /** - * @param string $url - * @param string $userName - * @param string $sharedSecret - * @param string $resourcePath - * @return array - */ - protected function download($url, $userName, $sharedSecret, $resourcePath) { - $client = $this->getClient($url, $userName, $sharedSecret); - return $client->request('GET', $resourcePath); + return (string)$response->getBody(); } - /** - * @param string|null $syncToken - * @return string - */ - private function buildSyncCollectionRequestBody($syncToken) { + private function buildSyncCollectionRequestBody(?string $syncToken): string { $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->formatOutput = true; $root = $dom->createElementNS('DAV:', 'd:sync-collection'); - $sync = $dom->createElement('d:sync-token', $syncToken); + $sync = $dom->createElement('d:sync-token', $syncToken ?? ''); $prop = $dom->createElement('d:prop'); $cont = $dom->createElement('d:getcontenttype'); $etag = $dom->createElement('d:getetag'); @@ -241,50 +226,77 @@ class SyncService { } /** - * @param string $body - * @return array - * @throws \Sabre\Xml\ParseException + * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool} + * @throws ParseException */ - private function parseMultiStatus($body) { - $xml = new Service(); - + private function parseMultiStatus(string $body, string $addressBookUrl): array { /** @var MultiStatus $multiStatus */ - $multiStatus = $xml->expect('{DAV:}multistatus', $body); + $multiStatus = (new Service())->expect('{DAV:}multistatus', $body); $result = []; + $truncated = false; + foreach ($multiStatus->getResponses() as $response) { - $result[$response->getHref()] = $response->getResponseProperties(); + $href = $response->getHref(); + if ($response->getHttpStatus() === '507' && $this->isResponseForRequestUri($href, $addressBookUrl)) { + $truncated = true; + } else { + $result[$response->getHref()] = $response->getResponseProperties(); + } } - return ['response' => $result, 'token' => $multiStatus->getSyncToken()]; + return ['response' => $result, 'token' => $multiStatus->getSyncToken(), 'truncated' => $truncated]; + } + + /** + * Determines whether the provided response URI corresponds to the given request URI. + */ + private function isResponseForRequestUri(string $responseUri, string $requestUri): bool { + /* + * Example response uri: + * + * /remote.php/dav/addressbooks/system/system/system/ + * /cloud/remote.php/dav/addressbooks/system/system/system/ (when installed in a subdirectory) + * + * Example request uri: + * + * remote.php/dav/addressbooks/system/system/system + * + * References: + * https://github.com/nextcloud/3rdparty/blob/e0a509739b13820f0a62ff9cad5d0fede00e76ee/sabre/dav/lib/DAV/Sync/Plugin.php#L172-L174 + * https://github.com/nextcloud/server/blob/b40acb34a39592070d8455eb91c5364c07928c50/apps/federation/lib/SyncFederationAddressBooks.php#L41 + */ + return str_ends_with( + rtrim($responseUri, '/'), + rtrim($requestUri, '/') + ); } /** * @param IUser $user */ - public function updateUser(IUser $user) { + public function updateUser(IUser $user): void { $systemAddressBook = $this->getLocalSystemAddressBook(); $addressBookId = $systemAddressBook['id']; - $converter = new Converter($this->accountManager); - $name = $user->getBackendClassName(); - $userId = $user->getUID(); - $cardId = "$name:$userId.vcf"; - $card = $this->backend->getCard($addressBookId, $cardId); + $cardId = self::getCardUri($user); if ($user->isEnabled()) { - if ($card === false) { - $vCard = $converter->createCardFromUser($user); - if ($vCard !== null) { - $this->backend->createCard($addressBookId, $cardId, $vCard->serialize()); - } - } else { - $vCard = $converter->createCardFromUser($user); - if (is_null($vCard)) { - $this->backend->deleteCard($addressBookId, $cardId); + $this->atomic(function () use ($addressBookId, $cardId, $user): void { + $card = $this->backend->getCard($addressBookId, $cardId); + if ($card === false) { + $vCard = $this->converter->createCardFromUser($user); + if ($vCard !== null) { + $this->backend->createCard($addressBookId, $cardId, $vCard->serialize(), false); + } } else { - $this->backend->updateCard($addressBookId, $cardId, $vCard->serialize()); + $vCard = $this->converter->createCardFromUser($user); + if (is_null($vCard)) { + $this->backend->deleteCard($addressBookId, $cardId); + } else { + $this->backend->updateCard($addressBookId, $cardId, $vCard->serialize()); + } } - } + }, $this->dbConnection); } else { $this->backend->deleteCard($addressBookId, $cardId); } @@ -296,10 +308,7 @@ class SyncService { public function deleteUser($userOrCardId) { $systemAddressBook = $this->getLocalSystemAddressBook(); if ($userOrCardId instanceof IUser) { - $name = $userOrCardId->getBackendClassName(); - $userId = $userOrCardId->getUID(); - - $userOrCardId = "$name:$userId.vcf"; + $userOrCardId = self::getCardUri($userOrCardId); } $this->backend->deleteCard($systemAddressBook['id'], $userOrCardId); } @@ -309,18 +318,18 @@ class SyncService { */ public function getLocalSystemAddressBook() { if (is_null($this->localSystemAddressBook)) { - $systemPrincipal = "principals/system/system"; - $this->localSystemAddressBook = $this->ensureSystemAddressBookExists($systemPrincipal, 'system', [ - '{' . Plugin::NS_CARDDAV . '}addressbook-description' => 'System addressbook which holds all users of this instance' - ]); + $this->localSystemAddressBook = $this->ensureLocalSystemAddressBookExists(); } return $this->localSystemAddressBook; } - public function syncInstance(\Closure $progressCallback = null) { + /** + * @return void + */ + public function syncInstance(?\Closure $progressCallback = null) { $systemAddressBook = $this->getLocalSystemAddressBook(); - $this->userManager->callForAllUsers(function ($user) use ($systemAddressBook, $progressCallback) { + $this->userManager->callForAllUsers(function ($user) use ($systemAddressBook, $progressCallback): void { $this->updateUser($user); if (!is_null($progressCallback)) { $progressCallback(); @@ -338,4 +347,12 @@ class SyncService { } } } + + /** + * @param IUser $user + * @return string + */ + public static function getCardUri(IUser $user): string { + return $user->getBackendClassName() . ':' . $user->getUID() . '.vcf'; + } } diff --git a/apps/dav/lib/CardDAV/SystemAddressbook.php b/apps/dav/lib/CardDAV/SystemAddressbook.php index c7190c81319..912a2f1dcee 100644 --- a/apps/dav/lib/CardDAV/SystemAddressbook.php +++ b/apps/dav/lib/CardDAV/SystemAddressbook.php @@ -3,51 +3,335 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\DAV\CardDAV; +use OCA\Federation\TrustedServers; +use OCP\Accounts\IAccountManager; use OCP\IConfig; +use OCP\IGroupManager; use OCP\IL10N; +use OCP\IRequest; +use OCP\IUserSession; use Sabre\CardDAV\Backend\BackendInterface; +use Sabre\CardDAV\Backend\SyncSupport; +use Sabre\CardDAV\Card; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\VObject\Component\VCard; +use Sabre\VObject\Reader; +use function array_filter; +use function array_intersect; +use function array_unique; +use function in_array; class SystemAddressbook extends AddressBook { - /** @var IConfig */ - private $config; + public const URI_SHARED = 'z-server-generated--system'; - public function __construct(BackendInterface $carddavBackend, array $addressBookInfo, IL10N $l10n, IConfig $config) { + public function __construct( + BackendInterface $carddavBackend, + array $addressBookInfo, + IL10N $l10n, + private IConfig $config, + private IUserSession $userSession, + private ?IRequest $request = null, + private ?TrustedServers $trustedServers = null, + private ?IGroupManager $groupManager = null, + ) { parent::__construct($carddavBackend, $addressBookInfo, $l10n); - $this->config = $config; + + $this->addressBookInfo['{DAV:}displayname'] = $l10n->t('Accounts'); + $this->addressBookInfo['{' . Plugin::NS_CARDDAV . '}addressbook-description'] = $l10n->t('System address book which holds all accounts'); } + /** + * No checkbox checked -> Show only the same user + * 'Allow username autocompletion in share dialog' -> show everyone + * 'Allow username autocompletion in share dialog' + 'Allow username autocompletion to users within the same groups' -> show only users in intersecting groups + * 'Allow username autocompletion in share dialog' + 'Allow username autocompletion to users based on phone number integration' -> show only the same user + * 'Allow username autocompletion in share dialog' + 'Allow username autocompletion to users within the same groups' + 'Allow username autocompletion to users based on phone number integration' -> show only users in intersecting groups + */ public function getChildren() { $shareEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes'; - $restrictShareEnumeration = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; - if (!$shareEnumeration || ($shareEnumeration && $restrictShareEnumeration)) { + $shareEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; + $shareEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes'; + $user = $this->userSession->getUser(); + if (!$user) { + // Should never happen because we don't allow anonymous access return []; } + if ($user->getBackendClassName() === 'Guests' || !$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) { + $name = SyncService::getCardUri($user); + try { + return [parent::getChild($name)]; + } catch (NotFound $e) { + return []; + } + } + if ($shareEnumerationGroup) { + if ($this->groupManager === null) { + // Group manager is not available, so we can't determine which data is safe + return []; + } + $groups = $this->groupManager->getUserGroups($user); + $names = []; + foreach ($groups as $group) { + $users = $group->getUsers(); + foreach ($users as $groupUser) { + if ($groupUser->getBackendClassName() === 'Guests') { + continue; + } + $names[] = SyncService::getCardUri($groupUser); + } + } + return parent::getMultipleChildren(array_unique($names)); + } + + $children = parent::getChildren(); + return array_filter($children, function (Card $child) { + // check only for URIs that begin with Guests: + return !str_starts_with($child->getName(), 'Guests:'); + }); + } + + /** + * @param array $paths + * @return Card[] + * @throws NotFound + */ + public function getMultipleChildren($paths): array { + $shareEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes'; + $shareEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; + $shareEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes'; + $user = $this->userSession->getUser(); + if (($user !== null && $user->getBackendClassName() === 'Guests') || !$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) { + // No user or cards with no access + if ($user === null || !in_array(SyncService::getCardUri($user), $paths, true)) { + return []; + } + // Only return the own card + try { + return [parent::getChild(SyncService::getCardUri($user))]; + } catch (NotFound $e) { + return []; + } + } + if ($shareEnumerationGroup) { + if ($this->groupManager === null || $user === null) { + // Group manager or user is not available, so we can't determine which data is safe + return []; + } + $groups = $this->groupManager->getUserGroups($user); + $allowedNames = []; + foreach ($groups as $group) { + $users = $group->getUsers(); + foreach ($users as $groupUser) { + if ($groupUser->getBackendClassName() === 'Guests') { + continue; + } + $allowedNames[] = SyncService::getCardUri($groupUser); + } + } + return parent::getMultipleChildren(array_intersect($paths, $allowedNames)); + } + if (!$this->isFederation()) { + return parent::getMultipleChildren($paths); + } + + $objs = $this->carddavBackend->getMultipleCards($this->addressBookInfo['id'], $paths); + $children = []; + /** @var array $obj */ + foreach ($objs as $obj) { + if (empty($obj)) { + continue; + } + $carddata = $this->extractCarddata($obj); + if (empty($carddata)) { + continue; + } else { + $obj['carddata'] = $carddata; + } + $children[] = new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + return $children; + } + + /** + * @param string $name + * @return Card + * @throws NotFound + * @throws Forbidden + */ + public function getChild($name): Card { + $user = $this->userSession->getUser(); + $shareEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes'; + $shareEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; + $shareEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes'; + if (($user !== null && $user->getBackendClassName() === 'Guests') || !$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) { + $ownName = $user !== null ? SyncService::getCardUri($user) : null; + if ($ownName === $name) { + return parent::getChild($name); + } + throw new Forbidden(); + } + if ($shareEnumerationGroup) { + if ($user === null || $this->groupManager === null) { + // Group manager is not available, so we can't determine which data is safe + throw new Forbidden(); + } + $groups = $this->groupManager->getUserGroups($user); + foreach ($groups as $group) { + foreach ($group->getUsers() as $groupUser) { + if ($groupUser->getBackendClassName() === 'Guests') { + continue; + } + $otherName = SyncService::getCardUri($groupUser); + if ($otherName === $name) { + return parent::getChild($name); + } + } + } + throw new Forbidden(); + } + if (!$this->isFederation()) { + return parent::getChild($name); + } + + $obj = $this->carddavBackend->getCard($this->addressBookInfo['id'], $name); + if (!$obj) { + throw new NotFound('Card not found'); + } + $carddata = $this->extractCarddata($obj); + if (empty($carddata)) { + throw new Forbidden(); + } else { + $obj['carddata'] = $carddata; + } + return new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + public function getChanges($syncToken, $syncLevel, $limit = null) { + + if (!$this->carddavBackend instanceof SyncSupport) { + return null; + } + + if (!$this->isFederation()) { + return parent::getChanges($syncToken, $syncLevel, $limit); + } + + $changed = $this->carddavBackend->getChangesForAddressBook( + $this->addressBookInfo['id'], + $syncToken, + $syncLevel, + $limit + ); + + if (empty($changed)) { + return $changed; + } + + $added = $modified = $deleted = []; + foreach ($changed['added'] as $uri) { + try { + $this->getChild($uri); + $added[] = $uri; + } catch (NotFound|Forbidden $e) { + $deleted[] = $uri; + } + } + foreach ($changed['modified'] as $uri) { + try { + $this->getChild($uri); + $modified[] = $uri; + } catch (NotFound|Forbidden $e) { + $deleted[] = $uri; + } + } + $changed['added'] = $added; + $changed['modified'] = $modified; + $changed['deleted'] = $deleted; + return $changed; + } + + private function isFederation(): bool { + if ($this->trustedServers === null || $this->request === null) { + return false; + } + + /** @psalm-suppress NoInterfaceProperties */ + $server = $this->request->server; + if (!isset($server['PHP_AUTH_USER']) || $server['PHP_AUTH_USER'] !== 'system') { + return false; + } + + /** @psalm-suppress NoInterfaceProperties */ + $sharedSecret = $server['PHP_AUTH_PW'] ?? null; + if ($sharedSecret === null) { + return false; + } + + $servers = $this->trustedServers->getServers(); + $trusted = array_filter($servers, function ($trustedServer) use ($sharedSecret) { + return $trustedServer['shared_secret'] === $sharedSecret; + }); + // Authentication is fine, but it's not for a federated share + if (empty($trusted)) { + return false; + } + + return true; + } + + /** + * If the validation doesn't work the card is "not found" so we + * return empty carddata even if the carddata might exist in the local backend. + * This can happen when a user sets the required properties + * FN, N to a local scope only but the request is from + * a federated share. + * + * @see https://github.com/nextcloud/server/issues/38042 + * + * @param array $obj + * @return string|null + */ + private function extractCarddata(array $obj): ?string { + $obj['acl'] = $this->getChildACL(); + $cardData = $obj['carddata']; + /** @var VCard $vCard */ + $vCard = Reader::read($cardData); + foreach ($vCard->children() as $child) { + $scope = $child->offsetGet('X-NC-SCOPE'); + if ($scope !== null && $scope->getValue() === IAccountManager::SCOPE_LOCAL) { + $vCard->remove($child); + } + } + $messages = $vCard->validate(); + if (!empty($messages)) { + return null; + } + + return $vCard->serialize(); + } + + /** + * @return mixed + * @throws Forbidden + */ + public function delete() { + if ($this->isFederation()) { + parent::delete(); + } + throw new Forbidden(); + } - return parent::getChildren(); + public function getACL() { + return array_filter(parent::getACL(), function ($acl) { + if (in_array($acl['privilege'], ['{DAV:}write', '{DAV:}all'], true)) { + return false; + } + return true; + }); } } diff --git a/apps/dav/lib/CardDAV/UserAddressBooks.php b/apps/dav/lib/CardDAV/UserAddressBooks.php index ce03ce43548..e29e52e77df 100644 --- a/apps/dav/lib/CardDAV/UserAddressBooks.php +++ b/apps/dav/lib/CardDAV/UserAddressBooks.php @@ -3,58 +3,47 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CardDAV; use OCA\DAV\AppInfo\PluginManager; -use OCA\DAV\CardDAV\Integration\IAddressBookProvider; use OCA\DAV\CardDAV\Integration\ExternalAddressBook; +use OCA\DAV\CardDAV\Integration\IAddressBookProvider; +use OCA\Federation\TrustedServers; +use OCP\AppFramework\QueryException; use OCP\IConfig; +use OCP\IGroupManager; use OCP\IL10N; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use OCP\Server; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; use Sabre\CardDAV\Backend; -use Sabre\DAV\Exception\MethodNotAllowed; use Sabre\CardDAV\IAddressBook; -use function array_map; +use Sabre\DAV\Exception\MethodNotAllowed; use Sabre\DAV\MkCol; +use function array_map; class UserAddressBooks extends \Sabre\CardDAV\AddressBookHome { - /** @var IL10N */ protected $l10n; /** @var IConfig */ protected $config; - /** @var PluginManager */ - private $pluginManager; - - public function __construct(Backend\BackendInterface $carddavBackend, - string $principalUri, - PluginManager $pluginManager) { + public function __construct( + Backend\BackendInterface $carddavBackend, + string $principalUri, + private PluginManager $pluginManager, + private ?IUser $user, + private ?IGroupManager $groupManager, + ) { parent::__construct($carddavBackend, $principalUri); - $this->pluginManager = $pluginManager; } /** @@ -67,18 +56,53 @@ class UserAddressBooks extends \Sabre\CardDAV\AddressBookHome { $this->l10n = \OC::$server->getL10N('dav'); } if ($this->config === null) { - $this->config = \OC::$server->getConfig(); + $this->config = Server::get(IConfig::class); } + /** @var string|array $principal */ + $principal = $this->principalUri; $addressBooks = $this->carddavBackend->getAddressBooksForUser($this->principalUri); - /** @var IAddressBook[] $objects */ - $objects = array_map(function (array $addressBook) { - if ($addressBook['principaluri'] === 'principals/system/system') { - return new SystemAddressbook($this->carddavBackend, $addressBook, $this->l10n, $this->config); + // add the system address book + $systemAddressBook = null; + $systemAddressBookExposed = $this->config->getAppValue('dav', 'system_addressbook_exposed', 'yes') === 'yes'; + if ($systemAddressBookExposed && is_string($principal) && $principal !== 'principals/system/system' && $this->carddavBackend instanceof CardDavBackend) { + $systemAddressBook = $this->carddavBackend->getAddressBooksByUri('principals/system/system', 'system'); + if ($systemAddressBook !== null) { + $systemAddressBook['uri'] = SystemAddressbook::URI_SHARED; } + } + if (!is_null($systemAddressBook)) { + $addressBooks[] = $systemAddressBook; + } - return new AddressBook($this->carddavBackend, $addressBook, $this->l10n); - }, $addressBooks); + $objects = []; + if (!empty($addressBooks)) { + /** @var IAddressBook[] $objects */ + $objects = array_map(function (array $addressBook) { + $trustedServers = null; + $request = null; + try { + $trustedServers = Server::get(TrustedServers::class); + $request = Server::get(IRequest::class); + } catch (QueryException|NotFoundExceptionInterface|ContainerExceptionInterface $e) { + // nothing to do, the request / trusted servers don't exist + } + if ($addressBook['principaluri'] === 'principals/system/system') { + return new SystemAddressbook( + $this->carddavBackend, + $addressBook, + $this->l10n, + $this->config, + Server::get(IUserSession::class), + $request, + $trustedServers, + $this->groupManager + ); + } + + return new AddressBook($this->carddavBackend, $addressBook, $this->l10n); + }, $addressBooks); + } /** @var IAddressBook[][] $objectsFromPlugins */ $objectsFromPlugins = array_map(function (IAddressBookProvider $plugin): array { return $plugin->fetchAllForAddressBookHome($this->principalUri); diff --git a/apps/dav/lib/CardDAV/Validation/CardDavValidatePlugin.php b/apps/dav/lib/CardDAV/Validation/CardDavValidatePlugin.php new file mode 100644 index 00000000000..a5fd80ec124 --- /dev/null +++ b/apps/dav/lib/CardDAV/Validation/CardDavValidatePlugin.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/* + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CardDAV\Validation; + +use OCA\DAV\AppInfo\Application; +use OCP\IAppConfig; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class CardDavValidatePlugin extends ServerPlugin { + + public function __construct( + private IAppConfig $config, + ) { + } + + public function initialize(Server $server): void { + $server->on('beforeMethod:PUT', [$this, 'beforePut']); + } + + public function beforePut(RequestInterface $request, ResponseInterface $response): bool { + // evaluate if card size exceeds defined limit + $cardSizeLimit = $this->config->getValueInt(Application::APP_ID, 'card_size_limit', 5242880); + if ((int)$request->getRawServerValue('CONTENT_LENGTH') > $cardSizeLimit) { + throw new Forbidden("VCard object exceeds $cardSizeLimit bytes"); + } + // all tests passed return true + return true; + } + +} diff --git a/apps/dav/lib/CardDAV/Xml/Groups.php b/apps/dav/lib/CardDAV/Xml/Groups.php index e23ff321c1e..07aeecb3fa2 100644 --- a/apps/dav/lib/CardDAV/Xml/Groups.php +++ b/apps/dav/lib/CardDAV/Xml/Groups.php @@ -1,27 +1,10 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CardDAV\Xml; use Sabre\Xml\Writer; @@ -30,14 +13,12 @@ use Sabre\Xml\XmlSerializable; class Groups implements XmlSerializable { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; - /** @var string[] of TYPE:CHECKSUM */ - private $groups; - /** - * @param string $groups + * @param list<string> $groups */ - public function __construct($groups) { - $this->groups = $groups; + public function __construct( + private array $groups, + ) { } public function xmlSerialize(Writer $writer) { |