diff options
Diffstat (limited to 'apps/dav/lib/CardDAV')
28 files changed, 5287 insertions, 0 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 new file mode 100644 index 00000000000..4d30d507a7d --- /dev/null +++ b/apps/dav/lib/CardDAV/AddressBook.php @@ -0,0 +1,261 @@ +<?php + +/** + * 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 OCP\DB\Exception; +use OCP\IL10N; +use OCP\Server; +use Psr\Log\LoggerInterface; +use Sabre\CardDAV\Backend\BackendInterface; +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 CardDavBackend $carddavBackend + */ +class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable, IMoveTarget { + /** + * AddressBook constructor. + * + * @param BackendInterface $carddavBackend + * @param array $addressBookInfo + * @param IL10N $l10n + */ + 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) { + $this->addressBookInfo['{DAV:}displayname'] = $l10n->t('Contacts'); + } + } + + /** + * Updates the list of shares. + * + * The first array is a list of people that are to be added to the + * addressbook. + * + * 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 + * * readOnly - A boolean value + * + * Every element in the remove array is just the address string. + * + * @param list<array{href: string, commonName: string, readOnly: bool}> $add + * @param list<string> $remove + * @throws Forbidden + */ + public function updateShares(array $add, array $remove): void { + if ($this->isShared()) { + throw new Forbidden(); + } + $this->carddavBackend->updateShares($this, $add, $remove); + } + + /** + * Returns the list of people whom this addressbook is shared with. + * + * Every element in this array should have the following properties: + * * href - Often a mailto: address + * * commonName - Optional, for example a first + last name + * * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants. + * * readOnly - boolean + * + * @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(): array { + if ($this->isShared()) { + return []; + } + return $this->carddavBackend->getShares($this->getResourceId()); + } + + public function getACL() { + $acl = [ + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner(), + 'protected' => true, + ],[ + 'privilege' => '{DAV:}write', + 'principal' => $this->getOwner(), + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}write-properties', + 'principal' => $this->getOwner(), + 'protected' => true, + ], + ]; + + if ($this->getOwner() === 'principals/system/system') { + $acl[] = [ + 'privilege' => '{DAV:}read', + 'principal' => '{DAV:}authenticated', + 'protected' => true, + ]; + $acl[] = [ + 'privilege' => '{DAV:}write-properties', + 'principal' => '{DAV:}authenticated', + 'protected' => true, + ]; + } + + if (!$this->isShared()) { + return $acl; + } + + if ($this->getOwner() !== parent::getOwner()) { + $acl[] = [ + 'privilege' => '{DAV:}read', + 'principal' => parent::getOwner(), + 'protected' => true, + ]; + if ($this->canWrite()) { + $acl[] = [ + 'privilege' => '{DAV:}write', + 'principal' => parent::getOwner(), + 'protected' => true, + ]; + } + } + + $acl = $this->carddavBackend->applyShareAcl($this->getResourceId(), $acl); + $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); + }); + } + + public function getChildACL() { + return $this->getACL(); + } + + public function getChild($name) { + $obj = $this->carddavBackend->getCard($this->addressBookInfo['id'], $name); + if (!$obj) { + throw new NotFound('Card not found'); + } + $obj['acl'] = $this->getChildACL(); + return new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + + 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(): ?string { + if (isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { + return $this->addressBookInfo['{http://owncloud.org/ns}owner-principal']; + } + return parent::getOwner(); + } + + public function delete() { + if (isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { + $principal = 'principal:' . parent::getOwner(); + $shares = $this->carddavBackend->getShares($this->getResourceId()); + $shares = array_filter($shares, function ($share) use ($principal) { + return $share['href'] === $principal; + }); + if (empty($shares)) { + throw new Forbidden(); + } + + $this->carddavBackend->updateShares($this, [], [ + $principal + ]); + return; + } + parent::delete(); + } + + public function propPatch(PropPatch $propPatch) { + if (!isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { + parent::propPatch($propPatch); + } + } + + public function getContactsGroups() { + return $this->carddavBackend->collectCardProperties($this->getResourceId(), 'CATEGORIES'); + } + + private function isShared(): bool { + if (!isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { + return false; + } + + return $this->addressBookInfo['{http://owncloud.org/ns}owner-principal'] !== $this->addressBookInfo['principaluri']; + } + + private function canWrite(): bool { + if (isset($this->addressBookInfo['{http://owncloud.org/ns}read-only'])) { + return !$this->addressBookInfo['{http://owncloud.org/ns}read-only']; + } + return true; + } + + public function getChanges($syncToken, $syncLevel, $limit = null) { + + 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 new file mode 100644 index 00000000000..ae77498539b --- /dev/null +++ b/apps/dav/lib/CardDAV/AddressBookImpl.php @@ -0,0 +1,339 @@ +<?php + +/** + * 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\IAddressBookEnabled; +use OCP\IURLGenerator; +use Sabre\VObject\Component\VCard; +use Sabre\VObject\Property; +use Sabre\VObject\Reader; +use Sabre\VObject\UUIDUtil; + +class AddressBookImpl implements IAddressBookEnabled { + + /** + * AddressBookImpl constructor. + * + * @param AddressBook $addressBook + * @param array $addressBookInfo + * @param CardDavBackend $backend + * @param IUrlGenerator $urlGenerator + */ + public function __construct( + private AddressBook $addressBook, + private array $addressBookInfo, + private CardDavBackend $backend, + private IURLGenerator $urlGenerator, + private PropertyMapper $propertyMapper, + private ?string $userId, + ) { + } + + /** + * @return string defining the technical unique key + * @since 5.0.0 + */ + public function getKey() { + return $this->addressBookInfo['id']; + } + + /** + * @return string defining the unique uri + * @since 16.0.0 + */ + public function getUri(): string { + return $this->addressBookInfo['uri']; + } + + /** + * In comparison to getKey() this function returns a human readable (maybe translated) name + * + * @return mixed + * @since 5.0.0 + */ + public function getDisplayName() { + return $this->addressBookInfo['{DAV:}displayname']; + } + + /** + * @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 + * - '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']] + * ] + * @since 5.0.0 + */ + public function search($pattern, $searchProperties, $options) { + $results = $this->backend->search($this->getKey(), $pattern, $searchProperties, $options); + + $withTypes = \array_key_exists('types', $options) && $options['types'] === true; + + $vCards = []; + foreach ($results as $result) { + $vCards[] = $this->vCard2Array($result['uri'], $this->readCard($result['carddata']), $withTypes); + } + + return $vCards; + } + + /** + * @param array $properties this array if key-value-pairs defines a contact + * @return array an array representing the contact just created or updated + * @since 5.0.0 + */ + public function createOrUpdate($properties) { + $update = false; + if (!isset($properties['URI'])) { // create a new contact + $uid = $this->createUid(); + $uri = $uid . '.vcf'; + $vCard = $this->createEmptyVCard($uid); + } else { // update existing contact + $uri = $properties['URI']; + $vCardData = $this->backend->getCard($this->getKey(), $uri); + $vCard = $this->readCard($vCardData['carddata']); + $update = true; + } + + foreach ($properties as $key => $value) { + if (is_array($value)) { + $vCard->remove($key); + foreach ($value as $entry) { + 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); + } + } elseif ($key !== 'URI') { + $vCard->$key = $vCard->createProperty($key, $value); + } + } + + if ($update) { + $this->backend->updateCard($this->getKey(), $uri, $vCard->serialize()); + } else { + $this->backend->createCard($this->getKey(), $uri, $vCard->serialize()); + } + + return $this->vCard2Array($uri, $vCard); + } + + /** + * @return mixed + * @since 5.0.0 + */ + public function getPermissions() { + $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; + break; + case '{DAV:}write': + $result |= Constants::PERMISSION_CREATE; + $result |= Constants::PERMISSION_UPDATE; + break; + case '{DAV:}all': + $result |= Constants::PERMISSION_ALL; + break; + } + } + + return $result; + } + + /** + * @param int $id the unique identifier to a contact + * @return bool successful or not + * @since 5.0.0 + */ + public function delete($id) { + $uri = $this->backend->getCardUri($id); + return $this->backend->deleteCard($this->addressBookInfo['id'], $uri); + } + + /** + * read vCard data into a vCard object + * + * @param string $cardData + * @return VCard + */ + protected function readCard($cardData) { + return Reader::read($cardData); + } + + /** + * create UID for contact + * + * @return string + */ + protected function createUid() { + do { + $uid = $this->getUid(); + $contact = $this->backend->getContact($this->getKey(), $uid . '.vcf'); + } while (!empty($contact)); + + return $uid; + } + + /** + * getUid is only there for testing, use createUid instead + */ + protected function getUid() { + return UUIDUtil::getUUID(); + } + + /** + * create empty vcard + * + * @param string $uid + * @return VCard + */ + protected function createEmptyVCard($uid) { + $vCard = new VCard(); + $vCard->UID = $uid; + return $vCard; + } + + /** + * create array with all vCard properties + * + * @param string $uri + * @param VCard $vCard + * @param boolean $withTypes (optional) return the values as arrays of value/type pairs + * @return array + */ + protected function vCard2Array($uri, VCard $vCard, $withTypes = false) { + $result = [ + 'URI' => $uri, + ]; + + foreach ($vCard->children() as $property) { + if ($property->name === 'PHOTO' && in_array($property->getValueType(), ['BINARY', 'URI'])) { + $url = $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkTo('', 'remote.php') . '/dav/'); + $url .= implode('/', [ + 'addressbooks', + substr($this->addressBookInfo['principaluri'], 11), //cut off 'principals/' + $this->addressBookInfo['uri'], + $uri + ]) . '?photo'; + + $result['PHOTO'] = 'VALUE=uri:' . $url; + } elseif (in_array($property->name, ['URL', 'GEO', 'CLOUD', 'ADR', 'EMAIL', 'IMPP', 'TEL', 'X-SOCIALPROFILE', 'RELATED', 'LANG', 'X-ADDRESSBOOKSERVER-MEMBER'])) { + if (!isset($result[$property->name])) { + $result[$property->name] = []; + } + + $type = $this->getTypeFromProperty($property); + if ($withTypes) { + $result[$property->name][] = [ + 'type' => $type, + 'value' => $property->getValue() + ]; + } else { + $result[$property->name][] = $property->getValue(); + } + } else { + $result[$property->name] = $property->getValue(); + } + } + + if ($this->isSystemAddressBook()) { + $result['isLocalSystemBook'] = true; + } + return $result; + } + + /** + * Get the type of the current property + * + * @param Property $property + * @return null|string + */ + protected function getTypeFromProperty(Property $property) { + $parameters = $property->parameters(); + // Type is the social network, when it's empty we don't need this. + if (isset($parameters['TYPE'])) { + /** @var \Sabre\VObject\Parameter $type */ + $type = $parameters['TYPE']; + return $type->getValue(); + } + + return null; + } + + /** + * @inheritDoc + */ + public function isShared(): bool { + if (!isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { + return false; + } + + return $this->addressBookInfo['principaluri'] + !== $this->addressBookInfo['{http://owncloud.org/ns}owner-principal']; + } + + /** + * @inheritDoc + */ + public function isSystemAddressBook(): bool { + return $this->addressBookInfo['principaluri'] === 'principals/system/system' && ( + $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 new file mode 100644 index 00000000000..5679a03545e --- /dev/null +++ b/apps/dav/lib/CardDAV/AddressBookRoot.php @@ -0,0 +1,57 @@ +<?php + +/** + * 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 { + + /** + * @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, + private PluginManager $pluginManager, + private ?IUser $user, + private ?IGroupManager $groupManager, + string $principalPrefix = 'principals', + ) { + parent::__construct($principalBackend, $carddavBackend, $principalPrefix); + } + + /** + * This method returns a node for a principal. + * + * The passed array contains principal information, and is guaranteed to + * at least contain a uri item. Other properties may or may not be + * supplied by the authentication backend. + * + * @param array $principal + * + * @return \Sabre\DAV\INode + */ + public function getChildForPrincipal(array $principal) { + return new UserAddressBooks($this->carddavBackend, $principal['uri'], $this->pluginManager, $this->user, $this->groupManager); + } + + public function getName() { + if ($this->principalPrefix === 'principals') { + return parent::getName(); + } + // Grabbing all the components of the principal path. + $parts = explode('/', $this->principalPrefix); + + // We are only interested in the second part. + return $parts[1]; + } +} 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 new file mode 100644 index 00000000000..a78686eb61d --- /dev/null +++ b/apps/dav/lib/CardDAV/CardDavBackend.php @@ -0,0 +1,1528 @@ +<?php + +/** + * 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; +use OCA\DAV\Events\AddressBookCreatedEvent; +use OCA\DAV\Events\AddressBookDeletedEvent; +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\IUserManager; +use PDO; +use Sabre\CardDAV\Backend\BackendInterface; +use Sabre\CardDAV\Backend\SyncSupport; +use Sabre\CardDAV\Plugin; +use Sabre\DAV\Exception\BadRequest; +use Sabre\VObject\Component\VCard; +use Sabre\VObject\Reader; + +class CardDavBackend implements BackendInterface, SyncSupport { + use TTransactional; + public const PERSONAL_ADDRESSBOOK_URI = 'contacts'; + public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts'; + + private string $dbCardsTable = 'cards'; + private string $dbCardsPropertiesTable = 'cards_properties'; + + /** @var array properties to index */ + public static array $indexProperties = [ + 'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME', + 'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO', + 'CLOUD', 'X-SOCIALPROFILE']; + + /** + * @var string[] Map of uid => display name + */ + 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, + ) { + } + + /** + * Return the number of address books for a principal + * + * @param $principalUri + * @return int + */ + public function getAddressBooksForUserCount($principalUri) { + $principalUri = $this->convertPrincipal($principalUri, true); + $query = $this->db->getQueryBuilder(); + $query->select($query->func()->count('*')) + ->from('addressbooks') + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); + + $result = $query->executeQuery(); + $column = (int)$result->fetchOne(); + $result->closeCursor(); + return $column; + } + + /** + * Returns the list of address books for a specific user. + * + * Every addressbook should have the following properties: + * id - an arbitrary unique id + * uri - the 'basename' part of the url + * principaluri - Same as the passed parameter + * + * Any additional clark-notation property may be passed besides this. Some + * common ones are : + * {DAV:}displayname + * {urn:ietf:params:xml:ns:carddav}addressbook-description + * {http://calendarserver.org/ns/}getctag + * + * @param string $principalUri + * @return array + */ + public function getAddressBooksForUser($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 = []; + + $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(); + + // query for shared addressbooks + $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true); + + $principals[] = $principalUri; + + $select = $this->db->getQueryBuilder(); + $subSelect = $this->db->getQueryBuilder(); + + $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)); + + + $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; + } + + $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; + } + } + + [, $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); + }, $this->db); + } + + public function getUsersOwnAddressBooks($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))); + + $addressBooks = []; + + $result = $query->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(); + + return array_values($addressBooks); + } + + /** + * @param int $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, IQueryBuilder::PARAM_INT))) + ->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + if (!$row) { + return null; + } + + $addressBook = [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'principaluri' => $row['principaluri'], + '{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($addressBook); + + return $addressBook; + } + + 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) + ->executeQuery(); + + $row = $result->fetch(); + $result->closeCursor(); + if ($row === false) { + return null; + } + + $addressBook = [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'principaluri' => $row['principaluri'], + '{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', + + ]; + + // 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; + } + + /** + * Updates properties for an address book. + * + * The list of mutations is stored in a Sabre\DAV\PropPatch object. + * To do the actual updates, you must tell this object which properties + * you're going to process with the handle() method. + * + * Calling the handle method is like telling the PropPatch object "I + * promise I can handle updating this property". + * + * Read the PropPatch documentation for more info and examples. + * + * @param string $addressBookId + * @param \Sabre\DAV\PropPatch $propPatch + * @return void + */ + public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) { + $supportedProperties = [ + '{DAV:}displayname', + '{' . Plugin::NS_CARDDAV . '}addressbook-description', + ]; + + $propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) { + $updates = []; + foreach ($mutations as $property => $newValue) { + switch ($property) { + case '{DAV:}displayname': + $updates['displayname'] = $newValue; + break; + case '{' . Plugin::NS_CARDDAV . '}addressbook-description': + $updates['description'] = $newValue; + break; + } + } + [$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))) + ->executeStatement(); + + $this->addChange($addressBookId, '', 2); + + $addressBookRow = $this->getAddressBookById((int)$addressBookId); + $shares = $this->getShares((int)$addressBookId); + return [$addressBookRow, $shares]; + }, $this->db); + + $this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations)); + + return true; + }); + } + + /** + * Creates a new address book + * + * @param string $principalUri + * @param string $url Just the 'basename' of the url. + * @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, + 'principaluri' => $principalUri, + 'uri' => $url, + 'synctoken' => 1 + ]; + + foreach ($properties as $property => $newValue) { + switch ($property) { + case '{DAV:}displayname': + $values['displayname'] = $newValue; + break; + case '{' . Plugin::NS_CARDDAV . '}addressbook-description': + $values['description'] = $newValue; + break; + default: + throw new BadRequest('Unknown property: ' . $property); + } + } + + // Fallback to make sure the displayname is set. Some clients may refuse + // to work with addressbooks not having a displayname. + if (is_null($values['displayname'])) { + $values['displayname'] = $url; + } + + [$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; + } + + /** + * Deletes an entire addressbook and all its contents + * + * @param mixed $addressBookId + * @return void + */ + public function deleteAddressBook($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, IQueryBuilder::PARAM_INT) + ->executeStatement(); + + $query = $this->db->getQueryBuilder(); + $query->delete('addressbookchanges') + ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid'))) + ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT) + ->executeStatement(); + + $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); + + $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($addressBookId, $addressBookData, $shares)); + } + }, $this->db); + } + + /** + * Returns all cards for a specific addressbook id. + * + * This method should return the following properties for each card: + * * carddata - raw vcard data + * * uri - Some unique url + * * lastmodified - A unix timestamp + * + * It's recommended to also return the following properties: + * * etag - A unique etag. This must change every time the card changes. + * * 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 omit carddata. + * This may speed up certain requests, especially with large cards. + * + * @param mixed $addressbookId + * @return array + */ + public function getCards($addressbookId) { + $query = $this->db->getQueryBuilder(); + $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) + ->from($this->dbCardsTable) + ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId))); + + $cards = []; + + $result = $query->executeQuery(); + while ($row = $result->fetch()) { + $row['etag'] = '"' . $row['etag'] . '"'; + + $modified = false; + $row['carddata'] = $this->readBlob($row['carddata'], $modified); + if ($modified) { + $row['size'] = strlen($row['carddata']); + } + + $cards[] = $row; + } + $result->closeCursor(); + + return $cards; + } + + /** + * Returns a specific card. + * + * The same set of properties must be returned as with getCards. The only + * exception is that 'carddata' is absolutely required. + * + * If the card does not exist, you must return false. + * + * @param mixed $addressBookId + * @param string $cardUri + * @return array + */ + public function getCard($addressBookId, $cardUri) { + $query = $this->db->getQueryBuilder(); + $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->executeQuery(); + $row = $result->fetch(); + if (!$row) { + return false; + } + $row['etag'] = '"' . $row['etag'] . '"'; + + $modified = false; + $row['carddata'] = $this->readBlob($row['carddata'], $modified); + if ($modified) { + $row['size'] = strlen($row['carddata']); + } + + return $row; + } + + /** + * Returns a list of cards. + * + * This method should work identical to getCard, but instead return all the + * cards in the list as an array. + * + * If the backend supports this, it may allow for some speed-ups. + * + * @param mixed $addressBookId + * @param array $uris + * @return array + */ + public function getMultipleCards($addressBookId, array $uris) { + if (empty($uris)) { + return []; + } + + $chunks = array_chunk($uris, 100); + $cards = []; + + $query = $this->db->getQueryBuilder(); + $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->executeQuery(); + + while ($row = $result->fetch()) { + $row['etag'] = '"' . $row['etag'] . '"'; + + $modified = false; + $row['carddata'] = $this->readBlob($row['carddata'], $modified); + if ($modified) { + $row['size'] = strlen($row['carddata']); + } + + $cards[] = $row; + } + $result->closeCursor(); + } + return $cards; + } + + /** + * Creates a new card. + * + * The addressbook id will be passed as the first argument. This is the + * same id as it is returned from the getAddressBooksForUser method. + * + * The cardUri is a base uri, and doesn't include the full path. The + * cardData argument is the vcard body, and is passed as a string. + * + * It is possible to return an ETag from this method. This ETag is for the + * newly created resource, and must be enclosed with double quotes (that + * is, the string itself must contain the double quotes). + * + * You should only return the ETag if you store the carddata as-is. If a + * subsequent GET request on the same card does not have the same body, + * byte-by-byte and you did return an ETag here, clients tend to get + * confused. + * + * If you don't return an ETag, you can just return null. + * + * @param mixed $addressBookId + * @param string $cardUri + * @param string $cardData + * @param bool $checkAlreadyExists + * @return string + */ + 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.'); + } + } + + $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(); + + $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); + } + + /** + * Updates a card. + * + * The addressbook id will be passed as the first argument. This is the + * same id as it is returned from the getAddressBooksForUser method. + * + * The cardUri is a base uri, and doesn't include the full path. The + * cardData argument is the vcard body, and is passed as a string. + * + * It is possible to return an ETag from this method. This ETag should + * match that of the updated resource, and must be enclosed with double + * quotes (that is: the string itself must contain the actual quotes). + * + * You should only return the ETag if you store the carddata as-is. If a + * subsequent GET request on the same card does not have the same body, + * byte-by-byte and you did return an ETag here, clients tend to get + * confused. + * + * If you don't return an ETag, you can just return null. + * + * @param mixed $addressBookId + * @param string $cardUri + * @param string $cardData + * @return string + */ + public function updateCard($addressBookId, $cardUri, $cardData) { + $uid = $this->getUID($cardData); + $etag = md5($cardData); + + 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); + } + + /** + * @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); + } + + /** + * Deletes a card + * + * @param mixed $addressBookId + * @param string $cardUri + * @return bool + */ + public function deleteCard($addressBookId, $cardUri) { + return $this->atomic(function () use ($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))) + ->executeStatement(); + + $this->addChange($addressBookId, $cardUri, 3); + + if ($ret === 1) { + if ($cardId !== null) { + $this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow)); + $this->purgeProperties($addressBookId, $cardId); + } + return true; + } + + return false; + }, $this->db); + } + + /** + * The getChanges method returns all the changes that have happened, since + * the specified syncToken in the specified address book. + * + * This function should return an array, such as the following: + * + * [ + * 'syncToken' => 'The current synctoken', + * 'added' => [ + * 'new.txt', + * ], + * 'modified' => [ + * 'modified.txt', + * ], + * 'deleted' => [ + * 'foo.php.bak', + * 'old.txt' + * ] + * ]; + * + * The returned syncToken property should reflect the *current* syncToken + * of the calendar, as reported in the {http://sabredav.org/ns}sync-token + * property. This is needed here too, to ensure the operation is atomic. + * + * If the $syncToken argument is specified as null, this is an initial + * sync, and all members should be reported. + * + * The modified property is an array of nodenames that have changed since + * the last token. + * + * The deleted property is an array with nodenames, that have been deleted + * from collection. + * + * The $syncLevel argument is basically the 'depth' of the report. If it's + * 1, you only have to report changes that happened only directly in + * immediate descendants. If it's 2, it should also include changes from + * the nodes below the child collections. (grandchildren) + * + * The $limit argument allows a client to specify how many results should + * be returned at most. If the limit is not specified, it should be treated + * as infinite. + * + * If the limit (infinite or not) is higher than you're willing to return, + * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception. + * + * If the syncToken is expired (due to data cleanup) or unknown, you must + * return null. + * + * The limit is 'suggestive'. You are free to ignore it. + * + * @param string $addressBookId + * @param string $syncToken + * @param int $syncLevel + * @param int|null $limit + * @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 + return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) { + $qb = $this->db->getQueryBuilder(); + $qb->select('synctoken') + ->from('addressbooks') + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($addressBookId)) + ); + $stmt = $qb->executeQuery(); + $currentToken = $stmt->fetchOne(); + $stmt->closeCursor(); + + if (is_null($currentToken)) { + return []; + } + + $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); + } + + // Fetching all changes + $stmt = $qb->executeQuery(); + $rowCount = $stmt->rowCount(); + + $changes = []; + $highestSyncToken = 0; + + // 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(); + } + return $result; + }, $this->db); + } + + /** + * Adds a change record to the addressbookchanges table. + * + * @param mixed $addressBookId + * @param string $objectUri + * @param int $operation 1 = add, 2 = modify, 3 = delete + * @return void + */ + 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); + } + + /** + * @param resource|string $cardData + * @param bool $modified + * @return string + */ + private function readBlob($cardData, &$modified = false) { + if (is_resource($cardData)) { + $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 (str_starts_with($line, 'PHOTO:data:') + && !str_starts_with($line, 'PHOTO:data:image/')) { + // Filter out PHOTO data of non-images + $removingPhoto = true; + $modified = true; + continue; + } + + if ($removingPhoto) { + if (str_starts_with($line, ' ')) { + continue; + } + // No leading space means this is a new property + $removingPhoto = false; + } + + $cardDataFiltered[] = $line; + } + return implode("\r\n", $cardDataFiltered); + } + + /** + * @param list<array{href: string, commonName: string, readOnly: bool}> $add + * @param list<string> $remove + */ + 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, $oldShares); + + $this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove)); + }, $this->db); + } + + /** + * Search contacts in a specific address-book + * + * @param int $addressBookId + * @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 + * - '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->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) { + return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options); + }, $this->db); + } + + /** + * Search contacts in all address-books accessible by a user + * + * @param string $principalUri + * @param string $pattern + * @param array $searchProperties + * @param array $options + * @return array + */ + public function searchPrincipalUri(string $principalUri, + 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 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 { + 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; + + if ($escapePattern) { + $searchProperties = array_filter($searchProperties, function ($property) use ($pattern) { + if ($property === 'EMAIL' && str_contains($pattern, ' ')) { + // There can be no spaces in emails + 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/ + return false; + } + + return true; + }); + } + + if (empty($searchProperties)) { + return []; + } + + $query2 = $this->db->getQueryBuilder(); + $query2->selectDistinct('cp.cardid') + ->from($this->dbCardsPropertiesTable, 'cp') + ->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 (!$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']); + } + if (isset($options['offset'])) { + $query2->setFirstResult($options['offset']); + } + + 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->createParameter('matches'))); + + 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']; + $modified = false; + $array['carddata'] = $this->readBlob($array['carddata'], $modified); + if ($modified) { + $array['size'] = strlen($array['carddata']); + } + return $array; + }, $cards); + } + + /** + * @param int $bookId + * @param string $name + * @return array + */ + public function collectCardProperties($bookId, $name) { + $query = $this->db->getQueryBuilder(); + $result = $query->selectDistinct('value') + ->from($this->dbCardsPropertiesTable) + ->where($query->expr()->eq('name', $query->createNamedParameter($name))) + ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId))) + ->executeQuery(); + + $all = $result->fetchAll(PDO::FETCH_COLUMN); + $result->closeCursor(); + + return $all; + } + + /** + * get URI from a given contact + * + * @param int $id + * @return string + */ + public function getCardUri($id) { + $query = $this->db->getQueryBuilder(); + $query->select('uri')->from($this->dbCardsTable) + ->where($query->expr()->eq('id', $query->createParameter('id'))) + ->setParameter('id', $id); + + $result = $query->executeQuery(); + $uri = $result->fetch(); + $result->closeCursor(); + + if (!isset($uri['uri'])) { + throw new \InvalidArgumentException('Card does not exists: ' . $id); + } + + return $uri['uri']; + } + + /** + * return contact with the given URI + * + * @param int $addressBookId + * @param string $uri + * @returns array + */ + public function getContact($addressBookId, $uri) { + $result = []; + $query = $this->db->getQueryBuilder(); + $query->select('*')->from($this->dbCardsTable) + ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) + ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); + $queryResult = $query->executeQuery(); + $contact = $queryResult->fetch(); + $queryResult->closeCursor(); + + if (is_array($contact)) { + $modified = false; + $contact['etag'] = '"' . $contact['etag'] . '"'; + $contact['carddata'] = $this->readBlob($contact['carddata'], $modified); + if ($modified) { + $contact['size'] = strlen($contact['carddata']); + } + + $result = $contact; + } + + return $result; + } + + /** + * Returns the list of people whom this address book is shared with. + * + * Every element in this array should have the following properties: + * * href - Often a mailto: address + * * commonName - Optional, for example a first + last name + * * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants. + * * readOnly - boolean + * + * @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(int $addressBookId): array { + return $this->sharingBackend->getShares($addressBookId); + } + + /** + * update properties table + * + * @param int $addressBookId + * @param string $cardUri + * @param string $vCardSerialized + */ + protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) { + $this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void { + $cardId = $this->getCardId($addressBookId, $cardUri); + $vCard = $this->readCard($vCardSerialized); + + $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') + ] + ); + + 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(); + } + }, $this->db); + } + + /** + * read vCard data into a vCard object + * + * @param string $cardData + * @return VCard + */ + protected function readCard($cardData) { + return Reader::read($cardData); + } + + /** + * delete all properties from a given card + * + * @param int $addressBookId + * @param int $cardId + */ + protected function purgeProperties($addressBookId, $cardId) { + $query = $this->db->getQueryBuilder(); + $query->delete($this->dbCardsPropertiesTable) + ->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId))) + ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); + $query->executeStatement(); + } + + /** + * Get ID from a given contact + */ + 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->executeQuery(); + $cardIds = $result->fetch(); + $result->closeCursor(); + + if (!isset($cardIds['id'])) { + throw new \InvalidArgumentException('Card does not exists: ' . $uri); + } + + return (int)$cardIds['id']; + } + + /** + * For shared address books the sharee is set in the ACL of the address book + * + * @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(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(string $principalUri, bool $toV2): string { + if ($this->principalBackend->getPrincipalPrefix() === 'principals') { + [, $name] = \Sabre\Uri\split($principalUri); + if ($toV2 === true) { + return "principals/users/$name"; + } + return "principals/$name"; + } + return $principalUri; + } + + 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])) { + $uri = $addressbookInfo[$ownerPrincipalKey]; + } else { + $uri = $addressbookInfo['principaluri']; + } + + $principalInformation = $this->principalBackend->getPrincipalByPath($uri); + if (isset($principalInformation['{DAV:}displayname'])) { + $addressbookInfo[$displaynameKey] = $principalInformation['{DAV:}displayname']; + } + } + + /** + * Extract UID from vcard + * + * @param string $cardData the vcard raw data + * @return string the uid + * @throws BadRequest if no UID is available or vcard is empty + */ + private function getUID(string $cardData): string { + if ($cardData !== '') { + $vCard = Reader::read($cardData); + if ($vCard->UID) { + $uid = $vCard->UID->getValue(); + return $uid; + } + // should already be handled, but just in case + throw new BadRequest('vCards on CardDAV servers MUST have a UID property'); + } + // should already be handled, but just in case + throw new BadRequest('vCard can not be empty'); + } +} diff --git a/apps/dav/lib/CardDAV/ContactsManager.php b/apps/dav/lib/CardDAV/ContactsManager.php new file mode 100644 index 00000000000..b35137c902d --- /dev/null +++ b/apps/dav/lib/CardDAV/ContactsManager.php @@ -0,0 +1,71 @@ +<?php + +/** + * 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 { + /** + * ContactsManager constructor. + * + * @param CardDavBackend $backend + * @param IL10N $l10n + */ + public function __construct( + private CardDavBackend $backend, + private IL10N $l10n, + private PropertyMapper $propertyMapper, + ) { + } + + /** + * @param IManager $cm + * @param string $userId + * @param IURLGenerator $urlGenerator + */ + public function setupContactsProvider(IManager $cm, $userId, IURLGenerator $urlGenerator) { + $addressBooks = $this->backend->getAddressBooksForUser("principals/users/$userId"); + $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, ?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, ?string $userId) { + foreach ($addressBooks as $addressBookInfo) { + $addressBook = new AddressBook($this->backend, $addressBookInfo, $this->l10n); + $cm->registerAddressBook( + new AddressBookImpl( + $addressBook, + $addressBookInfo, + $this->backend, + $urlGenerator, + $this->propertyMapper, + $userId, + ) + ); + } + } +} diff --git a/apps/dav/lib/CardDAV/Converter.php b/apps/dav/lib/CardDAV/Converter.php new file mode 100644 index 00000000000..30dba99839e --- /dev/null +++ b/apps/dav/lib/CardDAV/Converter.php @@ -0,0 +1,186 @@ +<?php + +/** + * 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 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 { + public function __construct( + private IAccountManager $accountManager, + private IUserManager $userManager, + private IURLGenerator $urlGenerator, + private LoggerInterface $logger, + ) { + } + + public function createCardFromUser(IUser $user): ?VCard { + $userProperties = $this->accountManager->getAccount($user)->getAllProperties(); + + $uid = $user->getUID(); + $cloudId = $user->getCloudId(); + $image = $this->getAvatarImage($user); + + $vCard = new VCard(); + $vCard->VERSION = '3.0'; + $vCard->UID = $uid; + + $publish = false; + + foreach ($userProperties as $property) { + if ($property->getName() !== IAccountManager::PROPERTY_AVATAR && empty($property->getValue())) { + continue; + } + + $scope = $property->getScope(); + // Do not write private data to the system address book at all + if ($scope === IAccountManager::SCOPE_PRIVATE || empty($scope)) { + continue; + } + + $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; + } + $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, + ])); + } + } + + if ($publish && !empty($cloudId)) { + $vCard->add(new Text($vCard, 'CLOUD', $cloudId)); + $vCard->validate(); + return $vCard; + } + + return null; + } + + 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 ;) + + $elements = explode(' ', $fullName); + $result = ['', '', '', '', '']; + if (count($elements) > 2) { + $result[0] = implode(' ', array_slice($elements, count($elements) - 1)); + $result[1] = $elements[0]; + $result[2] = implode(' ', array_slice($elements, 1, count($elements) - 2)); + } elseif (count($elements) === 2) { + $result[0] = $elements[1]; + $result[1] = $elements[0]; + } else { + $result[0] = $elements[0]; + } + + return $result; + } + + private function getAvatarImage(IUser $user): ?IImage { + try { + 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 new file mode 100644 index 00000000000..6e2e0423910 --- /dev/null +++ b/apps/dav/lib/CardDAV/HasPhotoPlugin.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CardDAV; + +use Sabre\CardDAV\Card; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\VObject\Component\VCard; +use Sabre\VObject\Reader; + +class HasPhotoPlugin extends ServerPlugin { + + /** @var Server */ + protected $server; + + /** + * Initializes the plugin and registers event handlers + * + * @param Server $server + * @return void + */ + public function initialize(Server $server) { + $server->on('propFind', [$this, 'propFind']); + } + + /** + * Adds all CardDAV-specific properties + * + * @param PropFind $propFind + * @param INode $node + * @return void + */ + public function propFind(PropFind $propFind, INode $node) { + $ns = '{http://nextcloud.com/ns}'; + + if ($node instanceof Card) { + $propFind->handle($ns . 'has-photo', function () use ($node) { + $vcard = Reader::read($node->get()); + 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/ + && (!str_starts_with($vcard->PHOTO->getValue(), 'data:') + || str_starts_with($vcard->PHOTO->getValue(), 'data:image/')) + ; + }); + } + } + + /** + * Returns a plugin name. + * + * Using this name other plugins will be able to access other plugins + * using \Sabre\DAV\Server::getPlugin + * + * @return string + */ + public function getPluginName() { + return 'vcard-has-photo'; + } + + /** + * Returns a bunch of meta-data about the plugin. + * + * Providing this information is optional, and is mainly displayed by the + * Browser plugin. + * + * The description key in the returned array may contain html and will not + * be sanitized. + * + * @return array + */ + public function getPluginInfo() { + return [ + 'name' => $this->getPluginName(), + 'description' => 'Return a boolean stating if the vcard have a photo property set or not.' + ]; + } +} diff --git a/apps/dav/lib/CardDAV/ImageExportPlugin.php b/apps/dav/lib/CardDAV/ImageExportPlugin.php new file mode 100644 index 00000000000..74a8b032e42 --- /dev/null +++ b/apps/dav/lib/CardDAV/ImageExportPlugin.php @@ -0,0 +1,99 @@ +<?php + +/** + * 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; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class ImageExportPlugin extends ServerPlugin { + + /** @var Server */ + protected $server; + + /** + * ImageExportPlugin constructor. + * + * @param PhotoCache $cache + */ + public function __construct( + private PhotoCache $cache, + ) { + } + + /** + * Initializes the plugin and registers event handlers + * + * @param Server $server + * @return void + */ + public function initialize(Server $server) { + $this->server = $server; + $this->server->on('method:GET', [$this, 'httpGet'], 90); + } + + /** + * Intercepts GET requests on addressbook urls ending with ?photo. + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return bool + */ + public function httpGet(RequestInterface $request, ResponseInterface $response) { + $queryParams = $request->getQueryParameters(); + // TODO: in addition to photo we should also add logo some point in time + if (!array_key_exists('photo', $queryParams)) { + return true; + } + + $size = isset($queryParams['size']) ? (int)$queryParams['size'] : -1; + + $path = $request->getPath(); + $node = $this->server->tree->getNodeForPath($path); + + if (!$node instanceof Card) { + return true; + } + + $this->server->transactionType = 'carddav-image-export'; + + // Checking ACL, if available. + if ($aclPlugin = $this->server->getPlugin('acl')) { + /** @var \Sabre\DAVACL\Plugin $aclPlugin */ + $aclPlugin->checkPrivileges($path, '{DAV:}read'); + } + + // Fetch addressbook + $addressbookpath = explode('/', $path); + array_pop($addressbookpath); + $addressbookpath = implode('/', $addressbookpath); + /** @var AddressBook $addressbook */ + $addressbook = $this->server->tree->getNodeForPath($addressbookpath); + + $response->setHeader('Cache-Control', 'private, max-age=3600, must-revalidate'); + $response->setHeader('Etag', $node->getETag()); + + 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(Http::STATUS_OK); + + $response->setBody($file->getContent()); + } catch (NotFoundException $e) { + $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 new file mode 100644 index 00000000000..372906a6ae8 --- /dev/null +++ b/apps/dav/lib/CardDAV/Integration/ExternalAddressBook.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CardDAV\Integration; + +use Sabre\CardDAV\IAddressBook; +use Sabre\DAV; + +/** + * @since 19.0.0 + */ +abstract class ExternalAddressBook implements IAddressBook, DAV\IProperties { + + /** @var string */ + private const PREFIX = 'z-app-generated'; + + /** + * @var string + * + * Double dash is a valid delimiter, + * because it will always split the URIs correctly: + * - our prefix contains only one dash and won't be split + * - appIds are not allowed to contain dashes as per spec: + * > must contain only lowercase ASCII characters and underscore + * - explode has a limit of three, so even if the app-generated + * URI has double dashes, it won't be split + */ + private const DELIMITER = '--'; + + public function __construct( + private string $appId, + private string $uri, + ) { + } + + /** + * @inheritDoc + */ + final public function getName() { + return implode(self::DELIMITER, [ + self::PREFIX, + $this->appId, + $this->uri, + ]); + } + + /** + * @inheritDoc + */ + final public function setName($name) { + throw new DAV\Exception\MethodNotAllowed('Renaming address books is not yet supported'); + } + + /** + * @inheritDoc + */ + final public function createDirectory($name) { + throw new DAV\Exception\MethodNotAllowed('Creating collections in address book objects is not allowed'); + } + + /** + * Checks whether the address book uri is app-generated + * + * @param string $uri + * + * @return bool + */ + public static function isAppGeneratedAddressBook(string $uri): bool { + return str_starts_with($uri, self::PREFIX) && substr_count($uri, self::DELIMITER) >= 2; + } + + /** + * Splits an app-generated uri into appId and uri + * + * @param string $uri + * + * @return array + */ + public static function splitAppGeneratedAddressBookUri(string $uri): array { + $array = array_slice(explode(self::DELIMITER, $uri, 3), 1); + // Check the array has expected amount of elements + // and none of them is an empty string + if (\count($array) !== 2 || \in_array('', $array, true)) { + throw new \InvalidArgumentException('Provided address book uri was not app-generated'); + } + + return $array; + } + + /** + * Checks whether a address book name the user wants to create violates + * the reserved name for URIs + * + * @param string $uri + * + * @return bool + */ + public static function doesViolateReservedName(string $uri): bool { + 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 new file mode 100644 index 00000000000..a8fa074f635 --- /dev/null +++ b/apps/dav/lib/CardDAV/Integration/IAddressBookProvider.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CardDAV\Integration; + +/** + * @since 19.0.0 + */ +interface IAddressBookProvider { + + /** + * Provides the appId of the plugin + * + * @since 19.0.0 + * @return string AppId + */ + public function getAppId(): string; + + /** + * Fetches all address books for a given principal uri + * + * @since 19.0.0 + * @param string $principalUri E.g. principals/users/user1 + * @return ExternalAddressBook[] Array of all address books + */ + public function fetchAllForAddressBookHome(string $principalUri): array; + + /** + * Checks whether plugin has an address book for a given principalUri and URI + * + * @since 19.0.0 + * @param string $principalUri E.g. principals/users/user1 + * @param string $uri E.g. personal + * @return bool True if address book for principalUri and URI exists, false otherwise + */ + public function hasAddressBookInAddressBookHome(string $principalUri, string $uri): bool; + + /** + * Fetches an address book for a given principalUri and URI + * Returns null if address book does not exist + * + * @param string $principalUri E.g. principals/users/user1 + * @param string $uri E.g. personal + * + * @return ExternalAddressBook|null address book if it exists, null otherwise + *@since 19.0.0 + */ + public function getAddressBookInAddressBookHome(string $principalUri, string $uri): ?ExternalAddressBook; +} diff --git a/apps/dav/lib/CardDAV/MultiGetExportPlugin.php b/apps/dav/lib/CardDAV/MultiGetExportPlugin.php new file mode 100644 index 00000000000..9d6b0df838e --- /dev/null +++ b/apps/dav/lib/CardDAV/MultiGetExportPlugin.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +/** + * 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; +use Sabre\HTTP\ResponseInterface; + +class MultiGetExportPlugin extends DAV\ServerPlugin { + + /** @var Server */ + protected $server; + + /** + * Initializes the plugin and registers event handlers + * + * @param Server $server + * @return void + */ + public function initialize(Server $server) { + $this->server = $server; + $this->server->on('afterMethod:REPORT', [$this, 'httpReport'], 90); + } + + /** + * Intercepts REPORT requests + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return bool + */ + public function httpReport(RequestInterface $request, ResponseInterface $response) { + $queryParams = $request->getQueryParameters(); + if (!array_key_exists('export', $queryParams)) { + return; + } + + // Only handling xml + $contentType = (string)$response->getHeader('Content-Type'); + if (!str_contains($contentType, 'application/xml') && !str_contains($contentType, 'text/xml')) { + return; + } + + $this->server->transactionType = 'vcf-multi-get-intercept-and-export'; + + // Get the xml response + $responseBody = $response->getBodyAsString(); + $responseXml = $this->server->xml->parse($responseBody); + + // Reduce the vcards into one string + $output = array_reduce($responseXml->getResponses(), function ($vcf, $card) { + $vcf .= $card->getResponseProperties()[200]['{urn:ietf:params:xml:ns:carddav}address-data'] . PHP_EOL; + return $vcf; + }, ''); + + // Build and override the response + $filename = 'vcfexport-' . date('Y-m-d') . '.vcf'; + $response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); + $response->setHeader('Content-Type', 'text/vcard'); + + $response->setStatus(Http::STATUS_OK); + $response->setBody($output); + + return true; + } + + /** + * Returns a plugin name. + * + * Using this name other plugins will be able to access other plugins + * using \Sabre\DAV\Server::getPlugin + * + * @return string + */ + public function getPluginName() { + return 'vcf-multi-get-intercept-and-export'; + } + + /** + * Returns a bunch of meta-data about the plugin. + * + * Providing this information is optional, and is mainly displayed by the + * Browser plugin. + * + * The description key in the returned array may contain html and will not + * be sanitized. + * + * @return array + */ + public function getPluginInfo() { + return [ + 'name' => $this->getPluginName(), + 'description' => 'Intercept a multi-get request and return a single vcf file instead.' + ]; + } +} diff --git a/apps/dav/lib/CardDAV/PhotoCache.php b/apps/dav/lib/CardDAV/PhotoCache.php new file mode 100644 index 00000000000..03c71f7e4a3 --- /dev/null +++ b/apps/dav/lib/CardDAV/PhotoCache.php @@ -0,0 +1,275 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CardDAV; + +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Files\SimpleFS\ISimpleFolder; +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 */ + 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', + ]; + + public function __construct( + private IAppDataFactory $appDataFactory, + private LoggerInterface $logger, + ) { + } + + /** + * @throws NotFoundException + */ + public function get(int $addressBookId, string $cardUri, int $size, Card $card): ISimpleFile { + $folder = $this->getFolder($addressBookId, $cardUri); + + if ($this->isEmpty($folder)) { + $this->init($folder, $card); + } + + if (!$this->hasPhoto($folder)) { + throw new NotFoundException(); + } + + if ($size !== -1) { + $size = 2 ** ceil(log($size) / log(2)); + } + + return $this->getFile($folder, $size); + } + + private function isEmpty(ISimpleFolder $folder): bool { + return $folder->getDirectoryListing() === []; + } + + /** + * @throws NotPermittedException + */ + private function init(ISimpleFolder $folder, Card $card): void { + $data = $this->getPhoto($card); + + if ($data === false || !isset($data['Content-Type'])) { + $folder->newFile('nophoto', ''); + return; + } + + $contentType = $data['Content-Type']; + $extension = self::ALLOWED_CONTENT_TYPES[$contentType] ?? null; + + if ($extension === null) { + $folder->newFile('nophoto', ''); + return; + } + + $file = $folder->newFile('photo.' . $extension); + $file->putContent($data['body']); + } + + private function hasPhoto(ISimpleFolder $folder): bool { + return !$folder->fileExists('nophoto'); + } + + /** + * @param float|-1 $size + */ + private function getFile(ISimpleFolder $folder, $size): ISimpleFile { + $ext = $this->getExtension($folder); + + if ($size === -1) { + $path = 'photo.' . $ext; + } else { + $path = 'photo.' . $size . '.' . $ext; + } + + try { + $file = $folder->getFile($path); + } catch (NotFoundException $e) { + if ($size <= 0) { + throw new NotFoundException; + } + + $photo = new Image(); + /** @var ISimpleFile $file */ + $file = $folder->getFile('photo.' . $ext); + $photo->loadFromData($file->getContent()); + + $ratio = $photo->width() / $photo->height(); + if ($ratio < 1) { + $ratio = 1 / $ratio; + } + + $size = (int)($size * $ratio); + if ($size !== -1) { + $photo->resize($size); + } + + try { + $file = $folder->newFile($path); + $file->putContent($photo->data()); + } catch (NotPermittedException $e) { + } + } + + return $file; + } + + /** + * @throws NotFoundException + * @throws NotPermittedException + */ + private function getFolder(int $addressBookId, string $cardUri, bool $createIfNotExists = true): ISimpleFolder { + $hash = md5($addressBookId . ' ' . $cardUri); + try { + return $this->getPhotoCacheAppData()->getFolder($hash); + } catch (NotFoundException $e) { + if ($createIfNotExists) { + return $this->getPhotoCacheAppData()->newFolder($hash); + } + throw $e; + } + } + + /** + * Get the extension of the avatar. If there is no avatar throw Exception + * + * @throws NotFoundException + */ + private function getExtension(ISimpleFolder $folder): string { + foreach (self::ALLOWED_CONTENT_TYPES as $extension) { + if ($folder->fileExists('photo.' . $extension)) { + return $extension; + } + } + + 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; + } + + $photo = $vObject->PHOTO; + $val = $photo->getValue(); + + // handle data URI. e.g PHOTO;VALUE=URI:data:image/jpeg;base64,/9j/4AAQSkZJRgABAQE + if ($photo->getValueType() === 'URI') { + $parsed = \Sabre\URI\parse($val); + + // only allow data:// + if ($parsed['scheme'] !== 'data') { + return false; + } + if (substr_count($parsed['path'], ';') === 1) { + [$type] = explode(';', $parsed['path']); + } + $val = file_get_contents($val); + } else { + // get type if binary data + $type = $this->getBinaryType($photo); + } + + if (empty($type) || !isset(self::ALLOWED_CONTENT_TYPES[$type])) { + $type = 'application/octet-stream'; + } + + return [ + 'Content-Type' => $type, + 'body' => $val + ]; + } catch (\Exception $e) { + $this->logger->error('Exception during vcard photo parsing', [ + 'exception' => $e + ]); + } + return false; + } + + private function readCard(string $cardData): Document { + return Reader::read($cardData); + } + + /** + * @param Binary $photo + * @return string + */ + private function getBinaryType(Binary $photo) { + $params = $photo->parameters(); + if (isset($params['TYPE']) || isset($params['MEDIATYPE'])) { + /** @var Parameter $typeParam */ + $typeParam = isset($params['TYPE']) ? $params['TYPE'] : $params['MEDIATYPE']; + $type = (string)$typeParam->getValue(); + + if (str_starts_with($type, 'image/')) { + return $type; + } else { + return 'image/' . strtolower($type); + } + } + return ''; + } + + /** + * @param int $addressBookId + * @param string $cardUri + * @throws NotPermittedException + */ + public function delete($addressBookId, $cardUri) { + try { + $folder = $this->getFolder($addressBookId, $cardUri, false); + $folder->delete(); + } catch (NotFoundException $e) { + // 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 new file mode 100644 index 00000000000..0ec10306ceb --- /dev/null +++ b/apps/dav/lib/CardDAV/Plugin.php @@ -0,0 +1,58 @@ +<?php + +/** + * 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; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAV\Server; + +class Plugin extends \Sabre\CardDAV\Plugin { + public function initialize(Server $server) { + $server->on('propFind', [$this, 'propFind']); + parent::initialize($server); + } + + /** + * Returns the addressbook home for a given principal + * + * @param string $principal + * @return string|null + */ + protected function getAddressbookHomeForPrincipal($principal) { + if (strrpos($principal, 'principals/users', -strlen($principal)) !== false) { + [, $principalId] = \Sabre\Uri\split($principal); + return self::ADDRESSBOOK_ROOT . '/users/' . $principalId; + } + if (strrpos($principal, 'principals/groups', -strlen($principal)) !== false) { + [, $principalId] = \Sabre\Uri\split($principal); + return self::ADDRESSBOOK_ROOT . '/groups/' . $principalId; + } + if (strrpos($principal, 'principals/system', -strlen($principal)) !== false) { + [, $principalId] = \Sabre\Uri\split($principal); + return self::ADDRESSBOOK_ROOT . '/system/' . $principalId; + } + } + + /** + * Adds all CardDAV-specific properties + * + * @param PropFind $propFind + * @param INode $node + * @return void + */ + public function propFind(PropFind $propFind, INode $node) { + $ns = '{http://owncloud.org/ns}'; + + if ($node instanceof AddressBook) { + $propFind->handle($ns . 'groups', function () use ($node) { + return new Groups($node->getContactsGroups()); + }); + } + } +} 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 new file mode 100644 index 00000000000..e6da3ed5923 --- /dev/null +++ b/apps/dav/lib/CardDAV/SyncService.php @@ -0,0 +1,358 @@ +<?php + + +/** + * 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\Db\TTransactional; +use OCP\AppFramework\Http; +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 Psr\Http\Client\ClientExceptionInterface; +use Psr\Log\LoggerInterface; +use Sabre\DAV\Xml\Response\MultiStatus; +use Sabre\DAV\Xml\Service; +use Sabre\VObject\Reader; +use Sabre\Xml\ParseException; +use function is_null; + +class SyncService { + + 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 = ''; + } + + /** + * @psalm-return list{0: ?string, 1: boolean} + * @throws \Exception + */ + 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, $targetBookHash, $targetProperties); + $addressBookId = $book['id']; + + // 2. query changes + try { + $response = $this->requestSyncReport($url, $userName, $addressBookUrl, $sharedSecret, $syncToken); + } 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->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 + // TODO: use multi-get for download + foreach ($response['response'] as $resource => $status) { + $cardUri = basename($resource); + if (isset($status[200])) { + $vCard = $this->download($url, $userName, $sharedSecret, $resource); + $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'], + $response['truncated'], + ]; + } + + /** + * @throws \Sabre\DAV\Exception\BadRequest + */ + 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; + } + + // 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; + } + } + + 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' + ]); + } + + 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; + } + + /** + * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool} + * @throws ClientExceptionInterface + * @throws ParseException + */ + 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), + ]; + + $response = $client->request( + 'REPORT', + $uri, + $options + ); + + $body = $response->getBody(); + assert(is_string($body)); + + return $this->parseMultiStatus($body, $addressBookUrl); + } + + protected function download(string $url, string $userName, string $sharedSecret, string $resourcePath): string { + $client = $this->clientService->newClient(); + $uri = $this->prepareUri($url, $resourcePath); + + $options = [ + 'auth' => [$userName, $sharedSecret], + 'verify' => !$this->config->getSystemValue('sharing.federation.allowSelfSignedCertificates', false), + ]; + + $response = $client->get( + $uri, + $options + ); + + return (string)$response->getBody(); + } + + 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 ?? ''); + $prop = $dom->createElement('d:prop'); + $cont = $dom->createElement('d:getcontenttype'); + $etag = $dom->createElement('d:getetag'); + + $prop->appendChild($cont); + $prop->appendChild($etag); + $root->appendChild($sync); + $root->appendChild($prop); + $dom->appendChild($root); + return $dom->saveXML(); + } + + /** + * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool} + * @throws ParseException + */ + private function parseMultiStatus(string $body, string $addressBookUrl): array { + /** @var MultiStatus $multiStatus */ + $multiStatus = (new Service())->expect('{DAV:}multistatus', $body); + + $result = []; + $truncated = false; + + foreach ($multiStatus->getResponses() as $response) { + $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(), '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): void { + $systemAddressBook = $this->getLocalSystemAddressBook(); + $addressBookId = $systemAddressBook['id']; + + $cardId = self::getCardUri($user); + if ($user->isEnabled()) { + $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 { + $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); + } + } + + /** + * @param IUser|string $userOrCardId + */ + public function deleteUser($userOrCardId) { + $systemAddressBook = $this->getLocalSystemAddressBook(); + if ($userOrCardId instanceof IUser) { + $userOrCardId = self::getCardUri($userOrCardId); + } + $this->backend->deleteCard($systemAddressBook['id'], $userOrCardId); + } + + /** + * @return array|null + */ + public function getLocalSystemAddressBook() { + if (is_null($this->localSystemAddressBook)) { + $this->localSystemAddressBook = $this->ensureLocalSystemAddressBookExists(); + } + + return $this->localSystemAddressBook; + } + + /** + * @return void + */ + public function syncInstance(?\Closure $progressCallback = null) { + $systemAddressBook = $this->getLocalSystemAddressBook(); + $this->userManager->callForAllUsers(function ($user) use ($systemAddressBook, $progressCallback): void { + $this->updateUser($user); + if (!is_null($progressCallback)) { + $progressCallback(); + } + }); + + // remove no longer existing + $allCards = $this->backend->getCards($systemAddressBook['id']); + foreach ($allCards as $card) { + $vCard = Reader::read($card['carddata']); + $uid = $vCard->UID->getValue(); + // load backend and see if user exists + if (!$this->userManager->userExists($uid)) { + $this->deleteUser($card['uri']); + } + } + } + + /** + * @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 new file mode 100644 index 00000000000..912a2f1dcee --- /dev/null +++ b/apps/dav/lib/CardDAV/SystemAddressbook.php @@ -0,0 +1,337 @@ +<?php + +declare(strict_types=1); + +/** + * 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 { + public const URI_SHARED = 'z-server-generated--system'; + + 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->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'; + $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(); + } + + 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 new file mode 100644 index 00000000000..e29e52e77df --- /dev/null +++ b/apps/dav/lib/CardDAV/UserAddressBooks.php @@ -0,0 +1,146 @@ +<?php + +declare(strict_types=1); + +/** + * 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\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\CardDAV\IAddressBook; +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; + + public function __construct( + Backend\BackendInterface $carddavBackend, + string $principalUri, + private PluginManager $pluginManager, + private ?IUser $user, + private ?IGroupManager $groupManager, + ) { + parent::__construct($carddavBackend, $principalUri); + } + + /** + * Returns a list of address books + * + * @return IAddressBook[] + */ + public function getChildren() { + if ($this->l10n === null) { + $this->l10n = \OC::$server->getL10N('dav'); + } + if ($this->config === null) { + $this->config = Server::get(IConfig::class); + } + + /** @var string|array $principal */ + $principal = $this->principalUri; + $addressBooks = $this->carddavBackend->getAddressBooksForUser($this->principalUri); + // 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; + } + + $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); + }, $this->pluginManager->getAddressBookPlugins()); + + return array_merge($objects, ...$objectsFromPlugins); + } + + public function createExtendedCollection($name, MkCol $mkCol) { + if (ExternalAddressBook::doesViolateReservedName($name)) { + throw new MethodNotAllowed('The resource you tried to create has a reserved name'); + } + + parent::createExtendedCollection($name, $mkCol); + } + + /** + * Returns a list of ACE's for this node. + * + * Each ACE has the following properties: + * * 'privilege', a string such as {DAV:}read or {DAV:}write. These are + * currently the only supported privileges + * * 'principal', a url to the principal who owns the node + * * 'protected' (optional), indicating that this ACE is not allowed to + * be updated. + * + * @return array + */ + public function getACL() { + $acl = parent::getACL(); + if ($this->principalUri === 'principals/system/system') { + $acl[] = [ + 'privilege' => '{DAV:}read', + 'principal' => '{DAV:}authenticated', + 'protected' => true, + ]; + } + + return $acl; + } +} 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 new file mode 100644 index 00000000000..07aeecb3fa2 --- /dev/null +++ b/apps/dav/lib/CardDAV/Xml/Groups.php @@ -0,0 +1,29 @@ +<?php + +/** + * 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; +use Sabre\Xml\XmlSerializable; + +class Groups implements XmlSerializable { + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + + /** + * @param list<string> $groups + */ + public function __construct( + private array $groups, + ) { + } + + public function xmlSerialize(Writer $writer) { + foreach ($this->groups as $group) { + $writer->writeElement('{' . self::NS_OWNCLOUD . '}group', $group); + } + } +} |