aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dav/lib/CardDAV
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dav/lib/CardDAV')
-rw-r--r--apps/dav/lib/CardDAV/Activity/Backend.php472
-rw-r--r--apps/dav/lib/CardDAV/Activity/Filter.php65
-rw-r--r--apps/dav/lib/CardDAV/Activity/Provider/Addressbook.php165
-rw-r--r--apps/dav/lib/CardDAV/Activity/Provider/Base.php95
-rw-r--r--apps/dav/lib/CardDAV/Activity/Provider/Card.php114
-rw-r--r--apps/dav/lib/CardDAV/Activity/Setting.php64
-rw-r--r--apps/dav/lib/CardDAV/AddressBook.php261
-rw-r--r--apps/dav/lib/CardDAV/AddressBookImpl.php339
-rw-r--r--apps/dav/lib/CardDAV/AddressBookRoot.php57
-rw-r--r--apps/dav/lib/CardDAV/Card.php42
-rw-r--r--apps/dav/lib/CardDAV/CardDavBackend.php1528
-rw-r--r--apps/dav/lib/CardDAV/ContactsManager.php71
-rw-r--r--apps/dav/lib/CardDAV/Converter.php186
-rw-r--r--apps/dav/lib/CardDAV/HasPhotoPlugin.php86
-rw-r--r--apps/dav/lib/CardDAV/ImageExportPlugin.php99
-rw-r--r--apps/dav/lib/CardDAV/Integration/ExternalAddressBook.php106
-rw-r--r--apps/dav/lib/CardDAV/Integration/IAddressBookProvider.php54
-rw-r--r--apps/dav/lib/CardDAV/MultiGetExportPlugin.php104
-rw-r--r--apps/dav/lib/CardDAV/PhotoCache.php275
-rw-r--r--apps/dav/lib/CardDAV/Plugin.php58
-rw-r--r--apps/dav/lib/CardDAV/Security/CardDavRateLimitingPlugin.php86
-rw-r--r--apps/dav/lib/CardDAV/Sharing/Backend.php29
-rw-r--r--apps/dav/lib/CardDAV/Sharing/Service.php21
-rw-r--r--apps/dav/lib/CardDAV/SyncService.php358
-rw-r--r--apps/dav/lib/CardDAV/SystemAddressbook.php337
-rw-r--r--apps/dav/lib/CardDAV/UserAddressBooks.php146
-rw-r--r--apps/dav/lib/CardDAV/Validation/CardDavValidatePlugin.php40
-rw-r--r--apps/dav/lib/CardDAV/Xml/Groups.php29
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);
+ }
+ }
+}