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