Completed
Push — master ( a3183f...21d28c )
by Christoph
32:59 queued 11:56
created
apps/dav/lib/Listener/UserEventsListener.php 2 patches
Indentation   +149 added lines, -149 removed lines patch added patch discarded remove patch
@@ -35,153 +35,153 @@
 block discarded – undo
35 35
 /** @template-implements IEventListener<UserFirstTimeLoggedInEvent|UserIdAssignedEvent|BeforeUserIdUnassignedEvent|UserIdUnassignedEvent|BeforeUserDeletedEvent|UserDeletedEvent|UserCreatedEvent|UserChangedEvent|UserUpdatedEvent> */
36 36
 class UserEventsListener implements IEventListener {
37 37
 
38
-	/** @var IUser[] */
39
-	private array $usersToDelete = [];
40
-
41
-	private array $calendarsToDelete = [];
42
-	private array $subscriptionsToDelete = [];
43
-	private array $addressBooksToDelete = [];
44
-
45
-	public function __construct(
46
-		private IUserManager $userManager,
47
-		private SyncService $syncService,
48
-		private CalDavBackend $calDav,
49
-		private CardDavBackend $cardDav,
50
-		private Defaults $themingDefaults,
51
-		private ExampleContactService $exampleContactService,
52
-		private ExampleEventService $exampleEventService,
53
-		private LoggerInterface $logger,
54
-		private IJobList $jobList,
55
-	) {
56
-	}
57
-
58
-	public function handle(Event $event): void {
59
-		if ($event instanceof UserCreatedEvent) {
60
-			$this->postCreateUser($event->getUser());
61
-		} elseif ($event instanceof UserIdAssignedEvent) {
62
-			$user = $this->userManager->get($event->getUserId());
63
-			if ($user !== null) {
64
-				$this->postCreateUser($user);
65
-			}
66
-		} elseif ($event instanceof BeforeUserDeletedEvent) {
67
-			$this->preDeleteUser($event->getUser());
68
-		} elseif ($event instanceof BeforeUserIdUnassignedEvent) {
69
-			$this->preUnassignedUserId($event->getUserId());
70
-		} elseif ($event instanceof UserDeletedEvent) {
71
-			$this->postDeleteUser($event->getUid());
72
-		} elseif ($event instanceof UserIdUnassignedEvent) {
73
-			$this->postDeleteUser($event->getUserId());
74
-		} elseif ($event instanceof UserChangedEvent) {
75
-			$this->changeUser($event->getUser(), $event->getFeature());
76
-		} elseif ($event instanceof UserFirstTimeLoggedInEvent) {
77
-			$this->firstLogin($event->getUser());
78
-		} elseif ($event instanceof UserUpdatedEvent) {
79
-			$this->updateUser($event->getUser());
80
-		}
81
-	}
82
-
83
-	public function postCreateUser(IUser $user): void {
84
-		$this->syncService->updateUser($user);
85
-	}
86
-
87
-	public function updateUser(IUser $user): void {
88
-		$this->syncService->updateUser($user);
89
-	}
90
-
91
-	public function preDeleteUser(IUser $user): void {
92
-		$uid = $user->getUID();
93
-		$userPrincipalUri = 'principals/users/' . $uid;
94
-		$this->usersToDelete[$uid] = $user;
95
-		$this->calendarsToDelete[$uid] = $this->calDav->getUsersOwnCalendars($userPrincipalUri);
96
-		$this->subscriptionsToDelete[$uid] = $this->calDav->getSubscriptionsForUser($userPrincipalUri);
97
-		$this->addressBooksToDelete[$uid] = $this->cardDav->getUsersOwnAddressBooks($userPrincipalUri);
98
-	}
99
-
100
-	public function preUnassignedUserId(string $uid): void {
101
-		$user = $this->userManager->get($uid);
102
-		if ($user !== null) {
103
-			$this->usersToDelete[$uid] = $user;
104
-		}
105
-	}
106
-
107
-	public function postDeleteUser(string $uid): void {
108
-		if (isset($this->usersToDelete[$uid])) {
109
-			$this->syncService->deleteUser($this->usersToDelete[$uid]);
110
-		}
111
-
112
-		foreach ($this->calendarsToDelete[$uid] as $calendar) {
113
-			$this->calDav->deleteCalendar(
114
-				$calendar['id'],
115
-				true // Make sure the data doesn't go into the trashbin, a new user with the same UID would later see it otherwise
116
-			);
117
-		}
118
-
119
-		foreach ($this->subscriptionsToDelete[$uid] as $subscription) {
120
-			$this->calDav->deleteSubscription(
121
-				$subscription['id'],
122
-			);
123
-		}
124
-		$this->calDav->deleteAllSharesByUser('principals/users/' . $uid);
125
-		$this->cardDav->deleteAllSharesByUser('principals/users/' . $uid);
126
-
127
-		foreach ($this->addressBooksToDelete[$uid] as $addressBook) {
128
-			$this->cardDav->deleteAddressBook($addressBook['id']);
129
-		}
130
-
131
-		$this->jobList->remove(UserStatusAutomation::class, ['userId' => $uid]);
132
-
133
-		unset($this->calendarsToDelete[$uid]);
134
-		unset($this->subscriptionsToDelete[$uid]);
135
-		unset($this->addressBooksToDelete[$uid]);
136
-	}
137
-
138
-	public function changeUser(IUser $user, string $feature): void {
139
-		// This case is already covered by the account manager firing up a signal
140
-		// later on
141
-		if ($feature !== 'eMailAddress' && $feature !== 'displayName') {
142
-			$this->syncService->updateUser($user);
143
-		}
144
-	}
145
-
146
-	public function firstLogin(IUser $user): void {
147
-		$principal = 'principals/users/' . $user->getUID();
148
-
149
-		$calendarId = null;
150
-		if ($this->calDav->getCalendarsForUserCount($principal) === 0) {
151
-			try {
152
-				$calendarId = $this->calDav->createCalendar($principal, CalDavBackend::PERSONAL_CALENDAR_URI, [
153
-					'{DAV:}displayname' => CalDavBackend::PERSONAL_CALENDAR_NAME,
154
-					'{http://apple.com/ns/ical/}calendar-color' => $this->themingDefaults->getColorPrimary(),
155
-					'components' => 'VEVENT'
156
-				]);
157
-			} catch (\Exception $e) {
158
-				$this->logger->error($e->getMessage(), ['exception' => $e]);
159
-			}
160
-		}
161
-		if ($calendarId !== null) {
162
-			try {
163
-				$this->exampleEventService->createExampleEvent($calendarId);
164
-			} catch (\Exception $e) {
165
-				$this->logger->error('Failed to create example event: ' . $e->getMessage(), [
166
-					'exception' => $e,
167
-					'userId' => $user->getUID(),
168
-					'calendarId' => $calendarId,
169
-				]);
170
-			}
171
-		}
172
-
173
-		$addressBookId = null;
174
-		if ($this->cardDav->getAddressBooksForUserCount($principal) === 0) {
175
-			try {
176
-				$addressBookId = $this->cardDav->createAddressBook($principal, CardDavBackend::PERSONAL_ADDRESSBOOK_URI, [
177
-					'{DAV:}displayname' => CardDavBackend::PERSONAL_ADDRESSBOOK_NAME,
178
-				]);
179
-			} catch (\Exception $e) {
180
-				$this->logger->error($e->getMessage(), ['exception' => $e]);
181
-			}
182
-		}
183
-		if ($addressBookId) {
184
-			$this->exampleContactService->createDefaultContact($addressBookId);
185
-		}
186
-	}
38
+    /** @var IUser[] */
39
+    private array $usersToDelete = [];
40
+
41
+    private array $calendarsToDelete = [];
42
+    private array $subscriptionsToDelete = [];
43
+    private array $addressBooksToDelete = [];
44
+
45
+    public function __construct(
46
+        private IUserManager $userManager,
47
+        private SyncService $syncService,
48
+        private CalDavBackend $calDav,
49
+        private CardDavBackend $cardDav,
50
+        private Defaults $themingDefaults,
51
+        private ExampleContactService $exampleContactService,
52
+        private ExampleEventService $exampleEventService,
53
+        private LoggerInterface $logger,
54
+        private IJobList $jobList,
55
+    ) {
56
+    }
57
+
58
+    public function handle(Event $event): void {
59
+        if ($event instanceof UserCreatedEvent) {
60
+            $this->postCreateUser($event->getUser());
61
+        } elseif ($event instanceof UserIdAssignedEvent) {
62
+            $user = $this->userManager->get($event->getUserId());
63
+            if ($user !== null) {
64
+                $this->postCreateUser($user);
65
+            }
66
+        } elseif ($event instanceof BeforeUserDeletedEvent) {
67
+            $this->preDeleteUser($event->getUser());
68
+        } elseif ($event instanceof BeforeUserIdUnassignedEvent) {
69
+            $this->preUnassignedUserId($event->getUserId());
70
+        } elseif ($event instanceof UserDeletedEvent) {
71
+            $this->postDeleteUser($event->getUid());
72
+        } elseif ($event instanceof UserIdUnassignedEvent) {
73
+            $this->postDeleteUser($event->getUserId());
74
+        } elseif ($event instanceof UserChangedEvent) {
75
+            $this->changeUser($event->getUser(), $event->getFeature());
76
+        } elseif ($event instanceof UserFirstTimeLoggedInEvent) {
77
+            $this->firstLogin($event->getUser());
78
+        } elseif ($event instanceof UserUpdatedEvent) {
79
+            $this->updateUser($event->getUser());
80
+        }
81
+    }
82
+
83
+    public function postCreateUser(IUser $user): void {
84
+        $this->syncService->updateUser($user);
85
+    }
86
+
87
+    public function updateUser(IUser $user): void {
88
+        $this->syncService->updateUser($user);
89
+    }
90
+
91
+    public function preDeleteUser(IUser $user): void {
92
+        $uid = $user->getUID();
93
+        $userPrincipalUri = 'principals/users/' . $uid;
94
+        $this->usersToDelete[$uid] = $user;
95
+        $this->calendarsToDelete[$uid] = $this->calDav->getUsersOwnCalendars($userPrincipalUri);
96
+        $this->subscriptionsToDelete[$uid] = $this->calDav->getSubscriptionsForUser($userPrincipalUri);
97
+        $this->addressBooksToDelete[$uid] = $this->cardDav->getUsersOwnAddressBooks($userPrincipalUri);
98
+    }
99
+
100
+    public function preUnassignedUserId(string $uid): void {
101
+        $user = $this->userManager->get($uid);
102
+        if ($user !== null) {
103
+            $this->usersToDelete[$uid] = $user;
104
+        }
105
+    }
106
+
107
+    public function postDeleteUser(string $uid): void {
108
+        if (isset($this->usersToDelete[$uid])) {
109
+            $this->syncService->deleteUser($this->usersToDelete[$uid]);
110
+        }
111
+
112
+        foreach ($this->calendarsToDelete[$uid] as $calendar) {
113
+            $this->calDav->deleteCalendar(
114
+                $calendar['id'],
115
+                true // Make sure the data doesn't go into the trashbin, a new user with the same UID would later see it otherwise
116
+            );
117
+        }
118
+
119
+        foreach ($this->subscriptionsToDelete[$uid] as $subscription) {
120
+            $this->calDav->deleteSubscription(
121
+                $subscription['id'],
122
+            );
123
+        }
124
+        $this->calDav->deleteAllSharesByUser('principals/users/' . $uid);
125
+        $this->cardDav->deleteAllSharesByUser('principals/users/' . $uid);
126
+
127
+        foreach ($this->addressBooksToDelete[$uid] as $addressBook) {
128
+            $this->cardDav->deleteAddressBook($addressBook['id']);
129
+        }
130
+
131
+        $this->jobList->remove(UserStatusAutomation::class, ['userId' => $uid]);
132
+
133
+        unset($this->calendarsToDelete[$uid]);
134
+        unset($this->subscriptionsToDelete[$uid]);
135
+        unset($this->addressBooksToDelete[$uid]);
136
+    }
137
+
138
+    public function changeUser(IUser $user, string $feature): void {
139
+        // This case is already covered by the account manager firing up a signal
140
+        // later on
141
+        if ($feature !== 'eMailAddress' && $feature !== 'displayName') {
142
+            $this->syncService->updateUser($user);
143
+        }
144
+    }
145
+
146
+    public function firstLogin(IUser $user): void {
147
+        $principal = 'principals/users/' . $user->getUID();
148
+
149
+        $calendarId = null;
150
+        if ($this->calDav->getCalendarsForUserCount($principal) === 0) {
151
+            try {
152
+                $calendarId = $this->calDav->createCalendar($principal, CalDavBackend::PERSONAL_CALENDAR_URI, [
153
+                    '{DAV:}displayname' => CalDavBackend::PERSONAL_CALENDAR_NAME,
154
+                    '{http://apple.com/ns/ical/}calendar-color' => $this->themingDefaults->getColorPrimary(),
155
+                    'components' => 'VEVENT'
156
+                ]);
157
+            } catch (\Exception $e) {
158
+                $this->logger->error($e->getMessage(), ['exception' => $e]);
159
+            }
160
+        }
161
+        if ($calendarId !== null) {
162
+            try {
163
+                $this->exampleEventService->createExampleEvent($calendarId);
164
+            } catch (\Exception $e) {
165
+                $this->logger->error('Failed to create example event: ' . $e->getMessage(), [
166
+                    'exception' => $e,
167
+                    'userId' => $user->getUID(),
168
+                    'calendarId' => $calendarId,
169
+                ]);
170
+            }
171
+        }
172
+
173
+        $addressBookId = null;
174
+        if ($this->cardDav->getAddressBooksForUserCount($principal) === 0) {
175
+            try {
176
+                $addressBookId = $this->cardDav->createAddressBook($principal, CardDavBackend::PERSONAL_ADDRESSBOOK_URI, [
177
+                    '{DAV:}displayname' => CardDavBackend::PERSONAL_ADDRESSBOOK_NAME,
178
+                ]);
179
+            } catch (\Exception $e) {
180
+                $this->logger->error($e->getMessage(), ['exception' => $e]);
181
+            }
182
+        }
183
+        if ($addressBookId) {
184
+            $this->exampleContactService->createDefaultContact($addressBookId);
185
+        }
186
+    }
187 187
 }
Please login to merge, or discard this patch.
Spacing   +5 added lines, -5 removed lines patch added patch discarded remove patch
@@ -90,7 +90,7 @@  discard block
 block discarded – undo
90 90
 
91 91
 	public function preDeleteUser(IUser $user): void {
92 92
 		$uid = $user->getUID();
93
-		$userPrincipalUri = 'principals/users/' . $uid;
93
+		$userPrincipalUri = 'principals/users/'.$uid;
94 94
 		$this->usersToDelete[$uid] = $user;
95 95
 		$this->calendarsToDelete[$uid] = $this->calDav->getUsersOwnCalendars($userPrincipalUri);
96 96
 		$this->subscriptionsToDelete[$uid] = $this->calDav->getSubscriptionsForUser($userPrincipalUri);
@@ -121,8 +121,8 @@  discard block
 block discarded – undo
121 121
 				$subscription['id'],
122 122
 			);
123 123
 		}
124
-		$this->calDav->deleteAllSharesByUser('principals/users/' . $uid);
125
-		$this->cardDav->deleteAllSharesByUser('principals/users/' . $uid);
124
+		$this->calDav->deleteAllSharesByUser('principals/users/'.$uid);
125
+		$this->cardDav->deleteAllSharesByUser('principals/users/'.$uid);
126 126
 
127 127
 		foreach ($this->addressBooksToDelete[$uid] as $addressBook) {
128 128
 			$this->cardDav->deleteAddressBook($addressBook['id']);
@@ -144,7 +144,7 @@  discard block
 block discarded – undo
144 144
 	}
145 145
 
146 146
 	public function firstLogin(IUser $user): void {
147
-		$principal = 'principals/users/' . $user->getUID();
147
+		$principal = 'principals/users/'.$user->getUID();
148 148
 
149 149
 		$calendarId = null;
150 150
 		if ($this->calDav->getCalendarsForUserCount($principal) === 0) {
@@ -162,7 +162,7 @@  discard block
 block discarded – undo
162 162
 			try {
163 163
 				$this->exampleEventService->createExampleEvent($calendarId);
164 164
 			} catch (\Exception $e) {
165
-				$this->logger->error('Failed to create example event: ' . $e->getMessage(), [
165
+				$this->logger->error('Failed to create example event: '.$e->getMessage(), [
166 166
 					'exception' => $e,
167 167
 					'userId' => $user->getUID(),
168 168
 					'calendarId' => $calendarId,
Please login to merge, or discard this patch.
apps/dav/lib/CardDAV/CardDavBackend.php 1 patch
Indentation   +1490 added lines, -1490 removed lines patch added patch discarded remove patch
@@ -35,926 +35,926 @@  discard block
 block discarded – undo
35 35
 use Sabre\VObject\Reader;
36 36
 
37 37
 class CardDavBackend implements BackendInterface, SyncSupport {
38
-	use TTransactional;
39
-	public const PERSONAL_ADDRESSBOOK_URI = 'contacts';
40
-	public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts';
41
-
42
-	private string $dbCardsTable = 'cards';
43
-	private string $dbCardsPropertiesTable = 'cards_properties';
44
-
45
-	/** @var array properties to index */
46
-	public static array $indexProperties = [
47
-		'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME',
48
-		'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO',
49
-		'CLOUD', 'X-SOCIALPROFILE'];
50
-
51
-	/**
52
-	 * @var string[] Map of uid => display name
53
-	 */
54
-	protected array $userDisplayNames;
55
-	private array $etagCache = [];
56
-
57
-	public function __construct(
58
-		private IDBConnection $db,
59
-		private Principal $principalBackend,
60
-		private IUserManager $userManager,
61
-		private IEventDispatcher $dispatcher,
62
-		private Sharing\Backend $sharingBackend,
63
-		private IConfig $config,
64
-	) {
65
-	}
66
-
67
-	/**
68
-	 * Return the number of address books for a principal
69
-	 *
70
-	 * @param $principalUri
71
-	 * @return int
72
-	 */
73
-	public function getAddressBooksForUserCount($principalUri) {
74
-		$principalUri = $this->convertPrincipal($principalUri, true);
75
-		$query = $this->db->getQueryBuilder();
76
-		$query->select($query->func()->count('*'))
77
-			->from('addressbooks')
78
-			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
79
-
80
-		$result = $query->executeQuery();
81
-		$column = (int)$result->fetchOne();
82
-		$result->closeCursor();
83
-		return $column;
84
-	}
85
-
86
-	/**
87
-	 * Returns the list of address books for a specific user.
88
-	 *
89
-	 * Every addressbook should have the following properties:
90
-	 *   id - an arbitrary unique id
91
-	 *   uri - the 'basename' part of the url
92
-	 *   principaluri - Same as the passed parameter
93
-	 *
94
-	 * Any additional clark-notation property may be passed besides this. Some
95
-	 * common ones are :
96
-	 *   {DAV:}displayname
97
-	 *   {urn:ietf:params:xml:ns:carddav}addressbook-description
98
-	 *   {http://calendarserver.org/ns/}getctag
99
-	 *
100
-	 * @param string $principalUri
101
-	 * @return array
102
-	 */
103
-	public function getAddressBooksForUser($principalUri) {
104
-		return $this->atomic(function () use ($principalUri) {
105
-			$principalUriOriginal = $principalUri;
106
-			$principalUri = $this->convertPrincipal($principalUri, true);
107
-			$select = $this->db->getQueryBuilder();
108
-			$select->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
109
-				->from('addressbooks')
110
-				->where($select->expr()->eq('principaluri', $select->createNamedParameter($principalUri)));
111
-
112
-			$addressBooks = [];
113
-
114
-			$result = $select->executeQuery();
115
-			while ($row = $result->fetch()) {
116
-				$addressBooks[$row['id']] = [
117
-					'id' => $row['id'],
118
-					'uri' => $row['uri'],
119
-					'principaluri' => $this->convertPrincipal($row['principaluri'], false),
120
-					'{DAV:}displayname' => $row['displayname'],
121
-					'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
122
-					'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
123
-					'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
124
-				];
125
-
126
-				$this->addOwnerPrincipal($addressBooks[$row['id']]);
127
-			}
128
-			$result->closeCursor();
129
-
130
-			// query for shared addressbooks
131
-			$principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true);
132
-
133
-			$principals[] = $principalUri;
134
-
135
-			$select = $this->db->getQueryBuilder();
136
-			$subSelect = $this->db->getQueryBuilder();
137
-
138
-			$subSelect->select('id')
139
-				->from('dav_shares', 'd')
140
-				->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(\OCA\DAV\CardDAV\Sharing\Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
141
-				->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY));
142
-
143
-
144
-			$select->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access'])
145
-				->from('dav_shares', 's')
146
-				->join('s', 'addressbooks', 'a', $select->expr()->eq('s.resourceid', 'a.id'))
147
-				->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY)))
148
-				->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('addressbook', IQueryBuilder::PARAM_STR)))
149
-				->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
150
-			$result = $select->executeQuery();
151
-
152
-			$readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
153
-			while ($row = $result->fetch()) {
154
-				if ($row['principaluri'] === $principalUri) {
155
-					continue;
156
-				}
157
-
158
-				$readOnly = (int)$row['access'] === Backend::ACCESS_READ;
159
-				if (isset($addressBooks[$row['id']])) {
160
-					if ($readOnly) {
161
-						// New share can not have more permissions then the old one.
162
-						continue;
163
-					}
164
-					if (isset($addressBooks[$row['id']][$readOnlyPropertyName])
165
-						&& $addressBooks[$row['id']][$readOnlyPropertyName] === 0) {
166
-						// Old share is already read-write, no more permissions can be gained
167
-						continue;
168
-					}
169
-				}
170
-
171
-				[, $name] = \Sabre\Uri\split($row['principaluri']);
172
-				$uri = $row['uri'] . '_shared_by_' . $name;
173
-				$displayName = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? $name ?? '') . ')';
174
-
175
-				$addressBooks[$row['id']] = [
176
-					'id' => $row['id'],
177
-					'uri' => $uri,
178
-					'principaluri' => $principalUriOriginal,
179
-					'{DAV:}displayname' => $displayName,
180
-					'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
181
-					'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
182
-					'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
183
-					'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'],
184
-					$readOnlyPropertyName => $readOnly,
185
-				];
186
-
187
-				$this->addOwnerPrincipal($addressBooks[$row['id']]);
188
-			}
189
-			$result->closeCursor();
190
-
191
-			return array_values($addressBooks);
192
-		}, $this->db);
193
-	}
194
-
195
-	public function getUsersOwnAddressBooks($principalUri) {
196
-		$principalUri = $this->convertPrincipal($principalUri, true);
197
-		$query = $this->db->getQueryBuilder();
198
-		$query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
199
-			->from('addressbooks')
200
-			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
201
-
202
-		$addressBooks = [];
203
-
204
-		$result = $query->executeQuery();
205
-		while ($row = $result->fetch()) {
206
-			$addressBooks[$row['id']] = [
207
-				'id' => $row['id'],
208
-				'uri' => $row['uri'],
209
-				'principaluri' => $this->convertPrincipal($row['principaluri'], false),
210
-				'{DAV:}displayname' => $row['displayname'],
211
-				'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
212
-				'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
213
-				'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
214
-			];
215
-
216
-			$this->addOwnerPrincipal($addressBooks[$row['id']]);
217
-		}
218
-		$result->closeCursor();
219
-
220
-		return array_values($addressBooks);
221
-	}
222
-
223
-	/**
224
-	 * @param int $addressBookId
225
-	 */
226
-	public function getAddressBookById(int $addressBookId): ?array {
227
-		$query = $this->db->getQueryBuilder();
228
-		$result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
229
-			->from('addressbooks')
230
-			->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
231
-			->executeQuery();
232
-		$row = $result->fetch();
233
-		$result->closeCursor();
234
-		if (!$row) {
235
-			return null;
236
-		}
237
-
238
-		$addressBook = [
239
-			'id' => $row['id'],
240
-			'uri' => $row['uri'],
241
-			'principaluri' => $row['principaluri'],
242
-			'{DAV:}displayname' => $row['displayname'],
243
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
244
-			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
245
-			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
246
-		];
247
-
248
-		$this->addOwnerPrincipal($addressBook);
249
-
250
-		return $addressBook;
251
-	}
252
-
253
-	public function getAddressBooksByUri(string $principal, string $addressBookUri): ?array {
254
-		$query = $this->db->getQueryBuilder();
255
-		$result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
256
-			->from('addressbooks')
257
-			->where($query->expr()->eq('uri', $query->createNamedParameter($addressBookUri)))
258
-			->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
259
-			->setMaxResults(1)
260
-			->executeQuery();
261
-
262
-		$row = $result->fetch();
263
-		$result->closeCursor();
264
-		if ($row === false) {
265
-			return null;
266
-		}
267
-
268
-		$addressBook = [
269
-			'id' => $row['id'],
270
-			'uri' => $row['uri'],
271
-			'principaluri' => $row['principaluri'],
272
-			'{DAV:}displayname' => $row['displayname'],
273
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
274
-			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
275
-			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
276
-
277
-		];
278
-
279
-		// system address books are always read only
280
-		if ($principal === 'principals/system/system') {
281
-			$addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] = $row['principaluri'];
282
-			$addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'] = true;
283
-		}
284
-
285
-		$this->addOwnerPrincipal($addressBook);
286
-
287
-		return $addressBook;
288
-	}
289
-
290
-	/**
291
-	 * Updates properties for an address book.
292
-	 *
293
-	 * The list of mutations is stored in a Sabre\DAV\PropPatch object.
294
-	 * To do the actual updates, you must tell this object which properties
295
-	 * you're going to process with the handle() method.
296
-	 *
297
-	 * Calling the handle method is like telling the PropPatch object "I
298
-	 * promise I can handle updating this property".
299
-	 *
300
-	 * Read the PropPatch documentation for more info and examples.
301
-	 *
302
-	 * @param string $addressBookId
303
-	 * @param \Sabre\DAV\PropPatch $propPatch
304
-	 * @return void
305
-	 */
306
-	public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
307
-		$supportedProperties = [
308
-			'{DAV:}displayname',
309
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description',
310
-		];
311
-
312
-		$propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) {
313
-			$updates = [];
314
-			foreach ($mutations as $property => $newValue) {
315
-				switch ($property) {
316
-					case '{DAV:}displayname':
317
-						$updates['displayname'] = $newValue;
318
-						break;
319
-					case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
320
-						$updates['description'] = $newValue;
321
-						break;
322
-				}
323
-			}
324
-			[$addressBookRow, $shares] = $this->atomic(function () use ($addressBookId, $updates) {
325
-				$query = $this->db->getQueryBuilder();
326
-				$query->update('addressbooks');
327
-
328
-				foreach ($updates as $key => $value) {
329
-					$query->set($key, $query->createNamedParameter($value));
330
-				}
331
-				$query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
332
-					->executeStatement();
333
-
334
-				$this->addChange($addressBookId, '', 2);
335
-
336
-				$addressBookRow = $this->getAddressBookById((int)$addressBookId);
337
-				$shares = $this->getShares((int)$addressBookId);
338
-				return [$addressBookRow, $shares];
339
-			}, $this->db);
340
-
341
-			$this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations));
342
-
343
-			return true;
344
-		});
345
-	}
346
-
347
-	/**
348
-	 * Creates a new address book
349
-	 *
350
-	 * @param string $principalUri
351
-	 * @param string $url Just the 'basename' of the url.
352
-	 * @param array $properties
353
-	 * @return int
354
-	 * @throws BadRequest
355
-	 * @throws Exception
356
-	 */
357
-	public function createAddressBook($principalUri, $url, array $properties) {
358
-		if (strlen($url) > 255) {
359
-			throw new BadRequest('URI too long. Address book not created');
360
-		}
361
-
362
-		$values = [
363
-			'displayname' => null,
364
-			'description' => null,
365
-			'principaluri' => $principalUri,
366
-			'uri' => $url,
367
-			'synctoken' => 1
368
-		];
369
-
370
-		foreach ($properties as $property => $newValue) {
371
-			switch ($property) {
372
-				case '{DAV:}displayname':
373
-					$values['displayname'] = $newValue;
374
-					break;
375
-				case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
376
-					$values['description'] = $newValue;
377
-					break;
378
-				default:
379
-					throw new BadRequest('Unknown property: ' . $property);
380
-			}
381
-		}
382
-
383
-		// Fallback to make sure the displayname is set. Some clients may refuse
384
-		// to work with addressbooks not having a displayname.
385
-		if (is_null($values['displayname'])) {
386
-			$values['displayname'] = $url;
387
-		}
388
-
389
-		[$addressBookId, $addressBookRow] = $this->atomic(function () use ($values) {
390
-			$query = $this->db->getQueryBuilder();
391
-			$query->insert('addressbooks')
392
-				->values([
393
-					'uri' => $query->createParameter('uri'),
394
-					'displayname' => $query->createParameter('displayname'),
395
-					'description' => $query->createParameter('description'),
396
-					'principaluri' => $query->createParameter('principaluri'),
397
-					'synctoken' => $query->createParameter('synctoken'),
398
-				])
399
-				->setParameters($values)
400
-				->executeStatement();
401
-
402
-			$addressBookId = $query->getLastInsertId();
403
-			return [
404
-				$addressBookId,
405
-				$this->getAddressBookById($addressBookId),
406
-			];
407
-		}, $this->db);
408
-
409
-		$this->dispatcher->dispatchTyped(new AddressBookCreatedEvent($addressBookId, $addressBookRow));
410
-
411
-		return $addressBookId;
412
-	}
413
-
414
-	/**
415
-	 * Deletes an entire addressbook and all its contents
416
-	 *
417
-	 * @param mixed $addressBookId
418
-	 * @return void
419
-	 */
420
-	public function deleteAddressBook($addressBookId) {
421
-		$this->atomic(function () use ($addressBookId): void {
422
-			$addressBookId = (int)$addressBookId;
423
-			$addressBookData = $this->getAddressBookById($addressBookId);
424
-			$shares = $this->getShares($addressBookId);
425
-
426
-			$query = $this->db->getQueryBuilder();
427
-			$query->delete($this->dbCardsTable)
428
-				->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
429
-				->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
430
-				->executeStatement();
431
-
432
-			$query = $this->db->getQueryBuilder();
433
-			$query->delete('addressbookchanges')
434
-				->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
435
-				->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
436
-				->executeStatement();
437
-
438
-			$query = $this->db->getQueryBuilder();
439
-			$query->delete('addressbooks')
440
-				->where($query->expr()->eq('id', $query->createParameter('id')))
441
-				->setParameter('id', $addressBookId, IQueryBuilder::PARAM_INT)
442
-				->executeStatement();
443
-
444
-			$this->sharingBackend->deleteAllShares($addressBookId);
445
-
446
-			$query = $this->db->getQueryBuilder();
447
-			$query->delete($this->dbCardsPropertiesTable)
448
-				->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
449
-				->executeStatement();
450
-
451
-			if ($addressBookData) {
452
-				$this->dispatcher->dispatchTyped(new AddressBookDeletedEvent($addressBookId, $addressBookData, $shares));
453
-			}
454
-		}, $this->db);
455
-	}
456
-
457
-	/**
458
-	 * Returns all cards for a specific addressbook id.
459
-	 *
460
-	 * This method should return the following properties for each card:
461
-	 *   * carddata - raw vcard data
462
-	 *   * uri - Some unique url
463
-	 *   * lastmodified - A unix timestamp
464
-	 *
465
-	 * It's recommended to also return the following properties:
466
-	 *   * etag - A unique etag. This must change every time the card changes.
467
-	 *   * size - The size of the card in bytes.
468
-	 *
469
-	 * If these last two properties are provided, less time will be spent
470
-	 * calculating them. If they are specified, you can also omit carddata.
471
-	 * This may speed up certain requests, especially with large cards.
472
-	 *
473
-	 * @param mixed $addressbookId
474
-	 * @return array
475
-	 */
476
-	public function getCards($addressbookId) {
477
-		$query = $this->db->getQueryBuilder();
478
-		$query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
479
-			->from($this->dbCardsTable)
480
-			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId)));
481
-
482
-		$cards = [];
483
-
484
-		$result = $query->executeQuery();
485
-		while ($row = $result->fetch()) {
486
-			$row['etag'] = '"' . $row['etag'] . '"';
487
-
488
-			$modified = false;
489
-			$row['carddata'] = $this->readBlob($row['carddata'], $modified);
490
-			if ($modified) {
491
-				$row['size'] = strlen($row['carddata']);
492
-			}
493
-
494
-			$cards[] = $row;
495
-		}
496
-		$result->closeCursor();
497
-
498
-		return $cards;
499
-	}
500
-
501
-	/**
502
-	 * Returns a specific card.
503
-	 *
504
-	 * The same set of properties must be returned as with getCards. The only
505
-	 * exception is that 'carddata' is absolutely required.
506
-	 *
507
-	 * If the card does not exist, you must return false.
508
-	 *
509
-	 * @param mixed $addressBookId
510
-	 * @param string $cardUri
511
-	 * @return array
512
-	 */
513
-	public function getCard($addressBookId, $cardUri) {
514
-		$query = $this->db->getQueryBuilder();
515
-		$query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
516
-			->from($this->dbCardsTable)
517
-			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
518
-			->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
519
-			->setMaxResults(1);
520
-
521
-		$result = $query->executeQuery();
522
-		$row = $result->fetch();
523
-		if (!$row) {
524
-			return false;
525
-		}
526
-		$row['etag'] = '"' . $row['etag'] . '"';
527
-
528
-		$modified = false;
529
-		$row['carddata'] = $this->readBlob($row['carddata'], $modified);
530
-		if ($modified) {
531
-			$row['size'] = strlen($row['carddata']);
532
-		}
533
-
534
-		return $row;
535
-	}
536
-
537
-	/**
538
-	 * Returns a list of cards.
539
-	 *
540
-	 * This method should work identical to getCard, but instead return all the
541
-	 * cards in the list as an array.
542
-	 *
543
-	 * If the backend supports this, it may allow for some speed-ups.
544
-	 *
545
-	 * @param mixed $addressBookId
546
-	 * @param array $uris
547
-	 * @return array
548
-	 */
549
-	public function getMultipleCards($addressBookId, array $uris) {
550
-		if (empty($uris)) {
551
-			return [];
552
-		}
553
-
554
-		$chunks = array_chunk($uris, 100);
555
-		$cards = [];
556
-
557
-		$query = $this->db->getQueryBuilder();
558
-		$query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
559
-			->from($this->dbCardsTable)
560
-			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
561
-			->andWhere($query->expr()->in('uri', $query->createParameter('uri')));
562
-
563
-		foreach ($chunks as $uris) {
564
-			$query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY);
565
-			$result = $query->executeQuery();
566
-
567
-			while ($row = $result->fetch()) {
568
-				$row['etag'] = '"' . $row['etag'] . '"';
569
-
570
-				$modified = false;
571
-				$row['carddata'] = $this->readBlob($row['carddata'], $modified);
572
-				if ($modified) {
573
-					$row['size'] = strlen($row['carddata']);
574
-				}
575
-
576
-				$cards[] = $row;
577
-			}
578
-			$result->closeCursor();
579
-		}
580
-		return $cards;
581
-	}
582
-
583
-	/**
584
-	 * Creates a new card.
585
-	 *
586
-	 * The addressbook id will be passed as the first argument. This is the
587
-	 * same id as it is returned from the getAddressBooksForUser method.
588
-	 *
589
-	 * The cardUri is a base uri, and doesn't include the full path. The
590
-	 * cardData argument is the vcard body, and is passed as a string.
591
-	 *
592
-	 * It is possible to return an ETag from this method. This ETag is for the
593
-	 * newly created resource, and must be enclosed with double quotes (that
594
-	 * is, the string itself must contain the double quotes).
595
-	 *
596
-	 * You should only return the ETag if you store the carddata as-is. If a
597
-	 * subsequent GET request on the same card does not have the same body,
598
-	 * byte-by-byte and you did return an ETag here, clients tend to get
599
-	 * confused.
600
-	 *
601
-	 * If you don't return an ETag, you can just return null.
602
-	 *
603
-	 * @param mixed $addressBookId
604
-	 * @param string $cardUri
605
-	 * @param string $cardData
606
-	 * @param bool $checkAlreadyExists
607
-	 * @return string
608
-	 */
609
-	public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
610
-		$etag = md5($cardData);
611
-		$uid = $this->getUID($cardData);
612
-		return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
613
-			if ($checkAlreadyExists) {
614
-				$q = $this->db->getQueryBuilder();
615
-				$q->select('uid')
616
-					->from($this->dbCardsTable)
617
-					->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId)))
618
-					->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
619
-					->setMaxResults(1);
620
-				$result = $q->executeQuery();
621
-				$count = (bool)$result->fetchOne();
622
-				$result->closeCursor();
623
-				if ($count) {
624
-					throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
625
-				}
626
-			}
627
-
628
-			$query = $this->db->getQueryBuilder();
629
-			$query->insert('cards')
630
-				->values([
631
-					'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB),
632
-					'uri' => $query->createNamedParameter($cardUri),
633
-					'lastmodified' => $query->createNamedParameter(time()),
634
-					'addressbookid' => $query->createNamedParameter($addressBookId),
635
-					'size' => $query->createNamedParameter(strlen($cardData)),
636
-					'etag' => $query->createNamedParameter($etag),
637
-					'uid' => $query->createNamedParameter($uid),
638
-				])
639
-				->executeStatement();
640
-
641
-			$etagCacheKey = "$addressBookId#$cardUri";
642
-			$this->etagCache[$etagCacheKey] = $etag;
643
-
644
-			$this->addChange($addressBookId, $cardUri, 1);
645
-			$this->updateProperties($addressBookId, $cardUri, $cardData);
646
-
647
-			$addressBookData = $this->getAddressBookById($addressBookId);
648
-			$shares = $this->getShares($addressBookId);
649
-			$objectRow = $this->getCard($addressBookId, $cardUri);
650
-			$this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
651
-
652
-			return '"' . $etag . '"';
653
-		}, $this->db);
654
-	}
655
-
656
-	/**
657
-	 * Updates a card.
658
-	 *
659
-	 * The addressbook id will be passed as the first argument. This is the
660
-	 * same id as it is returned from the getAddressBooksForUser method.
661
-	 *
662
-	 * The cardUri is a base uri, and doesn't include the full path. The
663
-	 * cardData argument is the vcard body, and is passed as a string.
664
-	 *
665
-	 * It is possible to return an ETag from this method. This ETag should
666
-	 * match that of the updated resource, and must be enclosed with double
667
-	 * quotes (that is: the string itself must contain the actual quotes).
668
-	 *
669
-	 * You should only return the ETag if you store the carddata as-is. If a
670
-	 * subsequent GET request on the same card does not have the same body,
671
-	 * byte-by-byte and you did return an ETag here, clients tend to get
672
-	 * confused.
673
-	 *
674
-	 * If you don't return an ETag, you can just return null.
675
-	 *
676
-	 * @param mixed $addressBookId
677
-	 * @param string $cardUri
678
-	 * @param string $cardData
679
-	 * @return string
680
-	 */
681
-	public function updateCard($addressBookId, $cardUri, $cardData) {
682
-		$uid = $this->getUID($cardData);
683
-		$etag = md5($cardData);
684
-
685
-		return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $uid, $etag) {
686
-			$query = $this->db->getQueryBuilder();
687
-
688
-			// check for recently stored etag and stop if it is the same
689
-			$etagCacheKey = "$addressBookId#$cardUri";
690
-			if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) {
691
-				return '"' . $etag . '"';
692
-			}
693
-
694
-			$query->update($this->dbCardsTable)
695
-				->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB))
696
-				->set('lastmodified', $query->createNamedParameter(time()))
697
-				->set('size', $query->createNamedParameter(strlen($cardData)))
698
-				->set('etag', $query->createNamedParameter($etag))
699
-				->set('uid', $query->createNamedParameter($uid))
700
-				->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
701
-				->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
702
-				->executeStatement();
703
-
704
-			$this->etagCache[$etagCacheKey] = $etag;
705
-
706
-			$this->addChange($addressBookId, $cardUri, 2);
707
-			$this->updateProperties($addressBookId, $cardUri, $cardData);
708
-
709
-			$addressBookData = $this->getAddressBookById($addressBookId);
710
-			$shares = $this->getShares($addressBookId);
711
-			$objectRow = $this->getCard($addressBookId, $cardUri);
712
-			$this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
713
-			return '"' . $etag . '"';
714
-		}, $this->db);
715
-	}
716
-
717
-	/**
718
-	 * @throws Exception
719
-	 */
720
-	public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int $targetAddressBookId, string $tragetObjectUri): bool {
721
-		return $this->atomic(function () use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) {
722
-			$card = $this->getCard($sourceAddressBookId, $sourceObjectUri);
723
-			if (empty($card)) {
724
-				return false;
725
-			}
726
-			$sourceObjectId = (int)$card['id'];
727
-
728
-			$query = $this->db->getQueryBuilder();
729
-			$query->update('cards')
730
-				->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT))
731
-				->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR))
732
-				->where($query->expr()->eq('uri', $query->createNamedParameter($sourceObjectUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
733
-				->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($sourceAddressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
734
-				->executeStatement();
735
-
736
-			$this->purgeProperties($sourceAddressBookId, $sourceObjectId);
737
-			$this->updateProperties($targetAddressBookId, $tragetObjectUri, $card['carddata']);
738
-
739
-			$this->addChange($sourceAddressBookId, $sourceObjectUri, 3);
740
-			$this->addChange($targetAddressBookId, $tragetObjectUri, 1);
741
-
742
-			$card = $this->getCard($targetAddressBookId, $tragetObjectUri);
743
-			// Card wasn't found - possibly because it was deleted in the meantime by a different client
744
-			if (empty($card)) {
745
-				return false;
746
-			}
747
-			$targetAddressBookRow = $this->getAddressBookById($targetAddressBookId);
748
-			// the address book this card is being moved to does not exist any longer
749
-			if (empty($targetAddressBookRow)) {
750
-				return false;
751
-			}
752
-
753
-			$sourceShares = $this->getShares($sourceAddressBookId);
754
-			$targetShares = $this->getShares($targetAddressBookId);
755
-			$sourceAddressBookRow = $this->getAddressBookById($sourceAddressBookId);
756
-			$this->dispatcher->dispatchTyped(new CardMovedEvent($sourceAddressBookId, $sourceAddressBookRow, $targetAddressBookId, $targetAddressBookRow, $sourceShares, $targetShares, $card));
757
-			return true;
758
-		}, $this->db);
759
-	}
760
-
761
-	/**
762
-	 * Deletes a card
763
-	 *
764
-	 * @param mixed $addressBookId
765
-	 * @param string $cardUri
766
-	 * @return bool
767
-	 */
768
-	public function deleteCard($addressBookId, $cardUri) {
769
-		return $this->atomic(function () use ($addressBookId, $cardUri) {
770
-			$addressBookData = $this->getAddressBookById($addressBookId);
771
-			$shares = $this->getShares($addressBookId);
772
-			$objectRow = $this->getCard($addressBookId, $cardUri);
773
-
774
-			try {
775
-				$cardId = $this->getCardId($addressBookId, $cardUri);
776
-			} catch (\InvalidArgumentException $e) {
777
-				$cardId = null;
778
-			}
779
-			$query = $this->db->getQueryBuilder();
780
-			$ret = $query->delete($this->dbCardsTable)
781
-				->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
782
-				->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
783
-				->executeStatement();
784
-
785
-			$this->addChange($addressBookId, $cardUri, 3);
786
-
787
-			if ($ret === 1) {
788
-				if ($cardId !== null) {
789
-					$this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow));
790
-					$this->purgeProperties($addressBookId, $cardId);
791
-				}
792
-				return true;
793
-			}
794
-
795
-			return false;
796
-		}, $this->db);
797
-	}
798
-
799
-	/**
800
-	 * The getChanges method returns all the changes that have happened, since
801
-	 * the specified syncToken in the specified address book.
802
-	 *
803
-	 * This function should return an array, such as the following:
804
-	 *
805
-	 * [
806
-	 *   'syncToken' => 'The current synctoken',
807
-	 *   'added'   => [
808
-	 *      'new.txt',
809
-	 *   ],
810
-	 *   'modified'   => [
811
-	 *      'modified.txt',
812
-	 *   ],
813
-	 *   'deleted' => [
814
-	 *      'foo.php.bak',
815
-	 *      'old.txt'
816
-	 *   ]
817
-	 * ];
818
-	 *
819
-	 * The returned syncToken property should reflect the *current* syncToken
820
-	 * of the calendar, as reported in the {http://sabredav.org/ns}sync-token
821
-	 * property. This is needed here too, to ensure the operation is atomic.
822
-	 *
823
-	 * If the $syncToken argument is specified as null, this is an initial
824
-	 * sync, and all members should be reported.
825
-	 *
826
-	 * The modified property is an array of nodenames that have changed since
827
-	 * the last token.
828
-	 *
829
-	 * The deleted property is an array with nodenames, that have been deleted
830
-	 * from collection.
831
-	 *
832
-	 * The $syncLevel argument is basically the 'depth' of the report. If it's
833
-	 * 1, you only have to report changes that happened only directly in
834
-	 * immediate descendants. If it's 2, it should also include changes from
835
-	 * the nodes below the child collections. (grandchildren)
836
-	 *
837
-	 * The $limit argument allows a client to specify how many results should
838
-	 * be returned at most. If the limit is not specified, it should be treated
839
-	 * as infinite.
840
-	 *
841
-	 * If the limit (infinite or not) is higher than you're willing to return,
842
-	 * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception.
843
-	 *
844
-	 * If the syncToken is expired (due to data cleanup) or unknown, you must
845
-	 * return null.
846
-	 *
847
-	 * The limit is 'suggestive'. You are free to ignore it.
848
-	 *
849
-	 * @param string $addressBookId
850
-	 * @param string $syncToken
851
-	 * @param int $syncLevel
852
-	 * @param int|null $limit
853
-	 * @return array
854
-	 */
855
-	public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
856
-		$maxLimit = $this->config->getSystemValueInt('carddav_sync_request_truncation', 2500);
857
-		$limit = ($limit === null) ? $maxLimit : min($limit, $maxLimit);
858
-		// Current synctoken
859
-		return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) {
860
-			$qb = $this->db->getQueryBuilder();
861
-			$qb->select('synctoken')
862
-				->from('addressbooks')
863
-				->where(
864
-					$qb->expr()->eq('id', $qb->createNamedParameter($addressBookId))
865
-				);
866
-			$stmt = $qb->executeQuery();
867
-			$currentToken = $stmt->fetchOne();
868
-			$stmt->closeCursor();
869
-
870
-			if (is_null($currentToken)) {
871
-				return [];
872
-			}
873
-
874
-			$result = [
875
-				'syncToken' => $currentToken,
876
-				'added' => [],
877
-				'modified' => [],
878
-				'deleted' => [],
879
-			];
880
-			if (str_starts_with($syncToken, 'init_')) {
881
-				$syncValues = explode('_', $syncToken);
882
-				$lastID = $syncValues[1];
883
-				$initialSyncToken = $syncValues[2];
884
-				$qb = $this->db->getQueryBuilder();
885
-				$qb->select('id', 'uri')
886
-					->from('cards')
887
-					->where(
888
-						$qb->expr()->andX(
889
-							$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)),
890
-							$qb->expr()->gt('id', $qb->createNamedParameter($lastID)))
891
-					)->orderBy('id')
892
-					->setMaxResults($limit);
893
-				$stmt = $qb->executeQuery();
894
-				$values = $stmt->fetchAll(\PDO::FETCH_ASSOC);
895
-				$stmt->closeCursor();
896
-				if (count($values) === 0) {
897
-					$result['syncToken'] = $initialSyncToken;
898
-					$result['result_truncated'] = false;
899
-					$result['added'] = [];
900
-				} else {
901
-					$lastID = $values[array_key_last($values)]['id'];
902
-					$result['added'] = array_column($values, 'uri');
903
-					$result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken ;
904
-					$result['result_truncated'] = count($result['added']) >= $limit;
905
-				}
906
-			} elseif ($syncToken) {
907
-				$qb = $this->db->getQueryBuilder();
908
-				$qb->select('uri', 'operation', 'synctoken')
909
-					->from('addressbookchanges')
910
-					->where(
911
-						$qb->expr()->andX(
912
-							$qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)),
913
-							$qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)),
914
-							$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
915
-						)
916
-					)->orderBy('synctoken');
917
-
918
-				if ($limit > 0) {
919
-					$qb->setMaxResults($limit);
920
-				}
921
-
922
-				// Fetching all changes
923
-				$stmt = $qb->executeQuery();
924
-				$rowCount = $stmt->rowCount();
925
-
926
-				$changes = [];
927
-				$highestSyncToken = 0;
928
-
929
-				// This loop ensures that any duplicates are overwritten, only the
930
-				// last change on a node is relevant.
931
-				while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
932
-					$changes[$row['uri']] = $row['operation'];
933
-					$highestSyncToken = $row['synctoken'];
934
-				}
935
-
936
-				$stmt->closeCursor();
937
-
938
-				// No changes found, use current token
939
-				if (empty($changes)) {
940
-					$result['syncToken'] = $currentToken;
941
-				}
942
-
943
-				foreach ($changes as $uri => $operation) {
944
-					switch ($operation) {
945
-						case 1:
946
-							$result['added'][] = $uri;
947
-							break;
948
-						case 2:
949
-							$result['modified'][] = $uri;
950
-							break;
951
-						case 3:
952
-							$result['deleted'][] = $uri;
953
-							break;
954
-					}
955
-				}
956
-
957
-				/*
38
+    use TTransactional;
39
+    public const PERSONAL_ADDRESSBOOK_URI = 'contacts';
40
+    public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts';
41
+
42
+    private string $dbCardsTable = 'cards';
43
+    private string $dbCardsPropertiesTable = 'cards_properties';
44
+
45
+    /** @var array properties to index */
46
+    public static array $indexProperties = [
47
+        'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME',
48
+        'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO',
49
+        'CLOUD', 'X-SOCIALPROFILE'];
50
+
51
+    /**
52
+     * @var string[] Map of uid => display name
53
+     */
54
+    protected array $userDisplayNames;
55
+    private array $etagCache = [];
56
+
57
+    public function __construct(
58
+        private IDBConnection $db,
59
+        private Principal $principalBackend,
60
+        private IUserManager $userManager,
61
+        private IEventDispatcher $dispatcher,
62
+        private Sharing\Backend $sharingBackend,
63
+        private IConfig $config,
64
+    ) {
65
+    }
66
+
67
+    /**
68
+     * Return the number of address books for a principal
69
+     *
70
+     * @param $principalUri
71
+     * @return int
72
+     */
73
+    public function getAddressBooksForUserCount($principalUri) {
74
+        $principalUri = $this->convertPrincipal($principalUri, true);
75
+        $query = $this->db->getQueryBuilder();
76
+        $query->select($query->func()->count('*'))
77
+            ->from('addressbooks')
78
+            ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
79
+
80
+        $result = $query->executeQuery();
81
+        $column = (int)$result->fetchOne();
82
+        $result->closeCursor();
83
+        return $column;
84
+    }
85
+
86
+    /**
87
+     * Returns the list of address books for a specific user.
88
+     *
89
+     * Every addressbook should have the following properties:
90
+     *   id - an arbitrary unique id
91
+     *   uri - the 'basename' part of the url
92
+     *   principaluri - Same as the passed parameter
93
+     *
94
+     * Any additional clark-notation property may be passed besides this. Some
95
+     * common ones are :
96
+     *   {DAV:}displayname
97
+     *   {urn:ietf:params:xml:ns:carddav}addressbook-description
98
+     *   {http://calendarserver.org/ns/}getctag
99
+     *
100
+     * @param string $principalUri
101
+     * @return array
102
+     */
103
+    public function getAddressBooksForUser($principalUri) {
104
+        return $this->atomic(function () use ($principalUri) {
105
+            $principalUriOriginal = $principalUri;
106
+            $principalUri = $this->convertPrincipal($principalUri, true);
107
+            $select = $this->db->getQueryBuilder();
108
+            $select->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
109
+                ->from('addressbooks')
110
+                ->where($select->expr()->eq('principaluri', $select->createNamedParameter($principalUri)));
111
+
112
+            $addressBooks = [];
113
+
114
+            $result = $select->executeQuery();
115
+            while ($row = $result->fetch()) {
116
+                $addressBooks[$row['id']] = [
117
+                    'id' => $row['id'],
118
+                    'uri' => $row['uri'],
119
+                    'principaluri' => $this->convertPrincipal($row['principaluri'], false),
120
+                    '{DAV:}displayname' => $row['displayname'],
121
+                    '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
122
+                    '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
123
+                    '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
124
+                ];
125
+
126
+                $this->addOwnerPrincipal($addressBooks[$row['id']]);
127
+            }
128
+            $result->closeCursor();
129
+
130
+            // query for shared addressbooks
131
+            $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true);
132
+
133
+            $principals[] = $principalUri;
134
+
135
+            $select = $this->db->getQueryBuilder();
136
+            $subSelect = $this->db->getQueryBuilder();
137
+
138
+            $subSelect->select('id')
139
+                ->from('dav_shares', 'd')
140
+                ->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(\OCA\DAV\CardDAV\Sharing\Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
141
+                ->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY));
142
+
143
+
144
+            $select->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access'])
145
+                ->from('dav_shares', 's')
146
+                ->join('s', 'addressbooks', 'a', $select->expr()->eq('s.resourceid', 'a.id'))
147
+                ->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY)))
148
+                ->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('addressbook', IQueryBuilder::PARAM_STR)))
149
+                ->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
150
+            $result = $select->executeQuery();
151
+
152
+            $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
153
+            while ($row = $result->fetch()) {
154
+                if ($row['principaluri'] === $principalUri) {
155
+                    continue;
156
+                }
157
+
158
+                $readOnly = (int)$row['access'] === Backend::ACCESS_READ;
159
+                if (isset($addressBooks[$row['id']])) {
160
+                    if ($readOnly) {
161
+                        // New share can not have more permissions then the old one.
162
+                        continue;
163
+                    }
164
+                    if (isset($addressBooks[$row['id']][$readOnlyPropertyName])
165
+                        && $addressBooks[$row['id']][$readOnlyPropertyName] === 0) {
166
+                        // Old share is already read-write, no more permissions can be gained
167
+                        continue;
168
+                    }
169
+                }
170
+
171
+                [, $name] = \Sabre\Uri\split($row['principaluri']);
172
+                $uri = $row['uri'] . '_shared_by_' . $name;
173
+                $displayName = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? $name ?? '') . ')';
174
+
175
+                $addressBooks[$row['id']] = [
176
+                    'id' => $row['id'],
177
+                    'uri' => $uri,
178
+                    'principaluri' => $principalUriOriginal,
179
+                    '{DAV:}displayname' => $displayName,
180
+                    '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
181
+                    '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
182
+                    '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
183
+                    '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'],
184
+                    $readOnlyPropertyName => $readOnly,
185
+                ];
186
+
187
+                $this->addOwnerPrincipal($addressBooks[$row['id']]);
188
+            }
189
+            $result->closeCursor();
190
+
191
+            return array_values($addressBooks);
192
+        }, $this->db);
193
+    }
194
+
195
+    public function getUsersOwnAddressBooks($principalUri) {
196
+        $principalUri = $this->convertPrincipal($principalUri, true);
197
+        $query = $this->db->getQueryBuilder();
198
+        $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
199
+            ->from('addressbooks')
200
+            ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
201
+
202
+        $addressBooks = [];
203
+
204
+        $result = $query->executeQuery();
205
+        while ($row = $result->fetch()) {
206
+            $addressBooks[$row['id']] = [
207
+                'id' => $row['id'],
208
+                'uri' => $row['uri'],
209
+                'principaluri' => $this->convertPrincipal($row['principaluri'], false),
210
+                '{DAV:}displayname' => $row['displayname'],
211
+                '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
212
+                '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
213
+                '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
214
+            ];
215
+
216
+            $this->addOwnerPrincipal($addressBooks[$row['id']]);
217
+        }
218
+        $result->closeCursor();
219
+
220
+        return array_values($addressBooks);
221
+    }
222
+
223
+    /**
224
+     * @param int $addressBookId
225
+     */
226
+    public function getAddressBookById(int $addressBookId): ?array {
227
+        $query = $this->db->getQueryBuilder();
228
+        $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
229
+            ->from('addressbooks')
230
+            ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
231
+            ->executeQuery();
232
+        $row = $result->fetch();
233
+        $result->closeCursor();
234
+        if (!$row) {
235
+            return null;
236
+        }
237
+
238
+        $addressBook = [
239
+            'id' => $row['id'],
240
+            'uri' => $row['uri'],
241
+            'principaluri' => $row['principaluri'],
242
+            '{DAV:}displayname' => $row['displayname'],
243
+            '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
244
+            '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
245
+            '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
246
+        ];
247
+
248
+        $this->addOwnerPrincipal($addressBook);
249
+
250
+        return $addressBook;
251
+    }
252
+
253
+    public function getAddressBooksByUri(string $principal, string $addressBookUri): ?array {
254
+        $query = $this->db->getQueryBuilder();
255
+        $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
256
+            ->from('addressbooks')
257
+            ->where($query->expr()->eq('uri', $query->createNamedParameter($addressBookUri)))
258
+            ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
259
+            ->setMaxResults(1)
260
+            ->executeQuery();
261
+
262
+        $row = $result->fetch();
263
+        $result->closeCursor();
264
+        if ($row === false) {
265
+            return null;
266
+        }
267
+
268
+        $addressBook = [
269
+            'id' => $row['id'],
270
+            'uri' => $row['uri'],
271
+            'principaluri' => $row['principaluri'],
272
+            '{DAV:}displayname' => $row['displayname'],
273
+            '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
274
+            '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
275
+            '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
276
+
277
+        ];
278
+
279
+        // system address books are always read only
280
+        if ($principal === 'principals/system/system') {
281
+            $addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] = $row['principaluri'];
282
+            $addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'] = true;
283
+        }
284
+
285
+        $this->addOwnerPrincipal($addressBook);
286
+
287
+        return $addressBook;
288
+    }
289
+
290
+    /**
291
+     * Updates properties for an address book.
292
+     *
293
+     * The list of mutations is stored in a Sabre\DAV\PropPatch object.
294
+     * To do the actual updates, you must tell this object which properties
295
+     * you're going to process with the handle() method.
296
+     *
297
+     * Calling the handle method is like telling the PropPatch object "I
298
+     * promise I can handle updating this property".
299
+     *
300
+     * Read the PropPatch documentation for more info and examples.
301
+     *
302
+     * @param string $addressBookId
303
+     * @param \Sabre\DAV\PropPatch $propPatch
304
+     * @return void
305
+     */
306
+    public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
307
+        $supportedProperties = [
308
+            '{DAV:}displayname',
309
+            '{' . Plugin::NS_CARDDAV . '}addressbook-description',
310
+        ];
311
+
312
+        $propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) {
313
+            $updates = [];
314
+            foreach ($mutations as $property => $newValue) {
315
+                switch ($property) {
316
+                    case '{DAV:}displayname':
317
+                        $updates['displayname'] = $newValue;
318
+                        break;
319
+                    case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
320
+                        $updates['description'] = $newValue;
321
+                        break;
322
+                }
323
+            }
324
+            [$addressBookRow, $shares] = $this->atomic(function () use ($addressBookId, $updates) {
325
+                $query = $this->db->getQueryBuilder();
326
+                $query->update('addressbooks');
327
+
328
+                foreach ($updates as $key => $value) {
329
+                    $query->set($key, $query->createNamedParameter($value));
330
+                }
331
+                $query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
332
+                    ->executeStatement();
333
+
334
+                $this->addChange($addressBookId, '', 2);
335
+
336
+                $addressBookRow = $this->getAddressBookById((int)$addressBookId);
337
+                $shares = $this->getShares((int)$addressBookId);
338
+                return [$addressBookRow, $shares];
339
+            }, $this->db);
340
+
341
+            $this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations));
342
+
343
+            return true;
344
+        });
345
+    }
346
+
347
+    /**
348
+     * Creates a new address book
349
+     *
350
+     * @param string $principalUri
351
+     * @param string $url Just the 'basename' of the url.
352
+     * @param array $properties
353
+     * @return int
354
+     * @throws BadRequest
355
+     * @throws Exception
356
+     */
357
+    public function createAddressBook($principalUri, $url, array $properties) {
358
+        if (strlen($url) > 255) {
359
+            throw new BadRequest('URI too long. Address book not created');
360
+        }
361
+
362
+        $values = [
363
+            'displayname' => null,
364
+            'description' => null,
365
+            'principaluri' => $principalUri,
366
+            'uri' => $url,
367
+            'synctoken' => 1
368
+        ];
369
+
370
+        foreach ($properties as $property => $newValue) {
371
+            switch ($property) {
372
+                case '{DAV:}displayname':
373
+                    $values['displayname'] = $newValue;
374
+                    break;
375
+                case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
376
+                    $values['description'] = $newValue;
377
+                    break;
378
+                default:
379
+                    throw new BadRequest('Unknown property: ' . $property);
380
+            }
381
+        }
382
+
383
+        // Fallback to make sure the displayname is set. Some clients may refuse
384
+        // to work with addressbooks not having a displayname.
385
+        if (is_null($values['displayname'])) {
386
+            $values['displayname'] = $url;
387
+        }
388
+
389
+        [$addressBookId, $addressBookRow] = $this->atomic(function () use ($values) {
390
+            $query = $this->db->getQueryBuilder();
391
+            $query->insert('addressbooks')
392
+                ->values([
393
+                    'uri' => $query->createParameter('uri'),
394
+                    'displayname' => $query->createParameter('displayname'),
395
+                    'description' => $query->createParameter('description'),
396
+                    'principaluri' => $query->createParameter('principaluri'),
397
+                    'synctoken' => $query->createParameter('synctoken'),
398
+                ])
399
+                ->setParameters($values)
400
+                ->executeStatement();
401
+
402
+            $addressBookId = $query->getLastInsertId();
403
+            return [
404
+                $addressBookId,
405
+                $this->getAddressBookById($addressBookId),
406
+            ];
407
+        }, $this->db);
408
+
409
+        $this->dispatcher->dispatchTyped(new AddressBookCreatedEvent($addressBookId, $addressBookRow));
410
+
411
+        return $addressBookId;
412
+    }
413
+
414
+    /**
415
+     * Deletes an entire addressbook and all its contents
416
+     *
417
+     * @param mixed $addressBookId
418
+     * @return void
419
+     */
420
+    public function deleteAddressBook($addressBookId) {
421
+        $this->atomic(function () use ($addressBookId): void {
422
+            $addressBookId = (int)$addressBookId;
423
+            $addressBookData = $this->getAddressBookById($addressBookId);
424
+            $shares = $this->getShares($addressBookId);
425
+
426
+            $query = $this->db->getQueryBuilder();
427
+            $query->delete($this->dbCardsTable)
428
+                ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
429
+                ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
430
+                ->executeStatement();
431
+
432
+            $query = $this->db->getQueryBuilder();
433
+            $query->delete('addressbookchanges')
434
+                ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
435
+                ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
436
+                ->executeStatement();
437
+
438
+            $query = $this->db->getQueryBuilder();
439
+            $query->delete('addressbooks')
440
+                ->where($query->expr()->eq('id', $query->createParameter('id')))
441
+                ->setParameter('id', $addressBookId, IQueryBuilder::PARAM_INT)
442
+                ->executeStatement();
443
+
444
+            $this->sharingBackend->deleteAllShares($addressBookId);
445
+
446
+            $query = $this->db->getQueryBuilder();
447
+            $query->delete($this->dbCardsPropertiesTable)
448
+                ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
449
+                ->executeStatement();
450
+
451
+            if ($addressBookData) {
452
+                $this->dispatcher->dispatchTyped(new AddressBookDeletedEvent($addressBookId, $addressBookData, $shares));
453
+            }
454
+        }, $this->db);
455
+    }
456
+
457
+    /**
458
+     * Returns all cards for a specific addressbook id.
459
+     *
460
+     * This method should return the following properties for each card:
461
+     *   * carddata - raw vcard data
462
+     *   * uri - Some unique url
463
+     *   * lastmodified - A unix timestamp
464
+     *
465
+     * It's recommended to also return the following properties:
466
+     *   * etag - A unique etag. This must change every time the card changes.
467
+     *   * size - The size of the card in bytes.
468
+     *
469
+     * If these last two properties are provided, less time will be spent
470
+     * calculating them. If they are specified, you can also omit carddata.
471
+     * This may speed up certain requests, especially with large cards.
472
+     *
473
+     * @param mixed $addressbookId
474
+     * @return array
475
+     */
476
+    public function getCards($addressbookId) {
477
+        $query = $this->db->getQueryBuilder();
478
+        $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
479
+            ->from($this->dbCardsTable)
480
+            ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId)));
481
+
482
+        $cards = [];
483
+
484
+        $result = $query->executeQuery();
485
+        while ($row = $result->fetch()) {
486
+            $row['etag'] = '"' . $row['etag'] . '"';
487
+
488
+            $modified = false;
489
+            $row['carddata'] = $this->readBlob($row['carddata'], $modified);
490
+            if ($modified) {
491
+                $row['size'] = strlen($row['carddata']);
492
+            }
493
+
494
+            $cards[] = $row;
495
+        }
496
+        $result->closeCursor();
497
+
498
+        return $cards;
499
+    }
500
+
501
+    /**
502
+     * Returns a specific card.
503
+     *
504
+     * The same set of properties must be returned as with getCards. The only
505
+     * exception is that 'carddata' is absolutely required.
506
+     *
507
+     * If the card does not exist, you must return false.
508
+     *
509
+     * @param mixed $addressBookId
510
+     * @param string $cardUri
511
+     * @return array
512
+     */
513
+    public function getCard($addressBookId, $cardUri) {
514
+        $query = $this->db->getQueryBuilder();
515
+        $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
516
+            ->from($this->dbCardsTable)
517
+            ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
518
+            ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
519
+            ->setMaxResults(1);
520
+
521
+        $result = $query->executeQuery();
522
+        $row = $result->fetch();
523
+        if (!$row) {
524
+            return false;
525
+        }
526
+        $row['etag'] = '"' . $row['etag'] . '"';
527
+
528
+        $modified = false;
529
+        $row['carddata'] = $this->readBlob($row['carddata'], $modified);
530
+        if ($modified) {
531
+            $row['size'] = strlen($row['carddata']);
532
+        }
533
+
534
+        return $row;
535
+    }
536
+
537
+    /**
538
+     * Returns a list of cards.
539
+     *
540
+     * This method should work identical to getCard, but instead return all the
541
+     * cards in the list as an array.
542
+     *
543
+     * If the backend supports this, it may allow for some speed-ups.
544
+     *
545
+     * @param mixed $addressBookId
546
+     * @param array $uris
547
+     * @return array
548
+     */
549
+    public function getMultipleCards($addressBookId, array $uris) {
550
+        if (empty($uris)) {
551
+            return [];
552
+        }
553
+
554
+        $chunks = array_chunk($uris, 100);
555
+        $cards = [];
556
+
557
+        $query = $this->db->getQueryBuilder();
558
+        $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
559
+            ->from($this->dbCardsTable)
560
+            ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
561
+            ->andWhere($query->expr()->in('uri', $query->createParameter('uri')));
562
+
563
+        foreach ($chunks as $uris) {
564
+            $query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY);
565
+            $result = $query->executeQuery();
566
+
567
+            while ($row = $result->fetch()) {
568
+                $row['etag'] = '"' . $row['etag'] . '"';
569
+
570
+                $modified = false;
571
+                $row['carddata'] = $this->readBlob($row['carddata'], $modified);
572
+                if ($modified) {
573
+                    $row['size'] = strlen($row['carddata']);
574
+                }
575
+
576
+                $cards[] = $row;
577
+            }
578
+            $result->closeCursor();
579
+        }
580
+        return $cards;
581
+    }
582
+
583
+    /**
584
+     * Creates a new card.
585
+     *
586
+     * The addressbook id will be passed as the first argument. This is the
587
+     * same id as it is returned from the getAddressBooksForUser method.
588
+     *
589
+     * The cardUri is a base uri, and doesn't include the full path. The
590
+     * cardData argument is the vcard body, and is passed as a string.
591
+     *
592
+     * It is possible to return an ETag from this method. This ETag is for the
593
+     * newly created resource, and must be enclosed with double quotes (that
594
+     * is, the string itself must contain the double quotes).
595
+     *
596
+     * You should only return the ETag if you store the carddata as-is. If a
597
+     * subsequent GET request on the same card does not have the same body,
598
+     * byte-by-byte and you did return an ETag here, clients tend to get
599
+     * confused.
600
+     *
601
+     * If you don't return an ETag, you can just return null.
602
+     *
603
+     * @param mixed $addressBookId
604
+     * @param string $cardUri
605
+     * @param string $cardData
606
+     * @param bool $checkAlreadyExists
607
+     * @return string
608
+     */
609
+    public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
610
+        $etag = md5($cardData);
611
+        $uid = $this->getUID($cardData);
612
+        return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
613
+            if ($checkAlreadyExists) {
614
+                $q = $this->db->getQueryBuilder();
615
+                $q->select('uid')
616
+                    ->from($this->dbCardsTable)
617
+                    ->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId)))
618
+                    ->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
619
+                    ->setMaxResults(1);
620
+                $result = $q->executeQuery();
621
+                $count = (bool)$result->fetchOne();
622
+                $result->closeCursor();
623
+                if ($count) {
624
+                    throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
625
+                }
626
+            }
627
+
628
+            $query = $this->db->getQueryBuilder();
629
+            $query->insert('cards')
630
+                ->values([
631
+                    'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB),
632
+                    'uri' => $query->createNamedParameter($cardUri),
633
+                    'lastmodified' => $query->createNamedParameter(time()),
634
+                    'addressbookid' => $query->createNamedParameter($addressBookId),
635
+                    'size' => $query->createNamedParameter(strlen($cardData)),
636
+                    'etag' => $query->createNamedParameter($etag),
637
+                    'uid' => $query->createNamedParameter($uid),
638
+                ])
639
+                ->executeStatement();
640
+
641
+            $etagCacheKey = "$addressBookId#$cardUri";
642
+            $this->etagCache[$etagCacheKey] = $etag;
643
+
644
+            $this->addChange($addressBookId, $cardUri, 1);
645
+            $this->updateProperties($addressBookId, $cardUri, $cardData);
646
+
647
+            $addressBookData = $this->getAddressBookById($addressBookId);
648
+            $shares = $this->getShares($addressBookId);
649
+            $objectRow = $this->getCard($addressBookId, $cardUri);
650
+            $this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
651
+
652
+            return '"' . $etag . '"';
653
+        }, $this->db);
654
+    }
655
+
656
+    /**
657
+     * Updates a card.
658
+     *
659
+     * The addressbook id will be passed as the first argument. This is the
660
+     * same id as it is returned from the getAddressBooksForUser method.
661
+     *
662
+     * The cardUri is a base uri, and doesn't include the full path. The
663
+     * cardData argument is the vcard body, and is passed as a string.
664
+     *
665
+     * It is possible to return an ETag from this method. This ETag should
666
+     * match that of the updated resource, and must be enclosed with double
667
+     * quotes (that is: the string itself must contain the actual quotes).
668
+     *
669
+     * You should only return the ETag if you store the carddata as-is. If a
670
+     * subsequent GET request on the same card does not have the same body,
671
+     * byte-by-byte and you did return an ETag here, clients tend to get
672
+     * confused.
673
+     *
674
+     * If you don't return an ETag, you can just return null.
675
+     *
676
+     * @param mixed $addressBookId
677
+     * @param string $cardUri
678
+     * @param string $cardData
679
+     * @return string
680
+     */
681
+    public function updateCard($addressBookId, $cardUri, $cardData) {
682
+        $uid = $this->getUID($cardData);
683
+        $etag = md5($cardData);
684
+
685
+        return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $uid, $etag) {
686
+            $query = $this->db->getQueryBuilder();
687
+
688
+            // check for recently stored etag and stop if it is the same
689
+            $etagCacheKey = "$addressBookId#$cardUri";
690
+            if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) {
691
+                return '"' . $etag . '"';
692
+            }
693
+
694
+            $query->update($this->dbCardsTable)
695
+                ->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB))
696
+                ->set('lastmodified', $query->createNamedParameter(time()))
697
+                ->set('size', $query->createNamedParameter(strlen($cardData)))
698
+                ->set('etag', $query->createNamedParameter($etag))
699
+                ->set('uid', $query->createNamedParameter($uid))
700
+                ->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
701
+                ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
702
+                ->executeStatement();
703
+
704
+            $this->etagCache[$etagCacheKey] = $etag;
705
+
706
+            $this->addChange($addressBookId, $cardUri, 2);
707
+            $this->updateProperties($addressBookId, $cardUri, $cardData);
708
+
709
+            $addressBookData = $this->getAddressBookById($addressBookId);
710
+            $shares = $this->getShares($addressBookId);
711
+            $objectRow = $this->getCard($addressBookId, $cardUri);
712
+            $this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
713
+            return '"' . $etag . '"';
714
+        }, $this->db);
715
+    }
716
+
717
+    /**
718
+     * @throws Exception
719
+     */
720
+    public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int $targetAddressBookId, string $tragetObjectUri): bool {
721
+        return $this->atomic(function () use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) {
722
+            $card = $this->getCard($sourceAddressBookId, $sourceObjectUri);
723
+            if (empty($card)) {
724
+                return false;
725
+            }
726
+            $sourceObjectId = (int)$card['id'];
727
+
728
+            $query = $this->db->getQueryBuilder();
729
+            $query->update('cards')
730
+                ->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT))
731
+                ->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR))
732
+                ->where($query->expr()->eq('uri', $query->createNamedParameter($sourceObjectUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
733
+                ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($sourceAddressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
734
+                ->executeStatement();
735
+
736
+            $this->purgeProperties($sourceAddressBookId, $sourceObjectId);
737
+            $this->updateProperties($targetAddressBookId, $tragetObjectUri, $card['carddata']);
738
+
739
+            $this->addChange($sourceAddressBookId, $sourceObjectUri, 3);
740
+            $this->addChange($targetAddressBookId, $tragetObjectUri, 1);
741
+
742
+            $card = $this->getCard($targetAddressBookId, $tragetObjectUri);
743
+            // Card wasn't found - possibly because it was deleted in the meantime by a different client
744
+            if (empty($card)) {
745
+                return false;
746
+            }
747
+            $targetAddressBookRow = $this->getAddressBookById($targetAddressBookId);
748
+            // the address book this card is being moved to does not exist any longer
749
+            if (empty($targetAddressBookRow)) {
750
+                return false;
751
+            }
752
+
753
+            $sourceShares = $this->getShares($sourceAddressBookId);
754
+            $targetShares = $this->getShares($targetAddressBookId);
755
+            $sourceAddressBookRow = $this->getAddressBookById($sourceAddressBookId);
756
+            $this->dispatcher->dispatchTyped(new CardMovedEvent($sourceAddressBookId, $sourceAddressBookRow, $targetAddressBookId, $targetAddressBookRow, $sourceShares, $targetShares, $card));
757
+            return true;
758
+        }, $this->db);
759
+    }
760
+
761
+    /**
762
+     * Deletes a card
763
+     *
764
+     * @param mixed $addressBookId
765
+     * @param string $cardUri
766
+     * @return bool
767
+     */
768
+    public function deleteCard($addressBookId, $cardUri) {
769
+        return $this->atomic(function () use ($addressBookId, $cardUri) {
770
+            $addressBookData = $this->getAddressBookById($addressBookId);
771
+            $shares = $this->getShares($addressBookId);
772
+            $objectRow = $this->getCard($addressBookId, $cardUri);
773
+
774
+            try {
775
+                $cardId = $this->getCardId($addressBookId, $cardUri);
776
+            } catch (\InvalidArgumentException $e) {
777
+                $cardId = null;
778
+            }
779
+            $query = $this->db->getQueryBuilder();
780
+            $ret = $query->delete($this->dbCardsTable)
781
+                ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
782
+                ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
783
+                ->executeStatement();
784
+
785
+            $this->addChange($addressBookId, $cardUri, 3);
786
+
787
+            if ($ret === 1) {
788
+                if ($cardId !== null) {
789
+                    $this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow));
790
+                    $this->purgeProperties($addressBookId, $cardId);
791
+                }
792
+                return true;
793
+            }
794
+
795
+            return false;
796
+        }, $this->db);
797
+    }
798
+
799
+    /**
800
+     * The getChanges method returns all the changes that have happened, since
801
+     * the specified syncToken in the specified address book.
802
+     *
803
+     * This function should return an array, such as the following:
804
+     *
805
+     * [
806
+     *   'syncToken' => 'The current synctoken',
807
+     *   'added'   => [
808
+     *      'new.txt',
809
+     *   ],
810
+     *   'modified'   => [
811
+     *      'modified.txt',
812
+     *   ],
813
+     *   'deleted' => [
814
+     *      'foo.php.bak',
815
+     *      'old.txt'
816
+     *   ]
817
+     * ];
818
+     *
819
+     * The returned syncToken property should reflect the *current* syncToken
820
+     * of the calendar, as reported in the {http://sabredav.org/ns}sync-token
821
+     * property. This is needed here too, to ensure the operation is atomic.
822
+     *
823
+     * If the $syncToken argument is specified as null, this is an initial
824
+     * sync, and all members should be reported.
825
+     *
826
+     * The modified property is an array of nodenames that have changed since
827
+     * the last token.
828
+     *
829
+     * The deleted property is an array with nodenames, that have been deleted
830
+     * from collection.
831
+     *
832
+     * The $syncLevel argument is basically the 'depth' of the report. If it's
833
+     * 1, you only have to report changes that happened only directly in
834
+     * immediate descendants. If it's 2, it should also include changes from
835
+     * the nodes below the child collections. (grandchildren)
836
+     *
837
+     * The $limit argument allows a client to specify how many results should
838
+     * be returned at most. If the limit is not specified, it should be treated
839
+     * as infinite.
840
+     *
841
+     * If the limit (infinite or not) is higher than you're willing to return,
842
+     * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception.
843
+     *
844
+     * If the syncToken is expired (due to data cleanup) or unknown, you must
845
+     * return null.
846
+     *
847
+     * The limit is 'suggestive'. You are free to ignore it.
848
+     *
849
+     * @param string $addressBookId
850
+     * @param string $syncToken
851
+     * @param int $syncLevel
852
+     * @param int|null $limit
853
+     * @return array
854
+     */
855
+    public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
856
+        $maxLimit = $this->config->getSystemValueInt('carddav_sync_request_truncation', 2500);
857
+        $limit = ($limit === null) ? $maxLimit : min($limit, $maxLimit);
858
+        // Current synctoken
859
+        return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) {
860
+            $qb = $this->db->getQueryBuilder();
861
+            $qb->select('synctoken')
862
+                ->from('addressbooks')
863
+                ->where(
864
+                    $qb->expr()->eq('id', $qb->createNamedParameter($addressBookId))
865
+                );
866
+            $stmt = $qb->executeQuery();
867
+            $currentToken = $stmt->fetchOne();
868
+            $stmt->closeCursor();
869
+
870
+            if (is_null($currentToken)) {
871
+                return [];
872
+            }
873
+
874
+            $result = [
875
+                'syncToken' => $currentToken,
876
+                'added' => [],
877
+                'modified' => [],
878
+                'deleted' => [],
879
+            ];
880
+            if (str_starts_with($syncToken, 'init_')) {
881
+                $syncValues = explode('_', $syncToken);
882
+                $lastID = $syncValues[1];
883
+                $initialSyncToken = $syncValues[2];
884
+                $qb = $this->db->getQueryBuilder();
885
+                $qb->select('id', 'uri')
886
+                    ->from('cards')
887
+                    ->where(
888
+                        $qb->expr()->andX(
889
+                            $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)),
890
+                            $qb->expr()->gt('id', $qb->createNamedParameter($lastID)))
891
+                    )->orderBy('id')
892
+                    ->setMaxResults($limit);
893
+                $stmt = $qb->executeQuery();
894
+                $values = $stmt->fetchAll(\PDO::FETCH_ASSOC);
895
+                $stmt->closeCursor();
896
+                if (count($values) === 0) {
897
+                    $result['syncToken'] = $initialSyncToken;
898
+                    $result['result_truncated'] = false;
899
+                    $result['added'] = [];
900
+                } else {
901
+                    $lastID = $values[array_key_last($values)]['id'];
902
+                    $result['added'] = array_column($values, 'uri');
903
+                    $result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken ;
904
+                    $result['result_truncated'] = count($result['added']) >= $limit;
905
+                }
906
+            } elseif ($syncToken) {
907
+                $qb = $this->db->getQueryBuilder();
908
+                $qb->select('uri', 'operation', 'synctoken')
909
+                    ->from('addressbookchanges')
910
+                    ->where(
911
+                        $qb->expr()->andX(
912
+                            $qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)),
913
+                            $qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)),
914
+                            $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
915
+                        )
916
+                    )->orderBy('synctoken');
917
+
918
+                if ($limit > 0) {
919
+                    $qb->setMaxResults($limit);
920
+                }
921
+
922
+                // Fetching all changes
923
+                $stmt = $qb->executeQuery();
924
+                $rowCount = $stmt->rowCount();
925
+
926
+                $changes = [];
927
+                $highestSyncToken = 0;
928
+
929
+                // This loop ensures that any duplicates are overwritten, only the
930
+                // last change on a node is relevant.
931
+                while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
932
+                    $changes[$row['uri']] = $row['operation'];
933
+                    $highestSyncToken = $row['synctoken'];
934
+                }
935
+
936
+                $stmt->closeCursor();
937
+
938
+                // No changes found, use current token
939
+                if (empty($changes)) {
940
+                    $result['syncToken'] = $currentToken;
941
+                }
942
+
943
+                foreach ($changes as $uri => $operation) {
944
+                    switch ($operation) {
945
+                        case 1:
946
+                            $result['added'][] = $uri;
947
+                            break;
948
+                        case 2:
949
+                            $result['modified'][] = $uri;
950
+                            break;
951
+                        case 3:
952
+                            $result['deleted'][] = $uri;
953
+                            break;
954
+                    }
955
+                }
956
+
957
+                /*
958 958
 				 * The synctoken in oc_addressbooks is always the highest synctoken in oc_addressbookchanges for a given addressbook plus one (see addChange).
959 959
 				 *
960 960
 				 * For truncated results, it is expected that we return the highest token from the response, so the client can continue from the latest change.
@@ -963,574 +963,574 @@  discard block
 block discarded – undo
963 963
 				 *
964 964
 				 * Therefore, we differentiate between truncated and non-truncated results when returning the synctoken.
965 965
 				 */
966
-				if ($rowCount === $limit && $highestSyncToken < $currentToken) {
967
-					$result['syncToken'] = $highestSyncToken;
968
-					$result['result_truncated'] = true;
969
-				}
970
-			} else {
971
-				$qb = $this->db->getQueryBuilder();
972
-				$qb->select('id', 'uri')
973
-					->from('cards')
974
-					->where(
975
-						$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
976
-					);
977
-				// No synctoken supplied, this is the initial sync.
978
-				$qb->setMaxResults($limit);
979
-				$stmt = $qb->executeQuery();
980
-				$values = $stmt->fetchAll(\PDO::FETCH_ASSOC);
981
-				if (empty($values)) {
982
-					$result['added'] = [];
983
-					return $result;
984
-				}
985
-				$lastID = $values[array_key_last($values)]['id'];
986
-				if (count($values) >= $limit) {
987
-					$result['syncToken'] = 'init_' . $lastID . '_' . $currentToken;
988
-					$result['result_truncated'] = true;
989
-				}
990
-
991
-				$result['added'] = array_column($values, 'uri');
992
-
993
-				$stmt->closeCursor();
994
-			}
995
-			return $result;
996
-		}, $this->db);
997
-	}
998
-
999
-	/**
1000
-	 * Adds a change record to the addressbookchanges table.
1001
-	 *
1002
-	 * @param mixed $addressBookId
1003
-	 * @param string $objectUri
1004
-	 * @param int $operation 1 = add, 2 = modify, 3 = delete
1005
-	 * @return void
1006
-	 */
1007
-	protected function addChange(int $addressBookId, string $objectUri, int $operation): void {
1008
-		$this->atomic(function () use ($addressBookId, $objectUri, $operation): void {
1009
-			$query = $this->db->getQueryBuilder();
1010
-			$query->select('synctoken')
1011
-				->from('addressbooks')
1012
-				->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)));
1013
-			$result = $query->executeQuery();
1014
-			$syncToken = (int)$result->fetchOne();
1015
-			$result->closeCursor();
1016
-
1017
-			$query = $this->db->getQueryBuilder();
1018
-			$query->insert('addressbookchanges')
1019
-				->values([
1020
-					'uri' => $query->createNamedParameter($objectUri),
1021
-					'synctoken' => $query->createNamedParameter($syncToken),
1022
-					'addressbookid' => $query->createNamedParameter($addressBookId),
1023
-					'operation' => $query->createNamedParameter($operation),
1024
-					'created_at' => time(),
1025
-				])
1026
-				->executeStatement();
1027
-
1028
-			$query = $this->db->getQueryBuilder();
1029
-			$query->update('addressbooks')
1030
-				->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT))
1031
-				->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
1032
-				->executeStatement();
1033
-		}, $this->db);
1034
-	}
1035
-
1036
-	/**
1037
-	 * @param resource|string $cardData
1038
-	 * @param bool $modified
1039
-	 * @return string
1040
-	 */
1041
-	private function readBlob($cardData, &$modified = false) {
1042
-		if (is_resource($cardData)) {
1043
-			$cardData = stream_get_contents($cardData);
1044
-		}
1045
-
1046
-		// Micro optimisation
1047
-		// don't loop through
1048
-		if (str_starts_with($cardData, 'PHOTO:data:')) {
1049
-			return $cardData;
1050
-		}
1051
-
1052
-		$cardDataArray = explode("\r\n", $cardData);
1053
-
1054
-		$cardDataFiltered = [];
1055
-		$removingPhoto = false;
1056
-		foreach ($cardDataArray as $line) {
1057
-			if (str_starts_with($line, 'PHOTO:data:')
1058
-				&& !str_starts_with($line, 'PHOTO:data:image/')) {
1059
-				// Filter out PHOTO data of non-images
1060
-				$removingPhoto = true;
1061
-				$modified = true;
1062
-				continue;
1063
-			}
1064
-
1065
-			if ($removingPhoto) {
1066
-				if (str_starts_with($line, ' ')) {
1067
-					continue;
1068
-				}
1069
-				// No leading space means this is a new property
1070
-				$removingPhoto = false;
1071
-			}
1072
-
1073
-			$cardDataFiltered[] = $line;
1074
-		}
1075
-		return implode("\r\n", $cardDataFiltered);
1076
-	}
1077
-
1078
-	/**
1079
-	 * @param list<array{href: string, commonName: string, readOnly: bool}> $add
1080
-	 * @param list<string> $remove
1081
-	 */
1082
-	public function updateShares(IShareable $shareable, array $add, array $remove): void {
1083
-		$this->atomic(function () use ($shareable, $add, $remove): void {
1084
-			$addressBookId = $shareable->getResourceId();
1085
-			$addressBookData = $this->getAddressBookById($addressBookId);
1086
-			$oldShares = $this->getShares($addressBookId);
1087
-
1088
-			$this->sharingBackend->updateShares($shareable, $add, $remove, $oldShares);
1089
-
1090
-			$this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove));
1091
-		}, $this->db);
1092
-	}
1093
-
1094
-	/**
1095
-	 * Delete all of a user's shares
1096
-	 */
1097
-	public function deleteAllSharesByUser(string $principaluri): void {
1098
-		$this->sharingBackend->deleteAllSharesByUser($principaluri);
1099
-	}
1100
-
1101
-	/**
1102
-	 * Search contacts in a specific address-book
1103
-	 *
1104
-	 * @param int $addressBookId
1105
-	 * @param string $pattern which should match within the $searchProperties
1106
-	 * @param array $searchProperties defines the properties within the query pattern should match
1107
-	 * @param array $options = array() to define the search behavior
1108
-	 *                       - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array
1109
-	 *                       - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are
1110
-	 *                       - 'limit' - Set a numeric limit for the search results
1111
-	 *                       - 'offset' - Set the offset for the limited search results
1112
-	 *                       - 'wildcard' - Whether the search should use wildcards
1113
-	 * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options
1114
-	 * @return array an array of contacts which are arrays of key-value-pairs
1115
-	 */
1116
-	public function search($addressBookId, $pattern, $searchProperties, $options = []): array {
1117
-		return $this->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) {
1118
-			return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options);
1119
-		}, $this->db);
1120
-	}
1121
-
1122
-	/**
1123
-	 * Search contacts in all address-books accessible by a user
1124
-	 *
1125
-	 * @param string $principalUri
1126
-	 * @param string $pattern
1127
-	 * @param array $searchProperties
1128
-	 * @param array $options
1129
-	 * @return array
1130
-	 */
1131
-	public function searchPrincipalUri(string $principalUri,
1132
-		string $pattern,
1133
-		array $searchProperties,
1134
-		array $options = []): array {
1135
-		return $this->atomic(function () use ($principalUri, $pattern, $searchProperties, $options) {
1136
-			$addressBookIds = array_map(static function ($row):int {
1137
-				return (int)$row['id'];
1138
-			}, $this->getAddressBooksForUser($principalUri));
1139
-
1140
-			return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options);
1141
-		}, $this->db);
1142
-	}
1143
-
1144
-	/**
1145
-	 * @param int[] $addressBookIds
1146
-	 * @param string $pattern
1147
-	 * @param array $searchProperties
1148
-	 * @param array $options
1149
-	 * @psalm-param array{
1150
-	 *   types?: bool,
1151
-	 *   escape_like_param?: bool,
1152
-	 *   limit?: int,
1153
-	 *   offset?: int,
1154
-	 *   wildcard?: bool,
1155
-	 *   since?: DateTimeFilter|null,
1156
-	 *   until?: DateTimeFilter|null,
1157
-	 *   person?: string
1158
-	 * } $options
1159
-	 * @return array
1160
-	 */
1161
-	private function searchByAddressBookIds(array $addressBookIds,
1162
-		string $pattern,
1163
-		array $searchProperties,
1164
-		array $options = []): array {
1165
-		if (empty($addressBookIds)) {
1166
-			return [];
1167
-		}
1168
-		$escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false;
1169
-		$useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false;
1170
-
1171
-		if ($escapePattern) {
1172
-			$searchProperties = array_filter($searchProperties, function ($property) use ($pattern) {
1173
-				if ($property === 'EMAIL' && str_contains($pattern, ' ')) {
1174
-					// There can be no spaces in emails
1175
-					return false;
1176
-				}
1177
-
1178
-				if ($property === 'CLOUD' && preg_match('/[^a-zA-Z0-9 :_.@\/\-\']/', $pattern) === 1) {
1179
-					// There can be no chars in cloud ids which are not valid for user ids plus :/
1180
-					// worst case: CA61590A-BBBC-423E-84AF-E6DF01455A53@https://my.nxt/srv/
1181
-					return false;
1182
-				}
1183
-
1184
-				return true;
1185
-			});
1186
-		}
1187
-
1188
-		if (empty($searchProperties)) {
1189
-			return [];
1190
-		}
1191
-
1192
-		$query2 = $this->db->getQueryBuilder();
1193
-		$query2->selectDistinct('cp.cardid')
1194
-			->from($this->dbCardsPropertiesTable, 'cp')
1195
-			->where($query2->expr()->in('cp.addressbookid', $query2->createNamedParameter($addressBookIds, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
1196
-			->andWhere($query2->expr()->in('cp.name', $query2->createNamedParameter($searchProperties, IQueryBuilder::PARAM_STR_ARRAY)));
1197
-
1198
-		// No need for like when the pattern is empty
1199
-		if ($pattern !== '') {
1200
-			if (!$useWildcards) {
1201
-				$query2->andWhere($query2->expr()->eq('cp.value', $query2->createNamedParameter($pattern)));
1202
-			} elseif (!$escapePattern) {
1203
-				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter($pattern)));
1204
-			} else {
1205
-				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
1206
-			}
1207
-		}
1208
-		if (isset($options['limit'])) {
1209
-			$query2->setMaxResults($options['limit']);
1210
-		}
1211
-		if (isset($options['offset'])) {
1212
-			$query2->setFirstResult($options['offset']);
1213
-		}
1214
-
1215
-		if (isset($options['person'])) {
1216
-			$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($options['person']) . '%')));
1217
-		}
1218
-		if (isset($options['since']) || isset($options['until'])) {
1219
-			$query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid');
1220
-			$query2->andWhere($query2->expr()->eq('cp_bday.name', $query2->createNamedParameter('BDAY')));
1221
-			/**
1222
-			 * FIXME Find a way to match only 4 last digits
1223
-			 * BDAY can be --1018 without year or 20001019 with it
1224
-			 * $bDayOr = [];
1225
-			 * if ($options['since'] instanceof DateTimeFilter) {
1226
-			 * $bDayOr[] =
1227
-			 * $query2->expr()->gte('SUBSTR(cp_bday.value, -4)',
1228
-			 * $query2->createNamedParameter($options['since']->get()->format('md'))
1229
-			 * );
1230
-			 * }
1231
-			 * if ($options['until'] instanceof DateTimeFilter) {
1232
-			 * $bDayOr[] =
1233
-			 * $query2->expr()->lte('SUBSTR(cp_bday.value, -4)',
1234
-			 * $query2->createNamedParameter($options['until']->get()->format('md'))
1235
-			 * );
1236
-			 * }
1237
-			 * $query2->andWhere($query2->expr()->orX(...$bDayOr));
1238
-			 */
1239
-		}
1240
-
1241
-		$result = $query2->executeQuery();
1242
-		$matches = $result->fetchAll();
1243
-		$result->closeCursor();
1244
-		$matches = array_map(function ($match) {
1245
-			return (int)$match['cardid'];
1246
-		}, $matches);
1247
-
1248
-		$cardResults = [];
1249
-		$query = $this->db->getQueryBuilder();
1250
-		$query->select('c.addressbookid', 'c.carddata', 'c.uri')
1251
-			->from($this->dbCardsTable, 'c')
1252
-			->where($query->expr()->in('c.id', $query->createParameter('matches')));
1253
-
1254
-		foreach (array_chunk($matches, 1000) as $matchesChunk) {
1255
-			$query->setParameter('matches', $matchesChunk, IQueryBuilder::PARAM_INT_ARRAY);
1256
-			$result = $query->executeQuery();
1257
-			$cardResults[] = $result->fetchAll();
1258
-			$result->closeCursor();
1259
-		}
1260
-
1261
-		$cards = array_merge(...$cardResults);
1262
-		return array_map(function ($array) {
1263
-			$array['addressbookid'] = (int)$array['addressbookid'];
1264
-			$modified = false;
1265
-			$array['carddata'] = $this->readBlob($array['carddata'], $modified);
1266
-			if ($modified) {
1267
-				$array['size'] = strlen($array['carddata']);
1268
-			}
1269
-			return $array;
1270
-		}, $cards);
1271
-	}
1272
-
1273
-	/**
1274
-	 * @param int $bookId
1275
-	 * @param string $name
1276
-	 * @return array
1277
-	 */
1278
-	public function collectCardProperties($bookId, $name) {
1279
-		$query = $this->db->getQueryBuilder();
1280
-		$result = $query->selectDistinct('value')
1281
-			->from($this->dbCardsPropertiesTable)
1282
-			->where($query->expr()->eq('name', $query->createNamedParameter($name)))
1283
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId)))
1284
-			->executeQuery();
1285
-
1286
-		$all = $result->fetchAll(PDO::FETCH_COLUMN);
1287
-		$result->closeCursor();
1288
-
1289
-		return $all;
1290
-	}
1291
-
1292
-	/**
1293
-	 * get URI from a given contact
1294
-	 *
1295
-	 * @param int $id
1296
-	 * @return string
1297
-	 */
1298
-	public function getCardUri($id) {
1299
-		$query = $this->db->getQueryBuilder();
1300
-		$query->select('uri')->from($this->dbCardsTable)
1301
-			->where($query->expr()->eq('id', $query->createParameter('id')))
1302
-			->setParameter('id', $id);
1303
-
1304
-		$result = $query->executeQuery();
1305
-		$uri = $result->fetch();
1306
-		$result->closeCursor();
1307
-
1308
-		if (!isset($uri['uri'])) {
1309
-			throw new \InvalidArgumentException('Card does not exists: ' . $id);
1310
-		}
1311
-
1312
-		return $uri['uri'];
1313
-	}
1314
-
1315
-	/**
1316
-	 * return contact with the given URI
1317
-	 *
1318
-	 * @param int $addressBookId
1319
-	 * @param string $uri
1320
-	 * @returns array
1321
-	 */
1322
-	public function getContact($addressBookId, $uri) {
1323
-		$result = [];
1324
-		$query = $this->db->getQueryBuilder();
1325
-		$query->select('*')->from($this->dbCardsTable)
1326
-			->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1327
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1328
-		$queryResult = $query->executeQuery();
1329
-		$contact = $queryResult->fetch();
1330
-		$queryResult->closeCursor();
1331
-
1332
-		if (is_array($contact)) {
1333
-			$modified = false;
1334
-			$contact['etag'] = '"' . $contact['etag'] . '"';
1335
-			$contact['carddata'] = $this->readBlob($contact['carddata'], $modified);
1336
-			if ($modified) {
1337
-				$contact['size'] = strlen($contact['carddata']);
1338
-			}
1339
-
1340
-			$result = $contact;
1341
-		}
1342
-
1343
-		return $result;
1344
-	}
1345
-
1346
-	/**
1347
-	 * Returns the list of people whom this address book is shared with.
1348
-	 *
1349
-	 * Every element in this array should have the following properties:
1350
-	 *   * href - Often a mailto: address
1351
-	 *   * commonName - Optional, for example a first + last name
1352
-	 *   * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants.
1353
-	 *   * readOnly - boolean
1354
-	 *
1355
-	 * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}>
1356
-	 */
1357
-	public function getShares(int $addressBookId): array {
1358
-		return $this->sharingBackend->getShares($addressBookId);
1359
-	}
1360
-
1361
-	/**
1362
-	 * update properties table
1363
-	 *
1364
-	 * @param int $addressBookId
1365
-	 * @param string $cardUri
1366
-	 * @param string $vCardSerialized
1367
-	 */
1368
-	protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) {
1369
-		$this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void {
1370
-			$cardId = $this->getCardId($addressBookId, $cardUri);
1371
-			$vCard = $this->readCard($vCardSerialized);
1372
-
1373
-			$this->purgeProperties($addressBookId, $cardId);
1374
-
1375
-			$query = $this->db->getQueryBuilder();
1376
-			$query->insert($this->dbCardsPropertiesTable)
1377
-				->values(
1378
-					[
1379
-						'addressbookid' => $query->createNamedParameter($addressBookId),
1380
-						'cardid' => $query->createNamedParameter($cardId),
1381
-						'name' => $query->createParameter('name'),
1382
-						'value' => $query->createParameter('value'),
1383
-						'preferred' => $query->createParameter('preferred')
1384
-					]
1385
-				);
1386
-
1387
-			foreach ($vCard->children() as $property) {
1388
-				if (!in_array($property->name, self::$indexProperties)) {
1389
-					continue;
1390
-				}
1391
-				$preferred = 0;
1392
-				foreach ($property->parameters as $parameter) {
1393
-					if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') {
1394
-						$preferred = 1;
1395
-						break;
1396
-					}
1397
-				}
1398
-				$query->setParameter('name', $property->name);
1399
-				$query->setParameter('value', mb_strcut($property->getValue(), 0, 254));
1400
-				$query->setParameter('preferred', $preferred);
1401
-				$query->executeStatement();
1402
-			}
1403
-		}, $this->db);
1404
-	}
1405
-
1406
-	/**
1407
-	 * read vCard data into a vCard object
1408
-	 *
1409
-	 * @param string $cardData
1410
-	 * @return VCard
1411
-	 */
1412
-	protected function readCard($cardData) {
1413
-		return Reader::read($cardData);
1414
-	}
1415
-
1416
-	/**
1417
-	 * delete all properties from a given card
1418
-	 *
1419
-	 * @param int $addressBookId
1420
-	 * @param int $cardId
1421
-	 */
1422
-	protected function purgeProperties($addressBookId, $cardId) {
1423
-		$query = $this->db->getQueryBuilder();
1424
-		$query->delete($this->dbCardsPropertiesTable)
1425
-			->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId)))
1426
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1427
-		$query->executeStatement();
1428
-	}
1429
-
1430
-	/**
1431
-	 * Get ID from a given contact
1432
-	 */
1433
-	protected function getCardId(int $addressBookId, string $uri): int {
1434
-		$query = $this->db->getQueryBuilder();
1435
-		$query->select('id')->from($this->dbCardsTable)
1436
-			->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1437
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1438
-
1439
-		$result = $query->executeQuery();
1440
-		$cardIds = $result->fetch();
1441
-		$result->closeCursor();
1442
-
1443
-		if (!isset($cardIds['id'])) {
1444
-			throw new \InvalidArgumentException('Card does not exists: ' . $uri);
1445
-		}
1446
-
1447
-		return (int)$cardIds['id'];
1448
-	}
1449
-
1450
-	/**
1451
-	 * For shared address books the sharee is set in the ACL of the address book
1452
-	 *
1453
-	 * @param int $addressBookId
1454
-	 * @param list<array{privilege: string, principal: string, protected: bool}> $acl
1455
-	 * @return list<array{privilege: string, principal: string, protected: bool}>
1456
-	 */
1457
-	public function applyShareAcl(int $addressBookId, array $acl): array {
1458
-		$shares = $this->sharingBackend->getShares($addressBookId);
1459
-		return $this->sharingBackend->applyShareAcl($shares, $acl);
1460
-	}
1461
-
1462
-	/**
1463
-	 * @throws \InvalidArgumentException
1464
-	 */
1465
-	public function pruneOutdatedSyncTokens(int $keep, int $retention): int {
1466
-		if ($keep < 0) {
1467
-			throw new \InvalidArgumentException();
1468
-		}
1469
-
1470
-		$query = $this->db->getQueryBuilder();
1471
-		$query->select($query->func()->max('id'))
1472
-			->from('addressbookchanges');
1473
-
1474
-		$result = $query->executeQuery();
1475
-		$maxId = (int)$result->fetchOne();
1476
-		$result->closeCursor();
1477
-		if (!$maxId || $maxId < $keep) {
1478
-			return 0;
1479
-		}
1480
-
1481
-		$query = $this->db->getQueryBuilder();
1482
-		$query->delete('addressbookchanges')
1483
-			->where(
1484
-				$query->expr()->lte('id', $query->createNamedParameter($maxId - $keep, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
1485
-				$query->expr()->lte('created_at', $query->createNamedParameter($retention)),
1486
-			);
1487
-		return $query->executeStatement();
1488
-	}
1489
-
1490
-	private function convertPrincipal(string $principalUri, bool $toV2): string {
1491
-		if ($this->principalBackend->getPrincipalPrefix() === 'principals') {
1492
-			[, $name] = \Sabre\Uri\split($principalUri);
1493
-			if ($toV2 === true) {
1494
-				return "principals/users/$name";
1495
-			}
1496
-			return "principals/$name";
1497
-		}
1498
-		return $principalUri;
1499
-	}
1500
-
1501
-	private function addOwnerPrincipal(array &$addressbookInfo): void {
1502
-		$ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
1503
-		$displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
1504
-		if (isset($addressbookInfo[$ownerPrincipalKey])) {
1505
-			$uri = $addressbookInfo[$ownerPrincipalKey];
1506
-		} else {
1507
-			$uri = $addressbookInfo['principaluri'];
1508
-		}
1509
-
1510
-		$principalInformation = $this->principalBackend->getPrincipalByPath($uri);
1511
-		if (isset($principalInformation['{DAV:}displayname'])) {
1512
-			$addressbookInfo[$displaynameKey] = $principalInformation['{DAV:}displayname'];
1513
-		}
1514
-	}
1515
-
1516
-	/**
1517
-	 * Extract UID from vcard
1518
-	 *
1519
-	 * @param string $cardData the vcard raw data
1520
-	 * @return string the uid
1521
-	 * @throws BadRequest if no UID is available or vcard is empty
1522
-	 */
1523
-	private function getUID(string $cardData): string {
1524
-		if ($cardData !== '') {
1525
-			$vCard = Reader::read($cardData);
1526
-			if ($vCard->UID) {
1527
-				$uid = $vCard->UID->getValue();
1528
-				return $uid;
1529
-			}
1530
-			// should already be handled, but just in case
1531
-			throw new BadRequest('vCards on CardDAV servers MUST have a UID property');
1532
-		}
1533
-		// should already be handled, but just in case
1534
-		throw new BadRequest('vCard can not be empty');
1535
-	}
966
+                if ($rowCount === $limit && $highestSyncToken < $currentToken) {
967
+                    $result['syncToken'] = $highestSyncToken;
968
+                    $result['result_truncated'] = true;
969
+                }
970
+            } else {
971
+                $qb = $this->db->getQueryBuilder();
972
+                $qb->select('id', 'uri')
973
+                    ->from('cards')
974
+                    ->where(
975
+                        $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
976
+                    );
977
+                // No synctoken supplied, this is the initial sync.
978
+                $qb->setMaxResults($limit);
979
+                $stmt = $qb->executeQuery();
980
+                $values = $stmt->fetchAll(\PDO::FETCH_ASSOC);
981
+                if (empty($values)) {
982
+                    $result['added'] = [];
983
+                    return $result;
984
+                }
985
+                $lastID = $values[array_key_last($values)]['id'];
986
+                if (count($values) >= $limit) {
987
+                    $result['syncToken'] = 'init_' . $lastID . '_' . $currentToken;
988
+                    $result['result_truncated'] = true;
989
+                }
990
+
991
+                $result['added'] = array_column($values, 'uri');
992
+
993
+                $stmt->closeCursor();
994
+            }
995
+            return $result;
996
+        }, $this->db);
997
+    }
998
+
999
+    /**
1000
+     * Adds a change record to the addressbookchanges table.
1001
+     *
1002
+     * @param mixed $addressBookId
1003
+     * @param string $objectUri
1004
+     * @param int $operation 1 = add, 2 = modify, 3 = delete
1005
+     * @return void
1006
+     */
1007
+    protected function addChange(int $addressBookId, string $objectUri, int $operation): void {
1008
+        $this->atomic(function () use ($addressBookId, $objectUri, $operation): void {
1009
+            $query = $this->db->getQueryBuilder();
1010
+            $query->select('synctoken')
1011
+                ->from('addressbooks')
1012
+                ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)));
1013
+            $result = $query->executeQuery();
1014
+            $syncToken = (int)$result->fetchOne();
1015
+            $result->closeCursor();
1016
+
1017
+            $query = $this->db->getQueryBuilder();
1018
+            $query->insert('addressbookchanges')
1019
+                ->values([
1020
+                    'uri' => $query->createNamedParameter($objectUri),
1021
+                    'synctoken' => $query->createNamedParameter($syncToken),
1022
+                    'addressbookid' => $query->createNamedParameter($addressBookId),
1023
+                    'operation' => $query->createNamedParameter($operation),
1024
+                    'created_at' => time(),
1025
+                ])
1026
+                ->executeStatement();
1027
+
1028
+            $query = $this->db->getQueryBuilder();
1029
+            $query->update('addressbooks')
1030
+                ->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT))
1031
+                ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
1032
+                ->executeStatement();
1033
+        }, $this->db);
1034
+    }
1035
+
1036
+    /**
1037
+     * @param resource|string $cardData
1038
+     * @param bool $modified
1039
+     * @return string
1040
+     */
1041
+    private function readBlob($cardData, &$modified = false) {
1042
+        if (is_resource($cardData)) {
1043
+            $cardData = stream_get_contents($cardData);
1044
+        }
1045
+
1046
+        // Micro optimisation
1047
+        // don't loop through
1048
+        if (str_starts_with($cardData, 'PHOTO:data:')) {
1049
+            return $cardData;
1050
+        }
1051
+
1052
+        $cardDataArray = explode("\r\n", $cardData);
1053
+
1054
+        $cardDataFiltered = [];
1055
+        $removingPhoto = false;
1056
+        foreach ($cardDataArray as $line) {
1057
+            if (str_starts_with($line, 'PHOTO:data:')
1058
+                && !str_starts_with($line, 'PHOTO:data:image/')) {
1059
+                // Filter out PHOTO data of non-images
1060
+                $removingPhoto = true;
1061
+                $modified = true;
1062
+                continue;
1063
+            }
1064
+
1065
+            if ($removingPhoto) {
1066
+                if (str_starts_with($line, ' ')) {
1067
+                    continue;
1068
+                }
1069
+                // No leading space means this is a new property
1070
+                $removingPhoto = false;
1071
+            }
1072
+
1073
+            $cardDataFiltered[] = $line;
1074
+        }
1075
+        return implode("\r\n", $cardDataFiltered);
1076
+    }
1077
+
1078
+    /**
1079
+     * @param list<array{href: string, commonName: string, readOnly: bool}> $add
1080
+     * @param list<string> $remove
1081
+     */
1082
+    public function updateShares(IShareable $shareable, array $add, array $remove): void {
1083
+        $this->atomic(function () use ($shareable, $add, $remove): void {
1084
+            $addressBookId = $shareable->getResourceId();
1085
+            $addressBookData = $this->getAddressBookById($addressBookId);
1086
+            $oldShares = $this->getShares($addressBookId);
1087
+
1088
+            $this->sharingBackend->updateShares($shareable, $add, $remove, $oldShares);
1089
+
1090
+            $this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove));
1091
+        }, $this->db);
1092
+    }
1093
+
1094
+    /**
1095
+     * Delete all of a user's shares
1096
+     */
1097
+    public function deleteAllSharesByUser(string $principaluri): void {
1098
+        $this->sharingBackend->deleteAllSharesByUser($principaluri);
1099
+    }
1100
+
1101
+    /**
1102
+     * Search contacts in a specific address-book
1103
+     *
1104
+     * @param int $addressBookId
1105
+     * @param string $pattern which should match within the $searchProperties
1106
+     * @param array $searchProperties defines the properties within the query pattern should match
1107
+     * @param array $options = array() to define the search behavior
1108
+     *                       - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array
1109
+     *                       - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are
1110
+     *                       - 'limit' - Set a numeric limit for the search results
1111
+     *                       - 'offset' - Set the offset for the limited search results
1112
+     *                       - 'wildcard' - Whether the search should use wildcards
1113
+     * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options
1114
+     * @return array an array of contacts which are arrays of key-value-pairs
1115
+     */
1116
+    public function search($addressBookId, $pattern, $searchProperties, $options = []): array {
1117
+        return $this->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) {
1118
+            return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options);
1119
+        }, $this->db);
1120
+    }
1121
+
1122
+    /**
1123
+     * Search contacts in all address-books accessible by a user
1124
+     *
1125
+     * @param string $principalUri
1126
+     * @param string $pattern
1127
+     * @param array $searchProperties
1128
+     * @param array $options
1129
+     * @return array
1130
+     */
1131
+    public function searchPrincipalUri(string $principalUri,
1132
+        string $pattern,
1133
+        array $searchProperties,
1134
+        array $options = []): array {
1135
+        return $this->atomic(function () use ($principalUri, $pattern, $searchProperties, $options) {
1136
+            $addressBookIds = array_map(static function ($row):int {
1137
+                return (int)$row['id'];
1138
+            }, $this->getAddressBooksForUser($principalUri));
1139
+
1140
+            return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options);
1141
+        }, $this->db);
1142
+    }
1143
+
1144
+    /**
1145
+     * @param int[] $addressBookIds
1146
+     * @param string $pattern
1147
+     * @param array $searchProperties
1148
+     * @param array $options
1149
+     * @psalm-param array{
1150
+     *   types?: bool,
1151
+     *   escape_like_param?: bool,
1152
+     *   limit?: int,
1153
+     *   offset?: int,
1154
+     *   wildcard?: bool,
1155
+     *   since?: DateTimeFilter|null,
1156
+     *   until?: DateTimeFilter|null,
1157
+     *   person?: string
1158
+     * } $options
1159
+     * @return array
1160
+     */
1161
+    private function searchByAddressBookIds(array $addressBookIds,
1162
+        string $pattern,
1163
+        array $searchProperties,
1164
+        array $options = []): array {
1165
+        if (empty($addressBookIds)) {
1166
+            return [];
1167
+        }
1168
+        $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false;
1169
+        $useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false;
1170
+
1171
+        if ($escapePattern) {
1172
+            $searchProperties = array_filter($searchProperties, function ($property) use ($pattern) {
1173
+                if ($property === 'EMAIL' && str_contains($pattern, ' ')) {
1174
+                    // There can be no spaces in emails
1175
+                    return false;
1176
+                }
1177
+
1178
+                if ($property === 'CLOUD' && preg_match('/[^a-zA-Z0-9 :_.@\/\-\']/', $pattern) === 1) {
1179
+                    // There can be no chars in cloud ids which are not valid for user ids plus :/
1180
+                    // worst case: CA61590A-BBBC-423E-84AF-E6DF01455A53@https://my.nxt/srv/
1181
+                    return false;
1182
+                }
1183
+
1184
+                return true;
1185
+            });
1186
+        }
1187
+
1188
+        if (empty($searchProperties)) {
1189
+            return [];
1190
+        }
1191
+
1192
+        $query2 = $this->db->getQueryBuilder();
1193
+        $query2->selectDistinct('cp.cardid')
1194
+            ->from($this->dbCardsPropertiesTable, 'cp')
1195
+            ->where($query2->expr()->in('cp.addressbookid', $query2->createNamedParameter($addressBookIds, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
1196
+            ->andWhere($query2->expr()->in('cp.name', $query2->createNamedParameter($searchProperties, IQueryBuilder::PARAM_STR_ARRAY)));
1197
+
1198
+        // No need for like when the pattern is empty
1199
+        if ($pattern !== '') {
1200
+            if (!$useWildcards) {
1201
+                $query2->andWhere($query2->expr()->eq('cp.value', $query2->createNamedParameter($pattern)));
1202
+            } elseif (!$escapePattern) {
1203
+                $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter($pattern)));
1204
+            } else {
1205
+                $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
1206
+            }
1207
+        }
1208
+        if (isset($options['limit'])) {
1209
+            $query2->setMaxResults($options['limit']);
1210
+        }
1211
+        if (isset($options['offset'])) {
1212
+            $query2->setFirstResult($options['offset']);
1213
+        }
1214
+
1215
+        if (isset($options['person'])) {
1216
+            $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($options['person']) . '%')));
1217
+        }
1218
+        if (isset($options['since']) || isset($options['until'])) {
1219
+            $query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid');
1220
+            $query2->andWhere($query2->expr()->eq('cp_bday.name', $query2->createNamedParameter('BDAY')));
1221
+            /**
1222
+             * FIXME Find a way to match only 4 last digits
1223
+             * BDAY can be --1018 without year or 20001019 with it
1224
+             * $bDayOr = [];
1225
+             * if ($options['since'] instanceof DateTimeFilter) {
1226
+             * $bDayOr[] =
1227
+             * $query2->expr()->gte('SUBSTR(cp_bday.value, -4)',
1228
+             * $query2->createNamedParameter($options['since']->get()->format('md'))
1229
+             * );
1230
+             * }
1231
+             * if ($options['until'] instanceof DateTimeFilter) {
1232
+             * $bDayOr[] =
1233
+             * $query2->expr()->lte('SUBSTR(cp_bday.value, -4)',
1234
+             * $query2->createNamedParameter($options['until']->get()->format('md'))
1235
+             * );
1236
+             * }
1237
+             * $query2->andWhere($query2->expr()->orX(...$bDayOr));
1238
+             */
1239
+        }
1240
+
1241
+        $result = $query2->executeQuery();
1242
+        $matches = $result->fetchAll();
1243
+        $result->closeCursor();
1244
+        $matches = array_map(function ($match) {
1245
+            return (int)$match['cardid'];
1246
+        }, $matches);
1247
+
1248
+        $cardResults = [];
1249
+        $query = $this->db->getQueryBuilder();
1250
+        $query->select('c.addressbookid', 'c.carddata', 'c.uri')
1251
+            ->from($this->dbCardsTable, 'c')
1252
+            ->where($query->expr()->in('c.id', $query->createParameter('matches')));
1253
+
1254
+        foreach (array_chunk($matches, 1000) as $matchesChunk) {
1255
+            $query->setParameter('matches', $matchesChunk, IQueryBuilder::PARAM_INT_ARRAY);
1256
+            $result = $query->executeQuery();
1257
+            $cardResults[] = $result->fetchAll();
1258
+            $result->closeCursor();
1259
+        }
1260
+
1261
+        $cards = array_merge(...$cardResults);
1262
+        return array_map(function ($array) {
1263
+            $array['addressbookid'] = (int)$array['addressbookid'];
1264
+            $modified = false;
1265
+            $array['carddata'] = $this->readBlob($array['carddata'], $modified);
1266
+            if ($modified) {
1267
+                $array['size'] = strlen($array['carddata']);
1268
+            }
1269
+            return $array;
1270
+        }, $cards);
1271
+    }
1272
+
1273
+    /**
1274
+     * @param int $bookId
1275
+     * @param string $name
1276
+     * @return array
1277
+     */
1278
+    public function collectCardProperties($bookId, $name) {
1279
+        $query = $this->db->getQueryBuilder();
1280
+        $result = $query->selectDistinct('value')
1281
+            ->from($this->dbCardsPropertiesTable)
1282
+            ->where($query->expr()->eq('name', $query->createNamedParameter($name)))
1283
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId)))
1284
+            ->executeQuery();
1285
+
1286
+        $all = $result->fetchAll(PDO::FETCH_COLUMN);
1287
+        $result->closeCursor();
1288
+
1289
+        return $all;
1290
+    }
1291
+
1292
+    /**
1293
+     * get URI from a given contact
1294
+     *
1295
+     * @param int $id
1296
+     * @return string
1297
+     */
1298
+    public function getCardUri($id) {
1299
+        $query = $this->db->getQueryBuilder();
1300
+        $query->select('uri')->from($this->dbCardsTable)
1301
+            ->where($query->expr()->eq('id', $query->createParameter('id')))
1302
+            ->setParameter('id', $id);
1303
+
1304
+        $result = $query->executeQuery();
1305
+        $uri = $result->fetch();
1306
+        $result->closeCursor();
1307
+
1308
+        if (!isset($uri['uri'])) {
1309
+            throw new \InvalidArgumentException('Card does not exists: ' . $id);
1310
+        }
1311
+
1312
+        return $uri['uri'];
1313
+    }
1314
+
1315
+    /**
1316
+     * return contact with the given URI
1317
+     *
1318
+     * @param int $addressBookId
1319
+     * @param string $uri
1320
+     * @returns array
1321
+     */
1322
+    public function getContact($addressBookId, $uri) {
1323
+        $result = [];
1324
+        $query = $this->db->getQueryBuilder();
1325
+        $query->select('*')->from($this->dbCardsTable)
1326
+            ->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1327
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1328
+        $queryResult = $query->executeQuery();
1329
+        $contact = $queryResult->fetch();
1330
+        $queryResult->closeCursor();
1331
+
1332
+        if (is_array($contact)) {
1333
+            $modified = false;
1334
+            $contact['etag'] = '"' . $contact['etag'] . '"';
1335
+            $contact['carddata'] = $this->readBlob($contact['carddata'], $modified);
1336
+            if ($modified) {
1337
+                $contact['size'] = strlen($contact['carddata']);
1338
+            }
1339
+
1340
+            $result = $contact;
1341
+        }
1342
+
1343
+        return $result;
1344
+    }
1345
+
1346
+    /**
1347
+     * Returns the list of people whom this address book is shared with.
1348
+     *
1349
+     * Every element in this array should have the following properties:
1350
+     *   * href - Often a mailto: address
1351
+     *   * commonName - Optional, for example a first + last name
1352
+     *   * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants.
1353
+     *   * readOnly - boolean
1354
+     *
1355
+     * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}>
1356
+     */
1357
+    public function getShares(int $addressBookId): array {
1358
+        return $this->sharingBackend->getShares($addressBookId);
1359
+    }
1360
+
1361
+    /**
1362
+     * update properties table
1363
+     *
1364
+     * @param int $addressBookId
1365
+     * @param string $cardUri
1366
+     * @param string $vCardSerialized
1367
+     */
1368
+    protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) {
1369
+        $this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void {
1370
+            $cardId = $this->getCardId($addressBookId, $cardUri);
1371
+            $vCard = $this->readCard($vCardSerialized);
1372
+
1373
+            $this->purgeProperties($addressBookId, $cardId);
1374
+
1375
+            $query = $this->db->getQueryBuilder();
1376
+            $query->insert($this->dbCardsPropertiesTable)
1377
+                ->values(
1378
+                    [
1379
+                        'addressbookid' => $query->createNamedParameter($addressBookId),
1380
+                        'cardid' => $query->createNamedParameter($cardId),
1381
+                        'name' => $query->createParameter('name'),
1382
+                        'value' => $query->createParameter('value'),
1383
+                        'preferred' => $query->createParameter('preferred')
1384
+                    ]
1385
+                );
1386
+
1387
+            foreach ($vCard->children() as $property) {
1388
+                if (!in_array($property->name, self::$indexProperties)) {
1389
+                    continue;
1390
+                }
1391
+                $preferred = 0;
1392
+                foreach ($property->parameters as $parameter) {
1393
+                    if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') {
1394
+                        $preferred = 1;
1395
+                        break;
1396
+                    }
1397
+                }
1398
+                $query->setParameter('name', $property->name);
1399
+                $query->setParameter('value', mb_strcut($property->getValue(), 0, 254));
1400
+                $query->setParameter('preferred', $preferred);
1401
+                $query->executeStatement();
1402
+            }
1403
+        }, $this->db);
1404
+    }
1405
+
1406
+    /**
1407
+     * read vCard data into a vCard object
1408
+     *
1409
+     * @param string $cardData
1410
+     * @return VCard
1411
+     */
1412
+    protected function readCard($cardData) {
1413
+        return Reader::read($cardData);
1414
+    }
1415
+
1416
+    /**
1417
+     * delete all properties from a given card
1418
+     *
1419
+     * @param int $addressBookId
1420
+     * @param int $cardId
1421
+     */
1422
+    protected function purgeProperties($addressBookId, $cardId) {
1423
+        $query = $this->db->getQueryBuilder();
1424
+        $query->delete($this->dbCardsPropertiesTable)
1425
+            ->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId)))
1426
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1427
+        $query->executeStatement();
1428
+    }
1429
+
1430
+    /**
1431
+     * Get ID from a given contact
1432
+     */
1433
+    protected function getCardId(int $addressBookId, string $uri): int {
1434
+        $query = $this->db->getQueryBuilder();
1435
+        $query->select('id')->from($this->dbCardsTable)
1436
+            ->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1437
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1438
+
1439
+        $result = $query->executeQuery();
1440
+        $cardIds = $result->fetch();
1441
+        $result->closeCursor();
1442
+
1443
+        if (!isset($cardIds['id'])) {
1444
+            throw new \InvalidArgumentException('Card does not exists: ' . $uri);
1445
+        }
1446
+
1447
+        return (int)$cardIds['id'];
1448
+    }
1449
+
1450
+    /**
1451
+     * For shared address books the sharee is set in the ACL of the address book
1452
+     *
1453
+     * @param int $addressBookId
1454
+     * @param list<array{privilege: string, principal: string, protected: bool}> $acl
1455
+     * @return list<array{privilege: string, principal: string, protected: bool}>
1456
+     */
1457
+    public function applyShareAcl(int $addressBookId, array $acl): array {
1458
+        $shares = $this->sharingBackend->getShares($addressBookId);
1459
+        return $this->sharingBackend->applyShareAcl($shares, $acl);
1460
+    }
1461
+
1462
+    /**
1463
+     * @throws \InvalidArgumentException
1464
+     */
1465
+    public function pruneOutdatedSyncTokens(int $keep, int $retention): int {
1466
+        if ($keep < 0) {
1467
+            throw new \InvalidArgumentException();
1468
+        }
1469
+
1470
+        $query = $this->db->getQueryBuilder();
1471
+        $query->select($query->func()->max('id'))
1472
+            ->from('addressbookchanges');
1473
+
1474
+        $result = $query->executeQuery();
1475
+        $maxId = (int)$result->fetchOne();
1476
+        $result->closeCursor();
1477
+        if (!$maxId || $maxId < $keep) {
1478
+            return 0;
1479
+        }
1480
+
1481
+        $query = $this->db->getQueryBuilder();
1482
+        $query->delete('addressbookchanges')
1483
+            ->where(
1484
+                $query->expr()->lte('id', $query->createNamedParameter($maxId - $keep, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
1485
+                $query->expr()->lte('created_at', $query->createNamedParameter($retention)),
1486
+            );
1487
+        return $query->executeStatement();
1488
+    }
1489
+
1490
+    private function convertPrincipal(string $principalUri, bool $toV2): string {
1491
+        if ($this->principalBackend->getPrincipalPrefix() === 'principals') {
1492
+            [, $name] = \Sabre\Uri\split($principalUri);
1493
+            if ($toV2 === true) {
1494
+                return "principals/users/$name";
1495
+            }
1496
+            return "principals/$name";
1497
+        }
1498
+        return $principalUri;
1499
+    }
1500
+
1501
+    private function addOwnerPrincipal(array &$addressbookInfo): void {
1502
+        $ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
1503
+        $displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
1504
+        if (isset($addressbookInfo[$ownerPrincipalKey])) {
1505
+            $uri = $addressbookInfo[$ownerPrincipalKey];
1506
+        } else {
1507
+            $uri = $addressbookInfo['principaluri'];
1508
+        }
1509
+
1510
+        $principalInformation = $this->principalBackend->getPrincipalByPath($uri);
1511
+        if (isset($principalInformation['{DAV:}displayname'])) {
1512
+            $addressbookInfo[$displaynameKey] = $principalInformation['{DAV:}displayname'];
1513
+        }
1514
+    }
1515
+
1516
+    /**
1517
+     * Extract UID from vcard
1518
+     *
1519
+     * @param string $cardData the vcard raw data
1520
+     * @return string the uid
1521
+     * @throws BadRequest if no UID is available or vcard is empty
1522
+     */
1523
+    private function getUID(string $cardData): string {
1524
+        if ($cardData !== '') {
1525
+            $vCard = Reader::read($cardData);
1526
+            if ($vCard->UID) {
1527
+                $uid = $vCard->UID->getValue();
1528
+                return $uid;
1529
+            }
1530
+            // should already be handled, but just in case
1531
+            throw new BadRequest('vCards on CardDAV servers MUST have a UID property');
1532
+        }
1533
+        // should already be handled, but just in case
1534
+        throw new BadRequest('vCard can not be empty');
1535
+    }
1536 1536
 }
Please login to merge, or discard this patch.
apps/dav/tests/unit/DAV/Listener/UserEventsListenerTest.php 1 patch
Indentation   +155 added lines, -155 removed lines patch added patch discarded remove patch
@@ -26,159 +26,159 @@
 block discarded – undo
26 26
 use Test\TestCase;
27 27
 
28 28
 class UserEventsListenerTest extends TestCase {
29
-	private IUserManager&MockObject $userManager;
30
-	private SyncService&MockObject $syncService;
31
-	private CalDavBackend&MockObject $calDavBackend;
32
-	private CardDavBackend&MockObject $cardDavBackend;
33
-	private Defaults&MockObject $defaults;
34
-	private ExampleContactService&MockObject $exampleContactService;
35
-	private ExampleEventService&MockObject $exampleEventService;
36
-	private LoggerInterface&MockObject $logger;
37
-
38
-	private UserEventsListener $userEventsListener;
39
-
40
-	protected function setUp(): void {
41
-		parent::setUp();
42
-
43
-		$this->userManager = $this->createMock(IUserManager::class);
44
-		$this->syncService = $this->createMock(SyncService::class);
45
-		$this->calDavBackend = $this->createMock(CalDavBackend::class);
46
-		$this->cardDavBackend = $this->createMock(CardDavBackend::class);
47
-		$this->defaults = $this->createMock(Defaults::class);
48
-		$this->exampleContactService = $this->createMock(ExampleContactService::class);
49
-		$this->exampleEventService = $this->createMock(ExampleEventService::class);
50
-		$this->logger = $this->createMock(LoggerInterface::class);
51
-		$this->jobList = $this->createMock(IJobList::class);
52
-
53
-		$this->userEventsListener = new UserEventsListener(
54
-			$this->userManager,
55
-			$this->syncService,
56
-			$this->calDavBackend,
57
-			$this->cardDavBackend,
58
-			$this->defaults,
59
-			$this->exampleContactService,
60
-			$this->exampleEventService,
61
-			$this->logger,
62
-			$this->jobList,
63
-		);
64
-	}
65
-
66
-	public function test(): void {
67
-		$user = $this->createMock(IUser::class);
68
-		$user->expects($this->once())->method('getUID')->willReturn('newUser');
69
-
70
-		$this->defaults->expects($this->once())->method('getColorPrimary')->willReturn('#745bca');
71
-
72
-		$this->calDavBackend->expects($this->once())->method('getCalendarsForUserCount')->willReturn(0);
73
-		$this->calDavBackend->expects($this->once())->method('createCalendar')->with(
74
-			'principals/users/newUser',
75
-			'personal', [
76
-				'{DAV:}displayname' => 'Personal',
77
-				'{http://apple.com/ns/ical/}calendar-color' => '#745bca',
78
-				'components' => 'VEVENT'
79
-			])
80
-			->willReturn(1000);
81
-		$this->calDavBackend->expects(self::never())
82
-			->method('getCalendarsForUser');
83
-		$this->exampleEventService->expects(self::once())
84
-			->method('createExampleEvent')
85
-			->with(1000);
86
-
87
-		$this->cardDavBackend->expects($this->once())->method('getAddressBooksForUserCount')->willReturn(0);
88
-		$this->cardDavBackend->expects($this->once())->method('createAddressBook')->with(
89
-			'principals/users/newUser',
90
-			'contacts', ['{DAV:}displayname' => 'Contacts']);
91
-
92
-		$this->userEventsListener->firstLogin($user);
93
-	}
94
-
95
-	public function testWithExisting(): void {
96
-		$user = $this->createMock(IUser::class);
97
-		$user->expects($this->once())->method('getUID')->willReturn('newUser');
98
-
99
-		$this->calDavBackend->expects($this->once())->method('getCalendarsForUserCount')->willReturn(1);
100
-		$this->calDavBackend->expects($this->never())->method('createCalendar');
101
-		$this->calDavBackend->expects(self::never())
102
-			->method('createCalendar');
103
-		$this->exampleEventService->expects(self::never())
104
-			->method('createExampleEvent');
105
-
106
-		$this->cardDavBackend->expects($this->once())->method('getAddressBooksForUserCount')->willReturn(1);
107
-		$this->cardDavBackend->expects($this->never())->method('createAddressBook');
108
-
109
-		$this->userEventsListener->firstLogin($user);
110
-	}
111
-
112
-	public function testWithBirthdayCalendar(): void {
113
-		$user = $this->createMock(IUser::class);
114
-		$user->expects($this->once())->method('getUID')->willReturn('newUser');
115
-
116
-		$this->defaults->expects($this->once())->method('getColorPrimary')->willReturn('#745bca');
117
-
118
-		$this->calDavBackend->expects($this->once())->method('getCalendarsForUserCount')->willReturn(0);
119
-		$this->calDavBackend->expects($this->once())->method('createCalendar')->with(
120
-			'principals/users/newUser',
121
-			'personal', [
122
-				'{DAV:}displayname' => 'Personal',
123
-				'{http://apple.com/ns/ical/}calendar-color' => '#745bca',
124
-				'components' => 'VEVENT'
125
-			]);
126
-
127
-		$this->cardDavBackend->expects($this->once())->method('getAddressBooksForUserCount')->willReturn(0);
128
-		$this->cardDavBackend->expects($this->once())->method('createAddressBook')->with(
129
-			'principals/users/newUser',
130
-			'contacts', ['{DAV:}displayname' => 'Contacts']);
131
-
132
-		$this->userEventsListener->firstLogin($user);
133
-	}
134
-
135
-	public function testDeleteUser(): void {
136
-		$user = $this->createMock(IUser::class);
137
-		$user->expects($this->once())->method('getUID')->willReturn('newUser');
138
-
139
-		$this->syncService->expects($this->once())
140
-			->method('deleteUser');
141
-
142
-		$this->calDavBackend->expects($this->once())->method('getUsersOwnCalendars')->willReturn([
143
-			['id' => 'personal']
144
-		]);
145
-		$this->calDavBackend->expects($this->once())->method('getSubscriptionsForUser')->willReturn([
146
-			['id' => 'some-subscription']
147
-		]);
148
-		$this->calDavBackend->expects($this->once())->method('deleteCalendar')->with('personal');
149
-		$this->calDavBackend->expects($this->once())->method('deleteSubscription')->with('some-subscription');
150
-		$this->calDavBackend->expects($this->once())->method('deleteAllSharesByUser');
151
-		$this->cardDavBackend->expects($this->once())->method('deleteAllSharesByUser');
152
-
153
-		$this->cardDavBackend->expects($this->once())->method('getUsersOwnAddressBooks')->willReturn([
154
-			['id' => 'personal']
155
-		]);
156
-		$this->cardDavBackend->expects($this->once())->method('deleteAddressBook');
157
-
158
-		$this->userEventsListener->preDeleteUser($user);
159
-		$this->userEventsListener->postDeleteUser('newUser');
160
-	}
161
-
162
-	public function testDeleteUserAutomationEvent(): void {
163
-		$user = $this->createMock(IUser::class);
164
-		$user->expects($this->once())->method('getUID')->willReturn('newUser');
165
-
166
-		$this->syncService->expects($this->once())
167
-			->method('deleteUser');
168
-
169
-		$this->calDavBackend->expects($this->once())->method('getUsersOwnCalendars')->willReturn([
170
-			['id' => []]
171
-		]);
172
-		$this->calDavBackend->expects($this->once())->method('getSubscriptionsForUser')->willReturn([
173
-			['id' => []]
174
-		]);
175
-		$this->cardDavBackend->expects($this->once())->method('getUsersOwnAddressBooks')->willReturn([
176
-			['id' => []]
177
-		]);
178
-
179
-		$this->jobList->expects(self::once())->method('remove')->with(UserStatusAutomation::class, ['userId' => 'newUser']);
180
-
181
-		$this->userEventsListener->preDeleteUser($user);
182
-		$this->userEventsListener->postDeleteUser('newUser');
183
-	}
29
+    private IUserManager&MockObject $userManager;
30
+    private SyncService&MockObject $syncService;
31
+    private CalDavBackend&MockObject $calDavBackend;
32
+    private CardDavBackend&MockObject $cardDavBackend;
33
+    private Defaults&MockObject $defaults;
34
+    private ExampleContactService&MockObject $exampleContactService;
35
+    private ExampleEventService&MockObject $exampleEventService;
36
+    private LoggerInterface&MockObject $logger;
37
+
38
+    private UserEventsListener $userEventsListener;
39
+
40
+    protected function setUp(): void {
41
+        parent::setUp();
42
+
43
+        $this->userManager = $this->createMock(IUserManager::class);
44
+        $this->syncService = $this->createMock(SyncService::class);
45
+        $this->calDavBackend = $this->createMock(CalDavBackend::class);
46
+        $this->cardDavBackend = $this->createMock(CardDavBackend::class);
47
+        $this->defaults = $this->createMock(Defaults::class);
48
+        $this->exampleContactService = $this->createMock(ExampleContactService::class);
49
+        $this->exampleEventService = $this->createMock(ExampleEventService::class);
50
+        $this->logger = $this->createMock(LoggerInterface::class);
51
+        $this->jobList = $this->createMock(IJobList::class);
52
+
53
+        $this->userEventsListener = new UserEventsListener(
54
+            $this->userManager,
55
+            $this->syncService,
56
+            $this->calDavBackend,
57
+            $this->cardDavBackend,
58
+            $this->defaults,
59
+            $this->exampleContactService,
60
+            $this->exampleEventService,
61
+            $this->logger,
62
+            $this->jobList,
63
+        );
64
+    }
65
+
66
+    public function test(): void {
67
+        $user = $this->createMock(IUser::class);
68
+        $user->expects($this->once())->method('getUID')->willReturn('newUser');
69
+
70
+        $this->defaults->expects($this->once())->method('getColorPrimary')->willReturn('#745bca');
71
+
72
+        $this->calDavBackend->expects($this->once())->method('getCalendarsForUserCount')->willReturn(0);
73
+        $this->calDavBackend->expects($this->once())->method('createCalendar')->with(
74
+            'principals/users/newUser',
75
+            'personal', [
76
+                '{DAV:}displayname' => 'Personal',
77
+                '{http://apple.com/ns/ical/}calendar-color' => '#745bca',
78
+                'components' => 'VEVENT'
79
+            ])
80
+            ->willReturn(1000);
81
+        $this->calDavBackend->expects(self::never())
82
+            ->method('getCalendarsForUser');
83
+        $this->exampleEventService->expects(self::once())
84
+            ->method('createExampleEvent')
85
+            ->with(1000);
86
+
87
+        $this->cardDavBackend->expects($this->once())->method('getAddressBooksForUserCount')->willReturn(0);
88
+        $this->cardDavBackend->expects($this->once())->method('createAddressBook')->with(
89
+            'principals/users/newUser',
90
+            'contacts', ['{DAV:}displayname' => 'Contacts']);
91
+
92
+        $this->userEventsListener->firstLogin($user);
93
+    }
94
+
95
+    public function testWithExisting(): void {
96
+        $user = $this->createMock(IUser::class);
97
+        $user->expects($this->once())->method('getUID')->willReturn('newUser');
98
+
99
+        $this->calDavBackend->expects($this->once())->method('getCalendarsForUserCount')->willReturn(1);
100
+        $this->calDavBackend->expects($this->never())->method('createCalendar');
101
+        $this->calDavBackend->expects(self::never())
102
+            ->method('createCalendar');
103
+        $this->exampleEventService->expects(self::never())
104
+            ->method('createExampleEvent');
105
+
106
+        $this->cardDavBackend->expects($this->once())->method('getAddressBooksForUserCount')->willReturn(1);
107
+        $this->cardDavBackend->expects($this->never())->method('createAddressBook');
108
+
109
+        $this->userEventsListener->firstLogin($user);
110
+    }
111
+
112
+    public function testWithBirthdayCalendar(): void {
113
+        $user = $this->createMock(IUser::class);
114
+        $user->expects($this->once())->method('getUID')->willReturn('newUser');
115
+
116
+        $this->defaults->expects($this->once())->method('getColorPrimary')->willReturn('#745bca');
117
+
118
+        $this->calDavBackend->expects($this->once())->method('getCalendarsForUserCount')->willReturn(0);
119
+        $this->calDavBackend->expects($this->once())->method('createCalendar')->with(
120
+            'principals/users/newUser',
121
+            'personal', [
122
+                '{DAV:}displayname' => 'Personal',
123
+                '{http://apple.com/ns/ical/}calendar-color' => '#745bca',
124
+                'components' => 'VEVENT'
125
+            ]);
126
+
127
+        $this->cardDavBackend->expects($this->once())->method('getAddressBooksForUserCount')->willReturn(0);
128
+        $this->cardDavBackend->expects($this->once())->method('createAddressBook')->with(
129
+            'principals/users/newUser',
130
+            'contacts', ['{DAV:}displayname' => 'Contacts']);
131
+
132
+        $this->userEventsListener->firstLogin($user);
133
+    }
134
+
135
+    public function testDeleteUser(): void {
136
+        $user = $this->createMock(IUser::class);
137
+        $user->expects($this->once())->method('getUID')->willReturn('newUser');
138
+
139
+        $this->syncService->expects($this->once())
140
+            ->method('deleteUser');
141
+
142
+        $this->calDavBackend->expects($this->once())->method('getUsersOwnCalendars')->willReturn([
143
+            ['id' => 'personal']
144
+        ]);
145
+        $this->calDavBackend->expects($this->once())->method('getSubscriptionsForUser')->willReturn([
146
+            ['id' => 'some-subscription']
147
+        ]);
148
+        $this->calDavBackend->expects($this->once())->method('deleteCalendar')->with('personal');
149
+        $this->calDavBackend->expects($this->once())->method('deleteSubscription')->with('some-subscription');
150
+        $this->calDavBackend->expects($this->once())->method('deleteAllSharesByUser');
151
+        $this->cardDavBackend->expects($this->once())->method('deleteAllSharesByUser');
152
+
153
+        $this->cardDavBackend->expects($this->once())->method('getUsersOwnAddressBooks')->willReturn([
154
+            ['id' => 'personal']
155
+        ]);
156
+        $this->cardDavBackend->expects($this->once())->method('deleteAddressBook');
157
+
158
+        $this->userEventsListener->preDeleteUser($user);
159
+        $this->userEventsListener->postDeleteUser('newUser');
160
+    }
161
+
162
+    public function testDeleteUserAutomationEvent(): void {
163
+        $user = $this->createMock(IUser::class);
164
+        $user->expects($this->once())->method('getUID')->willReturn('newUser');
165
+
166
+        $this->syncService->expects($this->once())
167
+            ->method('deleteUser');
168
+
169
+        $this->calDavBackend->expects($this->once())->method('getUsersOwnCalendars')->willReturn([
170
+            ['id' => []]
171
+        ]);
172
+        $this->calDavBackend->expects($this->once())->method('getSubscriptionsForUser')->willReturn([
173
+            ['id' => []]
174
+        ]);
175
+        $this->cardDavBackend->expects($this->once())->method('getUsersOwnAddressBooks')->willReturn([
176
+            ['id' => []]
177
+        ]);
178
+
179
+        $this->jobList->expects(self::once())->method('remove')->with(UserStatusAutomation::class, ['userId' => 'newUser']);
180
+
181
+        $this->userEventsListener->preDeleteUser($user);
182
+        $this->userEventsListener->postDeleteUser('newUser');
183
+    }
184 184
 }
Please login to merge, or discard this patch.