Completed
Push — master ( 453450...6f0537 )
by
unknown
37:16
created
apps/dav/lib/CalDAV/Reminder/Backend.php 1 patch
Indentation   +176 added lines, -176 removed lines patch added patch discarded remove patch
@@ -18,180 +18,180 @@
 block discarded – undo
18 18
  */
19 19
 class Backend {
20 20
 
21
-	/**
22
-	 * Backend constructor.
23
-	 *
24
-	 * @param IDBConnection $db
25
-	 * @param ITimeFactory $timeFactory
26
-	 */
27
-	public function __construct(
28
-		protected IDBConnection $db,
29
-		protected ITimeFactory $timeFactory,
30
-	) {
31
-	}
32
-
33
-	/**
34
-	 * Get all reminders with a notification date before now
35
-	 *
36
-	 * @return array
37
-	 * @throws \Exception
38
-	 */
39
-	public function getRemindersToProcess():array {
40
-		$query = $this->db->getQueryBuilder();
41
-		$query->select(['cr.id', 'cr.calendar_id','cr.object_id','cr.is_recurring','cr.uid','cr.recurrence_id','cr.is_recurrence_exception','cr.event_hash','cr.alarm_hash','cr.type','cr.is_relative','cr.notification_date','cr.is_repeat_based','co.calendardata', 'c.displayname', 'c.principaluri'])
42
-			->from('calendar_reminders', 'cr')
43
-			->where($query->expr()->lte('cr.notification_date', $query->createNamedParameter($this->timeFactory->getTime())))
44
-			->join('cr', 'calendarobjects', 'co', $query->expr()->eq('cr.object_id', 'co.id'))
45
-			->join('cr', 'calendars', 'c', $query->expr()->eq('cr.calendar_id', 'c.id'))
46
-			->groupBy('cr.event_hash', 'cr.notification_date', 'cr.type', 'cr.id', 'cr.calendar_id', 'cr.object_id', 'cr.is_recurring', 'cr.uid', 'cr.recurrence_id', 'cr.is_recurrence_exception', 'cr.alarm_hash', 'cr.is_relative', 'cr.is_repeat_based', 'co.calendardata', 'c.displayname', 'c.principaluri');
47
-		$stmt = $query->executeQuery();
48
-
49
-		return array_map(
50
-			[$this, 'fixRowTyping'],
51
-			$stmt->fetchAllAssociative()
52
-		);
53
-	}
54
-
55
-	/**
56
-	 * Get all scheduled reminders for an event
57
-	 *
58
-	 * @param int $objectId
59
-	 * @return array
60
-	 */
61
-	public function getAllScheduledRemindersForEvent(int $objectId):array {
62
-		$query = $this->db->getQueryBuilder();
63
-		$query->select('*')
64
-			->from('calendar_reminders')
65
-			->where($query->expr()->eq('object_id', $query->createNamedParameter($objectId)));
66
-		$stmt = $query->executeQuery();
67
-
68
-		return array_map(
69
-			[$this, 'fixRowTyping'],
70
-			$stmt->fetchAllAssociative()
71
-		);
72
-	}
73
-
74
-	/**
75
-	 * Insert a new reminder into the database
76
-	 *
77
-	 * @param int $calendarId
78
-	 * @param int $objectId
79
-	 * @param string $uid
80
-	 * @param bool $isRecurring
81
-	 * @param int $recurrenceId
82
-	 * @param bool $isRecurrenceException
83
-	 * @param string $eventHash
84
-	 * @param string $alarmHash
85
-	 * @param string $type
86
-	 * @param bool $isRelative
87
-	 * @param int $notificationDate
88
-	 * @param bool $isRepeatBased
89
-	 * @return int The insert id
90
-	 */
91
-	public function insertReminder(int $calendarId,
92
-		int $objectId,
93
-		string $uid,
94
-		bool $isRecurring,
95
-		int $recurrenceId,
96
-		bool $isRecurrenceException,
97
-		string $eventHash,
98
-		string $alarmHash,
99
-		string $type,
100
-		bool $isRelative,
101
-		int $notificationDate,
102
-		bool $isRepeatBased):int {
103
-		$query = $this->db->getQueryBuilder();
104
-		$query->insert('calendar_reminders')
105
-			->values([
106
-				'calendar_id' => $query->createNamedParameter($calendarId),
107
-				'object_id' => $query->createNamedParameter($objectId),
108
-				'uid' => $query->createNamedParameter($uid),
109
-				'is_recurring' => $query->createNamedParameter($isRecurring ? 1 : 0),
110
-				'recurrence_id' => $query->createNamedParameter($recurrenceId),
111
-				'is_recurrence_exception' => $query->createNamedParameter($isRecurrenceException ? 1 : 0),
112
-				'event_hash' => $query->createNamedParameter($eventHash),
113
-				'alarm_hash' => $query->createNamedParameter($alarmHash),
114
-				'type' => $query->createNamedParameter($type),
115
-				'is_relative' => $query->createNamedParameter($isRelative ? 1 : 0),
116
-				'notification_date' => $query->createNamedParameter($notificationDate),
117
-				'is_repeat_based' => $query->createNamedParameter($isRepeatBased ? 1 : 0),
118
-			])
119
-			->executeStatement();
120
-
121
-		return $query->getLastInsertId();
122
-	}
123
-
124
-	/**
125
-	 * Sets a new notificationDate on an existing reminder
126
-	 *
127
-	 * @param int $reminderId
128
-	 * @param int $newNotificationDate
129
-	 */
130
-	public function updateReminder(int $reminderId,
131
-		int $newNotificationDate):void {
132
-		$query = $this->db->getQueryBuilder();
133
-		$query->update('calendar_reminders')
134
-			->set('notification_date', $query->createNamedParameter($newNotificationDate))
135
-			->where($query->expr()->eq('id', $query->createNamedParameter($reminderId)))
136
-			->executeStatement();
137
-	}
138
-
139
-	/**
140
-	 * Remove a reminder by it's id
141
-	 *
142
-	 * @param integer $reminderId
143
-	 * @return void
144
-	 */
145
-	public function removeReminder(int $reminderId):void {
146
-		$query = $this->db->getQueryBuilder();
147
-
148
-		$query->delete('calendar_reminders')
149
-			->where($query->expr()->eq('id', $query->createNamedParameter($reminderId)))
150
-			->executeStatement();
151
-	}
152
-
153
-	/**
154
-	 * Cleans reminders in database
155
-	 *
156
-	 * @param int $objectId
157
-	 */
158
-	public function cleanRemindersForEvent(int $objectId):void {
159
-		$query = $this->db->getQueryBuilder();
160
-
161
-		$query->delete('calendar_reminders')
162
-			->where($query->expr()->eq('object_id', $query->createNamedParameter($objectId)))
163
-			->executeStatement();
164
-	}
165
-
166
-	/**
167
-	 * Remove all reminders for a calendar
168
-	 *
169
-	 * @param int $calendarId
170
-	 * @return void
171
-	 */
172
-	public function cleanRemindersForCalendar(int $calendarId):void {
173
-		$query = $this->db->getQueryBuilder();
174
-
175
-		$query->delete('calendar_reminders')
176
-			->where($query->expr()->eq('calendar_id', $query->createNamedParameter($calendarId)))
177
-			->executeStatement();
178
-	}
179
-
180
-	/**
181
-	 * @param array $row
182
-	 * @return array
183
-	 */
184
-	private function fixRowTyping(array $row): array {
185
-		$row['id'] = (int)$row['id'];
186
-		$row['calendar_id'] = (int)$row['calendar_id'];
187
-		$row['object_id'] = (int)$row['object_id'];
188
-		$row['is_recurring'] = (bool)$row['is_recurring'];
189
-		$row['recurrence_id'] = (int)$row['recurrence_id'];
190
-		$row['is_recurrence_exception'] = (bool)$row['is_recurrence_exception'];
191
-		$row['is_relative'] = (bool)$row['is_relative'];
192
-		$row['notification_date'] = (int)$row['notification_date'];
193
-		$row['is_repeat_based'] = (bool)$row['is_repeat_based'];
194
-
195
-		return $row;
196
-	}
21
+    /**
22
+     * Backend constructor.
23
+     *
24
+     * @param IDBConnection $db
25
+     * @param ITimeFactory $timeFactory
26
+     */
27
+    public function __construct(
28
+        protected IDBConnection $db,
29
+        protected ITimeFactory $timeFactory,
30
+    ) {
31
+    }
32
+
33
+    /**
34
+     * Get all reminders with a notification date before now
35
+     *
36
+     * @return array
37
+     * @throws \Exception
38
+     */
39
+    public function getRemindersToProcess():array {
40
+        $query = $this->db->getQueryBuilder();
41
+        $query->select(['cr.id', 'cr.calendar_id','cr.object_id','cr.is_recurring','cr.uid','cr.recurrence_id','cr.is_recurrence_exception','cr.event_hash','cr.alarm_hash','cr.type','cr.is_relative','cr.notification_date','cr.is_repeat_based','co.calendardata', 'c.displayname', 'c.principaluri'])
42
+            ->from('calendar_reminders', 'cr')
43
+            ->where($query->expr()->lte('cr.notification_date', $query->createNamedParameter($this->timeFactory->getTime())))
44
+            ->join('cr', 'calendarobjects', 'co', $query->expr()->eq('cr.object_id', 'co.id'))
45
+            ->join('cr', 'calendars', 'c', $query->expr()->eq('cr.calendar_id', 'c.id'))
46
+            ->groupBy('cr.event_hash', 'cr.notification_date', 'cr.type', 'cr.id', 'cr.calendar_id', 'cr.object_id', 'cr.is_recurring', 'cr.uid', 'cr.recurrence_id', 'cr.is_recurrence_exception', 'cr.alarm_hash', 'cr.is_relative', 'cr.is_repeat_based', 'co.calendardata', 'c.displayname', 'c.principaluri');
47
+        $stmt = $query->executeQuery();
48
+
49
+        return array_map(
50
+            [$this, 'fixRowTyping'],
51
+            $stmt->fetchAllAssociative()
52
+        );
53
+    }
54
+
55
+    /**
56
+     * Get all scheduled reminders for an event
57
+     *
58
+     * @param int $objectId
59
+     * @return array
60
+     */
61
+    public function getAllScheduledRemindersForEvent(int $objectId):array {
62
+        $query = $this->db->getQueryBuilder();
63
+        $query->select('*')
64
+            ->from('calendar_reminders')
65
+            ->where($query->expr()->eq('object_id', $query->createNamedParameter($objectId)));
66
+        $stmt = $query->executeQuery();
67
+
68
+        return array_map(
69
+            [$this, 'fixRowTyping'],
70
+            $stmt->fetchAllAssociative()
71
+        );
72
+    }
73
+
74
+    /**
75
+     * Insert a new reminder into the database
76
+     *
77
+     * @param int $calendarId
78
+     * @param int $objectId
79
+     * @param string $uid
80
+     * @param bool $isRecurring
81
+     * @param int $recurrenceId
82
+     * @param bool $isRecurrenceException
83
+     * @param string $eventHash
84
+     * @param string $alarmHash
85
+     * @param string $type
86
+     * @param bool $isRelative
87
+     * @param int $notificationDate
88
+     * @param bool $isRepeatBased
89
+     * @return int The insert id
90
+     */
91
+    public function insertReminder(int $calendarId,
92
+        int $objectId,
93
+        string $uid,
94
+        bool $isRecurring,
95
+        int $recurrenceId,
96
+        bool $isRecurrenceException,
97
+        string $eventHash,
98
+        string $alarmHash,
99
+        string $type,
100
+        bool $isRelative,
101
+        int $notificationDate,
102
+        bool $isRepeatBased):int {
103
+        $query = $this->db->getQueryBuilder();
104
+        $query->insert('calendar_reminders')
105
+            ->values([
106
+                'calendar_id' => $query->createNamedParameter($calendarId),
107
+                'object_id' => $query->createNamedParameter($objectId),
108
+                'uid' => $query->createNamedParameter($uid),
109
+                'is_recurring' => $query->createNamedParameter($isRecurring ? 1 : 0),
110
+                'recurrence_id' => $query->createNamedParameter($recurrenceId),
111
+                'is_recurrence_exception' => $query->createNamedParameter($isRecurrenceException ? 1 : 0),
112
+                'event_hash' => $query->createNamedParameter($eventHash),
113
+                'alarm_hash' => $query->createNamedParameter($alarmHash),
114
+                'type' => $query->createNamedParameter($type),
115
+                'is_relative' => $query->createNamedParameter($isRelative ? 1 : 0),
116
+                'notification_date' => $query->createNamedParameter($notificationDate),
117
+                'is_repeat_based' => $query->createNamedParameter($isRepeatBased ? 1 : 0),
118
+            ])
119
+            ->executeStatement();
120
+
121
+        return $query->getLastInsertId();
122
+    }
123
+
124
+    /**
125
+     * Sets a new notificationDate on an existing reminder
126
+     *
127
+     * @param int $reminderId
128
+     * @param int $newNotificationDate
129
+     */
130
+    public function updateReminder(int $reminderId,
131
+        int $newNotificationDate):void {
132
+        $query = $this->db->getQueryBuilder();
133
+        $query->update('calendar_reminders')
134
+            ->set('notification_date', $query->createNamedParameter($newNotificationDate))
135
+            ->where($query->expr()->eq('id', $query->createNamedParameter($reminderId)))
136
+            ->executeStatement();
137
+    }
138
+
139
+    /**
140
+     * Remove a reminder by it's id
141
+     *
142
+     * @param integer $reminderId
143
+     * @return void
144
+     */
145
+    public function removeReminder(int $reminderId):void {
146
+        $query = $this->db->getQueryBuilder();
147
+
148
+        $query->delete('calendar_reminders')
149
+            ->where($query->expr()->eq('id', $query->createNamedParameter($reminderId)))
150
+            ->executeStatement();
151
+    }
152
+
153
+    /**
154
+     * Cleans reminders in database
155
+     *
156
+     * @param int $objectId
157
+     */
158
+    public function cleanRemindersForEvent(int $objectId):void {
159
+        $query = $this->db->getQueryBuilder();
160
+
161
+        $query->delete('calendar_reminders')
162
+            ->where($query->expr()->eq('object_id', $query->createNamedParameter($objectId)))
163
+            ->executeStatement();
164
+    }
165
+
166
+    /**
167
+     * Remove all reminders for a calendar
168
+     *
169
+     * @param int $calendarId
170
+     * @return void
171
+     */
172
+    public function cleanRemindersForCalendar(int $calendarId):void {
173
+        $query = $this->db->getQueryBuilder();
174
+
175
+        $query->delete('calendar_reminders')
176
+            ->where($query->expr()->eq('calendar_id', $query->createNamedParameter($calendarId)))
177
+            ->executeStatement();
178
+    }
179
+
180
+    /**
181
+     * @param array $row
182
+     * @return array
183
+     */
184
+    private function fixRowTyping(array $row): array {
185
+        $row['id'] = (int)$row['id'];
186
+        $row['calendar_id'] = (int)$row['calendar_id'];
187
+        $row['object_id'] = (int)$row['object_id'];
188
+        $row['is_recurring'] = (bool)$row['is_recurring'];
189
+        $row['recurrence_id'] = (int)$row['recurrence_id'];
190
+        $row['is_recurrence_exception'] = (bool)$row['is_recurrence_exception'];
191
+        $row['is_relative'] = (bool)$row['is_relative'];
192
+        $row['notification_date'] = (int)$row['notification_date'];
193
+        $row['is_repeat_based'] = (bool)$row['is_repeat_based'];
194
+
195
+        return $row;
196
+    }
197 197
 }
Please login to merge, or discard this patch.
apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php 1 patch
Indentation   +465 added lines, -465 removed lines patch added patch discarded remove patch
@@ -26,469 +26,469 @@
 block discarded – undo
26 26
 
27 27
 abstract class AbstractPrincipalBackend implements BackendInterface {
28 28
 
29
-	/** @var string */
30
-	private $dbTableName;
31
-
32
-	/** @var string */
33
-	private $dbMetaDataTableName;
34
-
35
-	/** @var string */
36
-	private $dbForeignKeyName;
37
-
38
-	public function __construct(
39
-		private IDBConnection $db,
40
-		private IUserSession $userSession,
41
-		private IGroupManager $groupManager,
42
-		private LoggerInterface $logger,
43
-		private ProxyMapper $proxyMapper,
44
-		private string $principalPrefix,
45
-		string $dbPrefix,
46
-		private string $cuType,
47
-	) {
48
-		$this->dbTableName = 'calendar_' . $dbPrefix . 's';
49
-		$this->dbMetaDataTableName = $this->dbTableName . '_md';
50
-		$this->dbForeignKeyName = $dbPrefix . '_id';
51
-	}
52
-
53
-	use PrincipalProxyTrait;
54
-
55
-	/**
56
-	 * Returns a list of principals based on a prefix.
57
-	 *
58
-	 * This prefix will often contain something like 'principals'. You are only
59
-	 * expected to return principals that are in this base path.
60
-	 *
61
-	 * You are expected to return at least a 'uri' for every user, you can
62
-	 * return any additional properties if you wish so. Common properties are:
63
-	 *   {DAV:}displayname
64
-	 *
65
-	 * @param string $prefixPath
66
-	 * @return string[]
67
-	 */
68
-	public function getPrincipalsByPrefix($prefixPath): array {
69
-		$principals = [];
70
-
71
-		if ($prefixPath === $this->principalPrefix) {
72
-			$query = $this->db->getQueryBuilder();
73
-			$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
74
-				->from($this->dbTableName);
75
-			$stmt = $query->executeQuery();
76
-
77
-			$metaDataQuery = $this->db->getQueryBuilder();
78
-			$metaDataQuery->select([$this->dbForeignKeyName, 'key', 'value'])
79
-				->from($this->dbMetaDataTableName);
80
-			$metaDataStmt = $metaDataQuery->executeQuery();
81
-			$metaDataRows = $metaDataStmt->fetchAllAssociative();
82
-
83
-			$metaDataById = [];
84
-			foreach ($metaDataRows as $metaDataRow) {
85
-				if (!isset($metaDataById[$metaDataRow[$this->dbForeignKeyName]])) {
86
-					$metaDataById[$metaDataRow[$this->dbForeignKeyName]] = [];
87
-				}
88
-
89
-				$metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']]
90
-					= $metaDataRow['value'];
91
-			}
92
-
93
-			while ($row = $stmt->fetchAssociative()) {
94
-				$id = $row['id'];
95
-
96
-				if (isset($metaDataById[$id])) {
97
-					$principals[] = $this->rowToPrincipal($row, $metaDataById[$id]);
98
-				} else {
99
-					$principals[] = $this->rowToPrincipal($row);
100
-				}
101
-			}
102
-
103
-			$stmt->closeCursor();
104
-		}
105
-
106
-		return $principals;
107
-	}
108
-
109
-	/**
110
-	 * Returns a specific principal, specified by its path.
111
-	 * The returned structure should be the exact same as from
112
-	 * getPrincipalsByPrefix.
113
-	 *
114
-	 * @param string $prefixPath
115
-	 *
116
-	 * @return array
117
-	 */
118
-	public function getPrincipalByPath($path) {
119
-		if (!str_starts_with($path, $this->principalPrefix)) {
120
-			return null;
121
-		}
122
-		[, $name] = \Sabre\Uri\split($path);
123
-
124
-		[$backendId, $resourceId] = explode('-', $name, 2);
125
-
126
-		$query = $this->db->getQueryBuilder();
127
-		$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
128
-			->from($this->dbTableName)
129
-			->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
130
-			->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
131
-		$stmt = $query->executeQuery();
132
-		$row = $stmt->fetchAssociative();
133
-
134
-		if (!$row) {
135
-			return null;
136
-		}
137
-
138
-		$metaDataQuery = $this->db->getQueryBuilder();
139
-		$metaDataQuery->select(['key', 'value'])
140
-			->from($this->dbMetaDataTableName)
141
-			->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
142
-		$metaDataStmt = $metaDataQuery->executeQuery();
143
-		$metaDataRows = $metaDataStmt->fetchAllAssociative();
144
-		$metadata = [];
145
-
146
-		foreach ($metaDataRows as $metaDataRow) {
147
-			$metadata[$metaDataRow['key']] = $metaDataRow['value'];
148
-		}
149
-
150
-		return $this->rowToPrincipal($row, $metadata);
151
-	}
152
-
153
-	/**
154
-	 * @param int $id
155
-	 * @return string[]|null
156
-	 */
157
-	public function getPrincipalById($id): ?array {
158
-		$query = $this->db->getQueryBuilder();
159
-		$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
160
-			->from($this->dbTableName)
161
-			->where($query->expr()->eq('id', $query->createNamedParameter($id)));
162
-		$stmt = $query->executeQuery();
163
-		$row = $stmt->fetchAssociative();
164
-
165
-		if (!$row) {
166
-			return null;
167
-		}
168
-
169
-		$metaDataQuery = $this->db->getQueryBuilder();
170
-		$metaDataQuery->select(['key', 'value'])
171
-			->from($this->dbMetaDataTableName)
172
-			->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
173
-		$metaDataStmt = $metaDataQuery->executeQuery();
174
-		$metaDataRows = $metaDataStmt->fetchAllAssociative();
175
-		$metadata = [];
176
-
177
-		foreach ($metaDataRows as $metaDataRow) {
178
-			$metadata[$metaDataRow['key']] = $metaDataRow['value'];
179
-		}
180
-
181
-		return $this->rowToPrincipal($row, $metadata);
182
-	}
183
-
184
-	/**
185
-	 * @param string $path
186
-	 * @param PropPatch $propPatch
187
-	 * @return int
188
-	 */
189
-	public function updatePrincipal($path, PropPatch $propPatch): int {
190
-		return 0;
191
-	}
192
-
193
-	/**
194
-	 * @param string $prefixPath
195
-	 * @param string $test
196
-	 *
197
-	 * @return array
198
-	 */
199
-	public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') {
200
-		$results = [];
201
-		if (\count($searchProperties) === 0) {
202
-			return [];
203
-		}
204
-		if ($prefixPath !== $this->principalPrefix) {
205
-			return [];
206
-		}
207
-
208
-		$user = $this->userSession->getUser();
209
-		if (!$user) {
210
-			return [];
211
-		}
212
-		$usersGroups = $this->groupManager->getUserGroupIds($user);
213
-
214
-		foreach ($searchProperties as $prop => $value) {
215
-			switch ($prop) {
216
-				case '{http://sabredav.org/ns}email-address':
217
-					$query = $this->db->getQueryBuilder();
218
-					$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
219
-						->from($this->dbTableName)
220
-						->where($query->expr()->iLike('email', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
221
-
222
-					$stmt = $query->executeQuery();
223
-					$principals = [];
224
-					while ($row = $stmt->fetchAssociative()) {
225
-						if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
226
-							continue;
227
-						}
228
-						$principals[] = $this->rowToPrincipal($row)['uri'];
229
-					}
230
-					$results[] = $principals;
231
-
232
-					$stmt->closeCursor();
233
-					break;
234
-
235
-				case '{DAV:}displayname':
236
-					$query = $this->db->getQueryBuilder();
237
-					$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
238
-						->from($this->dbTableName)
239
-						->where($query->expr()->iLike('displayname', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
240
-
241
-					$stmt = $query->executeQuery();
242
-					$principals = [];
243
-					while ($row = $stmt->fetchAssociative()) {
244
-						if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
245
-							continue;
246
-						}
247
-						$principals[] = $this->rowToPrincipal($row)['uri'];
248
-					}
249
-					$results[] = $principals;
250
-
251
-					$stmt->closeCursor();
252
-					break;
253
-
254
-				case '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set':
255
-					// If you add support for more search properties that qualify as a user-address,
256
-					// please also add them to the array below
257
-					$results[] = $this->searchPrincipals($this->principalPrefix, [
258
-						'{http://sabredav.org/ns}email-address' => $value,
259
-					], 'anyof');
260
-					break;
261
-
262
-				case IRoomMetadata::FEATURES:
263
-					$results[] = $this->searchPrincipalsByRoomFeature($prop, $value);
264
-					break;
265
-
266
-				case IRoomMetadata::CAPACITY:
267
-				case IResourceMetadata::VEHICLE_SEATING_CAPACITY:
268
-					$results[] = $this->searchPrincipalsByCapacity($prop, $value);
269
-					break;
270
-
271
-				default:
272
-					$results[] = $this->searchPrincipalsByMetadataKey($prop, $value, $usersGroups);
273
-					break;
274
-			}
275
-		}
276
-
277
-		// results is an array of arrays, so this is not the first search result
278
-		// but the results of the first searchProperty
279
-		if (count($results) === 1) {
280
-			return $results[0];
281
-		}
282
-
283
-		switch ($test) {
284
-			case 'anyof':
285
-				return array_values(array_unique(array_merge(...$results)));
286
-
287
-			case 'allof':
288
-			default:
289
-				return array_values(array_intersect(...$results));
290
-		}
291
-	}
292
-
293
-	/**
294
-	 * @param string $key
295
-	 * @return IQueryBuilder
296
-	 */
297
-	private function getMetadataQuery(string $key): IQueryBuilder {
298
-		$query = $this->db->getQueryBuilder();
299
-		$query->select([$this->dbForeignKeyName])
300
-			->from($this->dbMetaDataTableName)
301
-			->where($query->expr()->eq('key', $query->createNamedParameter($key)));
302
-		return $query;
303
-	}
304
-
305
-	/**
306
-	 * Searches principals based on their metadata keys.
307
-	 * This allows to search for all principals with a specific key.
308
-	 * e.g.:
309
-	 * '{http://nextcloud.com/ns}room-building-address' => 'ABC Street 123, ...'
310
-	 *
311
-	 * @param string $key
312
-	 * @param string $value
313
-	 * @param string[] $usersGroups
314
-	 * @return string[]
315
-	 */
316
-	private function searchPrincipalsByMetadataKey(string $key, string $value, array $usersGroups = []): array {
317
-		$query = $this->getMetadataQuery($key);
318
-		$query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
319
-		return $this->getRows($query, $usersGroups);
320
-	}
321
-
322
-	/**
323
-	 * Searches principals based on room features
324
-	 * e.g.:
325
-	 * '{http://nextcloud.com/ns}room-features' => 'TV,PROJECTOR'
326
-	 *
327
-	 * @param string $key
328
-	 * @param string $value
329
-	 * @param string[] $usersGroups
330
-	 * @return string[]
331
-	 */
332
-	private function searchPrincipalsByRoomFeature(string $key, string $value, array $usersGroups = []): array {
333
-		$query = $this->getMetadataQuery($key);
334
-		foreach (explode(',', $value) as $v) {
335
-			$query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($v) . '%')));
336
-		}
337
-		return $this->getRows($query, $usersGroups);
338
-	}
339
-
340
-	/**
341
-	 * Searches principals based on room seating capacity or vehicle capacity
342
-	 * e.g.:
343
-	 * '{http://nextcloud.com/ns}room-seating-capacity' => '100'
344
-	 *
345
-	 * @param string $key
346
-	 * @param string $value
347
-	 * @param string[] $usersGroups
348
-	 * @return string[]
349
-	 */
350
-	private function searchPrincipalsByCapacity(string $key, string $value, array $usersGroups = []): array {
351
-		$query = $this->getMetadataQuery($key);
352
-		$query->andWhere($query->expr()->gte('value', $query->createNamedParameter($value)));
353
-		return $this->getRows($query, $usersGroups);
354
-	}
355
-
356
-	/**
357
-	 * @param IQueryBuilder $query
358
-	 * @param string[] $usersGroups
359
-	 * @return string[]
360
-	 */
361
-	private function getRows(IQueryBuilder $query, array $usersGroups): array {
362
-		try {
363
-			$stmt = $query->executeQuery();
364
-		} catch (Exception $e) {
365
-			$this->logger->error('Could not search resources: ' . $e->getMessage(), ['exception' => $e]);
366
-		}
367
-
368
-		$rows = [];
369
-		while ($row = $stmt->fetchAssociative()) {
370
-			$principalRow = $this->getPrincipalById($row[$this->dbForeignKeyName]);
371
-			if (!$principalRow) {
372
-				continue;
373
-			}
374
-
375
-			$rows[] = $principalRow;
376
-		}
377
-
378
-		$stmt->closeCursor();
379
-
380
-		$filteredRows = array_filter($rows, function ($row) use ($usersGroups) {
381
-			return $this->isAllowedToAccessResource($row, $usersGroups);
382
-		});
383
-
384
-		return array_map(static function ($row): string {
385
-			return $row['uri'];
386
-		}, $filteredRows);
387
-	}
388
-
389
-	/**
390
-	 * @param string $uri
391
-	 * @param string $principalPrefix
392
-	 * @return null|string
393
-	 * @throws Exception
394
-	 */
395
-	public function findByUri($uri, $principalPrefix): ?string {
396
-		$user = $this->userSession->getUser();
397
-		if (!$user) {
398
-			return null;
399
-		}
400
-		$usersGroups = $this->groupManager->getUserGroupIds($user);
401
-
402
-		if (str_starts_with($uri, 'mailto:')) {
403
-			$email = substr($uri, 7);
404
-			$query = $this->db->getQueryBuilder();
405
-			$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
406
-				->from($this->dbTableName)
407
-				->where($query->expr()->eq('email', $query->createNamedParameter($email)));
408
-
409
-			$stmt = $query->executeQuery();
410
-			$row = $stmt->fetchAssociative();
411
-
412
-			if (!$row) {
413
-				return null;
414
-			}
415
-			if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
416
-				return null;
417
-			}
418
-
419
-			return $this->rowToPrincipal($row)['uri'];
420
-		}
421
-
422
-		if (str_starts_with($uri, 'principal:')) {
423
-			$path = substr($uri, 10);
424
-			if (!str_starts_with($path, $this->principalPrefix)) {
425
-				return null;
426
-			}
427
-
428
-			[, $name] = \Sabre\Uri\split($path);
429
-			[$backendId, $resourceId] = explode('-', $name, 2);
430
-
431
-			$query = $this->db->getQueryBuilder();
432
-			$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
433
-				->from($this->dbTableName)
434
-				->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
435
-				->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
436
-			$stmt = $query->executeQuery();
437
-			$row = $stmt->fetchAssociative();
438
-
439
-			if (!$row) {
440
-				return null;
441
-			}
442
-			if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
443
-				return null;
444
-			}
445
-
446
-			return $this->rowToPrincipal($row)['uri'];
447
-		}
448
-
449
-		return null;
450
-	}
451
-
452
-	/**
453
-	 * convert database row to principal
454
-	 *
455
-	 * @param string[] $row
456
-	 * @param string[] $metadata
457
-	 * @return string[]
458
-	 */
459
-	private function rowToPrincipal(array $row, array $metadata = []): array {
460
-		return array_merge([
461
-			'uri' => $this->principalPrefix . '/' . $row['backend_id'] . '-' . $row['resource_id'],
462
-			'{DAV:}displayname' => $row['displayname'],
463
-			'{http://sabredav.org/ns}email-address' => $row['email'],
464
-			'{urn:ietf:params:xml:ns:caldav}calendar-user-type' => $this->cuType,
465
-		], $metadata);
466
-	}
467
-
468
-	/**
469
-	 * @param array $row
470
-	 * @param array $userGroups
471
-	 * @return bool
472
-	 */
473
-	private function isAllowedToAccessResource(array $row, array $userGroups): bool {
474
-		if (!isset($row['group_restrictions'])
475
-			|| $row['group_restrictions'] === null
476
-			|| $row['group_restrictions'] === '') {
477
-			return true;
478
-		}
479
-
480
-		// group restrictions contains something, but not parsable, deny access and log warning
481
-		$json = json_decode($row['group_restrictions'], null, 512, JSON_THROW_ON_ERROR);
482
-		if (!\is_array($json)) {
483
-			$this->logger->info('group_restrictions field could not be parsed for ' . $this->dbTableName . '::' . $row['id'] . ', denying access to resource');
484
-			return false;
485
-		}
486
-
487
-		// empty array => no group restrictions
488
-		if (empty($json)) {
489
-			return true;
490
-		}
491
-
492
-		return !empty(array_intersect($json, $userGroups));
493
-	}
29
+    /** @var string */
30
+    private $dbTableName;
31
+
32
+    /** @var string */
33
+    private $dbMetaDataTableName;
34
+
35
+    /** @var string */
36
+    private $dbForeignKeyName;
37
+
38
+    public function __construct(
39
+        private IDBConnection $db,
40
+        private IUserSession $userSession,
41
+        private IGroupManager $groupManager,
42
+        private LoggerInterface $logger,
43
+        private ProxyMapper $proxyMapper,
44
+        private string $principalPrefix,
45
+        string $dbPrefix,
46
+        private string $cuType,
47
+    ) {
48
+        $this->dbTableName = 'calendar_' . $dbPrefix . 's';
49
+        $this->dbMetaDataTableName = $this->dbTableName . '_md';
50
+        $this->dbForeignKeyName = $dbPrefix . '_id';
51
+    }
52
+
53
+    use PrincipalProxyTrait;
54
+
55
+    /**
56
+     * Returns a list of principals based on a prefix.
57
+     *
58
+     * This prefix will often contain something like 'principals'. You are only
59
+     * expected to return principals that are in this base path.
60
+     *
61
+     * You are expected to return at least a 'uri' for every user, you can
62
+     * return any additional properties if you wish so. Common properties are:
63
+     *   {DAV:}displayname
64
+     *
65
+     * @param string $prefixPath
66
+     * @return string[]
67
+     */
68
+    public function getPrincipalsByPrefix($prefixPath): array {
69
+        $principals = [];
70
+
71
+        if ($prefixPath === $this->principalPrefix) {
72
+            $query = $this->db->getQueryBuilder();
73
+            $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
74
+                ->from($this->dbTableName);
75
+            $stmt = $query->executeQuery();
76
+
77
+            $metaDataQuery = $this->db->getQueryBuilder();
78
+            $metaDataQuery->select([$this->dbForeignKeyName, 'key', 'value'])
79
+                ->from($this->dbMetaDataTableName);
80
+            $metaDataStmt = $metaDataQuery->executeQuery();
81
+            $metaDataRows = $metaDataStmt->fetchAllAssociative();
82
+
83
+            $metaDataById = [];
84
+            foreach ($metaDataRows as $metaDataRow) {
85
+                if (!isset($metaDataById[$metaDataRow[$this->dbForeignKeyName]])) {
86
+                    $metaDataById[$metaDataRow[$this->dbForeignKeyName]] = [];
87
+                }
88
+
89
+                $metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']]
90
+                    = $metaDataRow['value'];
91
+            }
92
+
93
+            while ($row = $stmt->fetchAssociative()) {
94
+                $id = $row['id'];
95
+
96
+                if (isset($metaDataById[$id])) {
97
+                    $principals[] = $this->rowToPrincipal($row, $metaDataById[$id]);
98
+                } else {
99
+                    $principals[] = $this->rowToPrincipal($row);
100
+                }
101
+            }
102
+
103
+            $stmt->closeCursor();
104
+        }
105
+
106
+        return $principals;
107
+    }
108
+
109
+    /**
110
+     * Returns a specific principal, specified by its path.
111
+     * The returned structure should be the exact same as from
112
+     * getPrincipalsByPrefix.
113
+     *
114
+     * @param string $prefixPath
115
+     *
116
+     * @return array
117
+     */
118
+    public function getPrincipalByPath($path) {
119
+        if (!str_starts_with($path, $this->principalPrefix)) {
120
+            return null;
121
+        }
122
+        [, $name] = \Sabre\Uri\split($path);
123
+
124
+        [$backendId, $resourceId] = explode('-', $name, 2);
125
+
126
+        $query = $this->db->getQueryBuilder();
127
+        $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
128
+            ->from($this->dbTableName)
129
+            ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
130
+            ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
131
+        $stmt = $query->executeQuery();
132
+        $row = $stmt->fetchAssociative();
133
+
134
+        if (!$row) {
135
+            return null;
136
+        }
137
+
138
+        $metaDataQuery = $this->db->getQueryBuilder();
139
+        $metaDataQuery->select(['key', 'value'])
140
+            ->from($this->dbMetaDataTableName)
141
+            ->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
142
+        $metaDataStmt = $metaDataQuery->executeQuery();
143
+        $metaDataRows = $metaDataStmt->fetchAllAssociative();
144
+        $metadata = [];
145
+
146
+        foreach ($metaDataRows as $metaDataRow) {
147
+            $metadata[$metaDataRow['key']] = $metaDataRow['value'];
148
+        }
149
+
150
+        return $this->rowToPrincipal($row, $metadata);
151
+    }
152
+
153
+    /**
154
+     * @param int $id
155
+     * @return string[]|null
156
+     */
157
+    public function getPrincipalById($id): ?array {
158
+        $query = $this->db->getQueryBuilder();
159
+        $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
160
+            ->from($this->dbTableName)
161
+            ->where($query->expr()->eq('id', $query->createNamedParameter($id)));
162
+        $stmt = $query->executeQuery();
163
+        $row = $stmt->fetchAssociative();
164
+
165
+        if (!$row) {
166
+            return null;
167
+        }
168
+
169
+        $metaDataQuery = $this->db->getQueryBuilder();
170
+        $metaDataQuery->select(['key', 'value'])
171
+            ->from($this->dbMetaDataTableName)
172
+            ->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
173
+        $metaDataStmt = $metaDataQuery->executeQuery();
174
+        $metaDataRows = $metaDataStmt->fetchAllAssociative();
175
+        $metadata = [];
176
+
177
+        foreach ($metaDataRows as $metaDataRow) {
178
+            $metadata[$metaDataRow['key']] = $metaDataRow['value'];
179
+        }
180
+
181
+        return $this->rowToPrincipal($row, $metadata);
182
+    }
183
+
184
+    /**
185
+     * @param string $path
186
+     * @param PropPatch $propPatch
187
+     * @return int
188
+     */
189
+    public function updatePrincipal($path, PropPatch $propPatch): int {
190
+        return 0;
191
+    }
192
+
193
+    /**
194
+     * @param string $prefixPath
195
+     * @param string $test
196
+     *
197
+     * @return array
198
+     */
199
+    public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') {
200
+        $results = [];
201
+        if (\count($searchProperties) === 0) {
202
+            return [];
203
+        }
204
+        if ($prefixPath !== $this->principalPrefix) {
205
+            return [];
206
+        }
207
+
208
+        $user = $this->userSession->getUser();
209
+        if (!$user) {
210
+            return [];
211
+        }
212
+        $usersGroups = $this->groupManager->getUserGroupIds($user);
213
+
214
+        foreach ($searchProperties as $prop => $value) {
215
+            switch ($prop) {
216
+                case '{http://sabredav.org/ns}email-address':
217
+                    $query = $this->db->getQueryBuilder();
218
+                    $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
219
+                        ->from($this->dbTableName)
220
+                        ->where($query->expr()->iLike('email', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
221
+
222
+                    $stmt = $query->executeQuery();
223
+                    $principals = [];
224
+                    while ($row = $stmt->fetchAssociative()) {
225
+                        if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
226
+                            continue;
227
+                        }
228
+                        $principals[] = $this->rowToPrincipal($row)['uri'];
229
+                    }
230
+                    $results[] = $principals;
231
+
232
+                    $stmt->closeCursor();
233
+                    break;
234
+
235
+                case '{DAV:}displayname':
236
+                    $query = $this->db->getQueryBuilder();
237
+                    $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
238
+                        ->from($this->dbTableName)
239
+                        ->where($query->expr()->iLike('displayname', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
240
+
241
+                    $stmt = $query->executeQuery();
242
+                    $principals = [];
243
+                    while ($row = $stmt->fetchAssociative()) {
244
+                        if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
245
+                            continue;
246
+                        }
247
+                        $principals[] = $this->rowToPrincipal($row)['uri'];
248
+                    }
249
+                    $results[] = $principals;
250
+
251
+                    $stmt->closeCursor();
252
+                    break;
253
+
254
+                case '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set':
255
+                    // If you add support for more search properties that qualify as a user-address,
256
+                    // please also add them to the array below
257
+                    $results[] = $this->searchPrincipals($this->principalPrefix, [
258
+                        '{http://sabredav.org/ns}email-address' => $value,
259
+                    ], 'anyof');
260
+                    break;
261
+
262
+                case IRoomMetadata::FEATURES:
263
+                    $results[] = $this->searchPrincipalsByRoomFeature($prop, $value);
264
+                    break;
265
+
266
+                case IRoomMetadata::CAPACITY:
267
+                case IResourceMetadata::VEHICLE_SEATING_CAPACITY:
268
+                    $results[] = $this->searchPrincipalsByCapacity($prop, $value);
269
+                    break;
270
+
271
+                default:
272
+                    $results[] = $this->searchPrincipalsByMetadataKey($prop, $value, $usersGroups);
273
+                    break;
274
+            }
275
+        }
276
+
277
+        // results is an array of arrays, so this is not the first search result
278
+        // but the results of the first searchProperty
279
+        if (count($results) === 1) {
280
+            return $results[0];
281
+        }
282
+
283
+        switch ($test) {
284
+            case 'anyof':
285
+                return array_values(array_unique(array_merge(...$results)));
286
+
287
+            case 'allof':
288
+            default:
289
+                return array_values(array_intersect(...$results));
290
+        }
291
+    }
292
+
293
+    /**
294
+     * @param string $key
295
+     * @return IQueryBuilder
296
+     */
297
+    private function getMetadataQuery(string $key): IQueryBuilder {
298
+        $query = $this->db->getQueryBuilder();
299
+        $query->select([$this->dbForeignKeyName])
300
+            ->from($this->dbMetaDataTableName)
301
+            ->where($query->expr()->eq('key', $query->createNamedParameter($key)));
302
+        return $query;
303
+    }
304
+
305
+    /**
306
+     * Searches principals based on their metadata keys.
307
+     * This allows to search for all principals with a specific key.
308
+     * e.g.:
309
+     * '{http://nextcloud.com/ns}room-building-address' => 'ABC Street 123, ...'
310
+     *
311
+     * @param string $key
312
+     * @param string $value
313
+     * @param string[] $usersGroups
314
+     * @return string[]
315
+     */
316
+    private function searchPrincipalsByMetadataKey(string $key, string $value, array $usersGroups = []): array {
317
+        $query = $this->getMetadataQuery($key);
318
+        $query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
319
+        return $this->getRows($query, $usersGroups);
320
+    }
321
+
322
+    /**
323
+     * Searches principals based on room features
324
+     * e.g.:
325
+     * '{http://nextcloud.com/ns}room-features' => 'TV,PROJECTOR'
326
+     *
327
+     * @param string $key
328
+     * @param string $value
329
+     * @param string[] $usersGroups
330
+     * @return string[]
331
+     */
332
+    private function searchPrincipalsByRoomFeature(string $key, string $value, array $usersGroups = []): array {
333
+        $query = $this->getMetadataQuery($key);
334
+        foreach (explode(',', $value) as $v) {
335
+            $query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($v) . '%')));
336
+        }
337
+        return $this->getRows($query, $usersGroups);
338
+    }
339
+
340
+    /**
341
+     * Searches principals based on room seating capacity or vehicle capacity
342
+     * e.g.:
343
+     * '{http://nextcloud.com/ns}room-seating-capacity' => '100'
344
+     *
345
+     * @param string $key
346
+     * @param string $value
347
+     * @param string[] $usersGroups
348
+     * @return string[]
349
+     */
350
+    private function searchPrincipalsByCapacity(string $key, string $value, array $usersGroups = []): array {
351
+        $query = $this->getMetadataQuery($key);
352
+        $query->andWhere($query->expr()->gte('value', $query->createNamedParameter($value)));
353
+        return $this->getRows($query, $usersGroups);
354
+    }
355
+
356
+    /**
357
+     * @param IQueryBuilder $query
358
+     * @param string[] $usersGroups
359
+     * @return string[]
360
+     */
361
+    private function getRows(IQueryBuilder $query, array $usersGroups): array {
362
+        try {
363
+            $stmt = $query->executeQuery();
364
+        } catch (Exception $e) {
365
+            $this->logger->error('Could not search resources: ' . $e->getMessage(), ['exception' => $e]);
366
+        }
367
+
368
+        $rows = [];
369
+        while ($row = $stmt->fetchAssociative()) {
370
+            $principalRow = $this->getPrincipalById($row[$this->dbForeignKeyName]);
371
+            if (!$principalRow) {
372
+                continue;
373
+            }
374
+
375
+            $rows[] = $principalRow;
376
+        }
377
+
378
+        $stmt->closeCursor();
379
+
380
+        $filteredRows = array_filter($rows, function ($row) use ($usersGroups) {
381
+            return $this->isAllowedToAccessResource($row, $usersGroups);
382
+        });
383
+
384
+        return array_map(static function ($row): string {
385
+            return $row['uri'];
386
+        }, $filteredRows);
387
+    }
388
+
389
+    /**
390
+     * @param string $uri
391
+     * @param string $principalPrefix
392
+     * @return null|string
393
+     * @throws Exception
394
+     */
395
+    public function findByUri($uri, $principalPrefix): ?string {
396
+        $user = $this->userSession->getUser();
397
+        if (!$user) {
398
+            return null;
399
+        }
400
+        $usersGroups = $this->groupManager->getUserGroupIds($user);
401
+
402
+        if (str_starts_with($uri, 'mailto:')) {
403
+            $email = substr($uri, 7);
404
+            $query = $this->db->getQueryBuilder();
405
+            $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
406
+                ->from($this->dbTableName)
407
+                ->where($query->expr()->eq('email', $query->createNamedParameter($email)));
408
+
409
+            $stmt = $query->executeQuery();
410
+            $row = $stmt->fetchAssociative();
411
+
412
+            if (!$row) {
413
+                return null;
414
+            }
415
+            if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
416
+                return null;
417
+            }
418
+
419
+            return $this->rowToPrincipal($row)['uri'];
420
+        }
421
+
422
+        if (str_starts_with($uri, 'principal:')) {
423
+            $path = substr($uri, 10);
424
+            if (!str_starts_with($path, $this->principalPrefix)) {
425
+                return null;
426
+            }
427
+
428
+            [, $name] = \Sabre\Uri\split($path);
429
+            [$backendId, $resourceId] = explode('-', $name, 2);
430
+
431
+            $query = $this->db->getQueryBuilder();
432
+            $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
433
+                ->from($this->dbTableName)
434
+                ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
435
+                ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
436
+            $stmt = $query->executeQuery();
437
+            $row = $stmt->fetchAssociative();
438
+
439
+            if (!$row) {
440
+                return null;
441
+            }
442
+            if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
443
+                return null;
444
+            }
445
+
446
+            return $this->rowToPrincipal($row)['uri'];
447
+        }
448
+
449
+        return null;
450
+    }
451
+
452
+    /**
453
+     * convert database row to principal
454
+     *
455
+     * @param string[] $row
456
+     * @param string[] $metadata
457
+     * @return string[]
458
+     */
459
+    private function rowToPrincipal(array $row, array $metadata = []): array {
460
+        return array_merge([
461
+            'uri' => $this->principalPrefix . '/' . $row['backend_id'] . '-' . $row['resource_id'],
462
+            '{DAV:}displayname' => $row['displayname'],
463
+            '{http://sabredav.org/ns}email-address' => $row['email'],
464
+            '{urn:ietf:params:xml:ns:caldav}calendar-user-type' => $this->cuType,
465
+        ], $metadata);
466
+    }
467
+
468
+    /**
469
+     * @param array $row
470
+     * @param array $userGroups
471
+     * @return bool
472
+     */
473
+    private function isAllowedToAccessResource(array $row, array $userGroups): bool {
474
+        if (!isset($row['group_restrictions'])
475
+            || $row['group_restrictions'] === null
476
+            || $row['group_restrictions'] === '') {
477
+            return true;
478
+        }
479
+
480
+        // group restrictions contains something, but not parsable, deny access and log warning
481
+        $json = json_decode($row['group_restrictions'], null, 512, JSON_THROW_ON_ERROR);
482
+        if (!\is_array($json)) {
483
+            $this->logger->info('group_restrictions field could not be parsed for ' . $this->dbTableName . '::' . $row['id'] . ', denying access to resource');
484
+            return false;
485
+        }
486
+
487
+        // empty array => no group restrictions
488
+        if (empty($json)) {
489
+            return true;
490
+        }
491
+
492
+        return !empty(array_intersect($json, $userGroups));
493
+    }
494 494
 }
Please login to merge, or discard this patch.
apps/dav/lib/DAV/CustomPropertiesBackend.php 2 patches
Indentation   +674 added lines, -674 removed lines patch added patch discarded remove patch
@@ -40,678 +40,678 @@
 block discarded – undo
40 40
 
41 41
 class CustomPropertiesBackend implements BackendInterface {
42 42
 
43
-	/** @var string */
44
-	private const TABLE_NAME = 'properties';
45
-
46
-	/**
47
-	 * Value is stored as string.
48
-	 */
49
-	public const PROPERTY_TYPE_STRING = 1;
50
-
51
-	/**
52
-	 * Value is stored as XML fragment.
53
-	 */
54
-	public const PROPERTY_TYPE_XML = 2;
55
-
56
-	/**
57
-	 * Value is stored as a property object.
58
-	 */
59
-	public const PROPERTY_TYPE_OBJECT = 3;
60
-
61
-	/**
62
-	 * Value is stored as a {DAV:}href string.
63
-	 */
64
-	public const PROPERTY_TYPE_HREF = 4;
65
-
66
-	/**
67
-	 * Ignored properties
68
-	 *
69
-	 * @var string[]
70
-	 */
71
-	private const IGNORED_PROPERTIES = [
72
-		'{DAV:}getcontentlength',
73
-		'{DAV:}getcontenttype',
74
-		'{DAV:}getetag',
75
-		'{DAV:}quota-used-bytes',
76
-		'{DAV:}quota-available-bytes',
77
-	];
78
-
79
-	/**
80
-	 * Allowed properties for the oc/nc namespace, all other properties in the namespace are ignored
81
-	 *
82
-	 * @var string[]
83
-	 */
84
-	private const ALLOWED_NC_PROPERTIES = [
85
-		'{http://owncloud.org/ns}calendar-enabled',
86
-		'{http://owncloud.org/ns}enabled',
87
-	];
88
-
89
-	/**
90
-	 * Properties set by one user, readable by all others
91
-	 *
92
-	 * @var string[]
93
-	 */
94
-	private const PUBLISHED_READ_ONLY_PROPERTIES = [
95
-		'{urn:ietf:params:xml:ns:caldav}calendar-availability',
96
-		'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
97
-	];
98
-
99
-	/**
100
-	 * Map of custom XML elements to parse when trying to deserialize an instance of
101
-	 * \Sabre\DAV\Xml\Property\Complex to find a more specialized PROPERTY_TYPE_*
102
-	 * @var array<string, class-string>
103
-	 */
104
-	private const COMPLEX_XML_ELEMENT_MAP = [
105
-		'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class,
106
-	];
107
-
108
-	/**
109
-	 * Map of well-known property names to default values
110
-	 * @var array<string, string>
111
-	 */
112
-	private const PROPERTY_DEFAULT_VALUES = [
113
-		'{http://owncloud.org/ns}calendar-enabled' => '1',
114
-	];
115
-
116
-	/**
117
-	 * Properties cache
118
-	 */
119
-	private array $userCache = [];
120
-	private array $publishedCache = [];
121
-	private XmlService $xmlService;
122
-
123
-	/**
124
-	 * @param IUser $user owner of the tree and properties
125
-	 */
126
-	public function __construct(
127
-		private readonly Server $server,
128
-		private readonly Tree $tree,
129
-		private readonly IDBConnection $connection,
130
-		private readonly IUser $user,
131
-		private readonly PropertyMapper $propertyMapper,
132
-		private readonly DefaultCalendarValidator $defaultCalendarValidator,
133
-	) {
134
-		$this->xmlService = new XmlService();
135
-		$this->xmlService->elementMap = array_merge(
136
-			$this->xmlService->elementMap,
137
-			self::COMPLEX_XML_ELEMENT_MAP,
138
-		);
139
-	}
140
-
141
-	/**
142
-	 * Fetches properties for a path.
143
-	 *
144
-	 * @param string $path
145
-	 * @param PropFind $propFind
146
-	 */
147
-	#[Override]
148
-	public function propFind($path, PropFind $propFind): void {
149
-		$requestedProps = $propFind->get404Properties();
150
-
151
-		$requestedProps = array_filter(
152
-			$requestedProps,
153
-			$this->isPropertyAllowed(...),
154
-		);
155
-
156
-		// substr of calendars/ => path is inside the CalDAV component
157
-		// two '/' => this a calendar (no calendar-home nor calendar object)
158
-		if (str_starts_with($path, 'calendars/') && substr_count($path, '/') === 2) {
159
-			$allRequestedProps = $propFind->getRequestedProperties();
160
-			$customPropertiesForShares = [
161
-				'{DAV:}displayname',
162
-				'{urn:ietf:params:xml:ns:caldav}calendar-description',
163
-				'{urn:ietf:params:xml:ns:caldav}calendar-timezone',
164
-				'{http://apple.com/ns/ical/}calendar-order',
165
-				'{http://apple.com/ns/ical/}calendar-color',
166
-				'{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp',
167
-			];
168
-
169
-			foreach ($customPropertiesForShares as $customPropertyForShares) {
170
-				if (in_array($customPropertyForShares, $allRequestedProps)) {
171
-					$requestedProps[] = $customPropertyForShares;
172
-				}
173
-			}
174
-		}
175
-
176
-		// substr of addressbooks/ => path is inside the CardDAV component
177
-		// three '/' => this a addressbook (no addressbook-home nor contact object)
178
-		if (str_starts_with($path, 'addressbooks/') && substr_count($path, '/') === 3) {
179
-			$allRequestedProps = $propFind->getRequestedProperties();
180
-			$customPropertiesForShares = [
181
-				'{DAV:}displayname',
182
-			];
183
-
184
-			foreach ($customPropertiesForShares as $customPropertyForShares) {
185
-				if (in_array($customPropertyForShares, $allRequestedProps, true)) {
186
-					$requestedProps[] = $customPropertyForShares;
187
-				}
188
-			}
189
-		}
190
-
191
-		// substr of principals/users/ => path is a user principal
192
-		// two '/' => this a principal collection (and not some child object)
193
-		if (str_starts_with($path, 'principals/users/') && substr_count($path, '/') === 2) {
194
-			$allRequestedProps = $propFind->getRequestedProperties();
195
-			$customProperties = [
196
-				'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
197
-			];
198
-
199
-			foreach ($customProperties as $customProperty) {
200
-				if (in_array($customProperty, $allRequestedProps, true)) {
201
-					$requestedProps[] = $customProperty;
202
-				}
203
-			}
204
-		}
205
-
206
-		if (empty($requestedProps)) {
207
-			return;
208
-		}
209
-
210
-		$node = $this->tree->getNodeForPath($path);
211
-		if ($node instanceof Directory && $propFind->getDepth() !== 0) {
212
-			$this->cacheDirectory($path, $node);
213
-		}
214
-
215
-		if ($node instanceof CalendarHome && $propFind->getDepth() !== 0) {
216
-			$backend = $node->getCalDAVBackend();
217
-			if ($backend instanceof CalDavBackend) {
218
-				$this->cacheCalendars($node, $requestedProps);
219
-			}
220
-		}
221
-
222
-		if ($node instanceof CalendarObject) {
223
-			// No custom properties supported on individual events
224
-			return;
225
-		}
226
-
227
-		// First fetch the published properties (set by another user), then get the ones set by
228
-		// the current user. If both are set then the latter as priority.
229
-		foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) {
230
-			try {
231
-				$this->validateProperty($path, $propName, $propValue);
232
-			} catch (DavException $e) {
233
-				continue;
234
-			}
235
-			$propFind->set($propName, $propValue);
236
-		}
237
-		foreach ($this->getUserProperties($path, $requestedProps) as $propName => $propValue) {
238
-			try {
239
-				$this->validateProperty($path, $propName, $propValue);
240
-			} catch (DavException $e) {
241
-				continue;
242
-			}
243
-			$propFind->set($propName, $propValue);
244
-		}
245
-	}
246
-
247
-	private function isPropertyAllowed(string $property): bool {
248
-		if (in_array($property, self::IGNORED_PROPERTIES)) {
249
-			return false;
250
-		}
251
-		if (str_starts_with($property, '{http://owncloud.org/ns}') || str_starts_with($property, '{http://nextcloud.org/ns}')) {
252
-			return in_array($property, self::ALLOWED_NC_PROPERTIES);
253
-		}
254
-		return true;
255
-	}
256
-
257
-	/**
258
-	 * Updates properties for a path
259
-	 *
260
-	 * @param string $path
261
-	 */
262
-	#[Override]
263
-	public function propPatch($path, PropPatch $propPatch): void {
264
-		$propPatch->handleRemaining(function (array $changedProps) use ($path) {
265
-			return $this->updateProperties($path, $changedProps);
266
-		});
267
-	}
268
-
269
-	/**
270
-	 * This method is called after a node is deleted.
271
-	 *
272
-	 * @param string $path path of node for which to delete properties
273
-	 */
274
-	#[Override]
275
-	public function delete($path): void {
276
-		$qb = $this->connection->getQueryBuilder();
277
-		$qb->delete('properties')
278
-			->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID())))
279
-			->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($path))));
280
-		$qb->executeStatement();
281
-		unset($this->userCache[$path]);
282
-	}
283
-
284
-	/**
285
-	 * This method is called after a successful MOVE
286
-	 *
287
-	 * @param string $source
288
-	 * @param string $destination
289
-	 */
290
-	#[Override]
291
-	public function move($source, $destination): void {
292
-		$qb = $this->connection->getQueryBuilder();
293
-		$qb->update('properties')
294
-			->set('propertypath', $qb->createNamedParameter($this->formatPath($destination)))
295
-			->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID())))
296
-			->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($source))));
297
-		$qb->executeStatement();
298
-	}
299
-
300
-	/**
301
-	 * Validate the value of a property. Will throw if a value is invalid.
302
-	 *
303
-	 * @throws DavException The value of the property is invalid
304
-	 */
305
-	private function validateProperty(string $path, string $propName, mixed $propValue): void {
306
-		switch ($propName) {
307
-			case '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL':
308
-				/** @var Href $propValue */
309
-				$href = $propValue->getHref();
310
-				if ($href === null) {
311
-					throw new DavException('Href is empty');
312
-				}
313
-
314
-				// $path is the principal here as this prop is only set on principals
315
-				$node = $this->tree->getNodeForPath($href);
316
-				if (!($node instanceof Calendar) || $node->getOwner() !== $path) {
317
-					throw new DavException('No such calendar');
318
-				}
319
-
320
-				$this->defaultCalendarValidator->validateScheduleDefaultCalendar($node);
321
-				break;
322
-		}
323
-	}
324
-
325
-	/**
326
-	 * @param string[] $requestedProperties
327
-	 *
328
-	 * @return array<string, mixed|Complex|Href|string>
329
-	 * @throws \OCP\DB\Exception
330
-	 */
331
-	private function getPublishedProperties(string $path, array $requestedProperties): array {
332
-		$allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
333
-
334
-		if (empty($allowedProps)) {
335
-			return [];
336
-		}
337
-
338
-		if (isset($this->publishedCache[$path])) {
339
-			return $this->publishedCache[$path];
340
-		}
341
-
342
-		$qb = $this->connection->getQueryBuilder();
343
-		$qb->select('*')
344
-			->from(self::TABLE_NAME)
345
-			->where($qb->expr()->eq('propertypath', $qb->createNamedParameter($path)));
346
-		$result = $qb->executeQuery();
347
-		$props = [];
348
-		while ($row = $result->fetchAssociative()) {
349
-			$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
350
-		}
351
-		$result->closeCursor();
352
-		$this->publishedCache[$path] = $props;
353
-		return $props;
354
-	}
355
-
356
-	/**
357
-	 * Prefetch all user properties in a directory
358
-	 */
359
-	private function cacheDirectory(string $path, Directory $node): void {
360
-		$prefix = ltrim($path . '/', '/');
361
-		$query = $this->connection->getQueryBuilder();
362
-		$query->select('name', 'p.propertypath', 'p.propertyname', 'p.propertyvalue', 'p.valuetype')
363
-			->from('filecache', 'f')
364
-			->hintShardKey('storage', $node->getNode()->getMountPoint()->getNumericStorageId())
365
-			->leftJoin('f', 'properties', 'p', $query->expr()->eq('p.propertypath', $query->func()->concat(
366
-				$query->createNamedParameter($prefix),
367
-				'f.name'
368
-			)),
369
-			)
370
-			->where($query->expr()->eq('parent', $query->createNamedParameter($node->getInternalFileId(), IQueryBuilder::PARAM_INT)))
371
-			->andWhere($query->expr()->orX(
372
-				$query->expr()->eq('p.userid', $query->createNamedParameter($this->user->getUID())),
373
-				$query->expr()->isNull('p.userid'),
374
-			));
375
-		$result = $query->executeQuery();
376
-
377
-		$propsByPath = [];
378
-
379
-		while ($row = $result->fetchAssociative()) {
380
-			$childPath = $prefix . $row['name'];
381
-			if (!isset($propsByPath[$childPath])) {
382
-				$propsByPath[$childPath] = [];
383
-			}
384
-			if (isset($row['propertyname'])) {
385
-				$propsByPath[$childPath][$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
386
-			}
387
-		}
388
-		$this->userCache = array_merge($this->userCache, $propsByPath);
389
-	}
390
-
391
-	private function cacheCalendars(CalendarHome $node, array $requestedProperties): void {
392
-		$calendars = $node->getChildren();
393
-
394
-		$users = [];
395
-		foreach ($calendars as $calendar) {
396
-			if ($calendar instanceof Calendar) {
397
-				$user = str_replace('principals/users/', '', $calendar->getPrincipalURI());
398
-				if (!isset($users[$user])) {
399
-					$users[$user] = ['calendars/' . $user];
400
-				}
401
-				$users[$user][] = 'calendars/' . $user . '/' . $calendar->getUri();
402
-			} elseif ($calendar instanceof Inbox || $calendar instanceof Outbox || $calendar instanceof TrashbinHome || $calendar instanceof ExternalCalendar) {
403
-				if ($calendar->getOwner()) {
404
-					$user = str_replace('principals/users/', '', $calendar->getOwner());
405
-					if (!isset($users[$user])) {
406
-						$users[$user] = ['calendars/' . $user];
407
-					}
408
-					$users[$user][] = 'calendars/' . $user . '/' . $calendar->getName();
409
-				}
410
-			}
411
-		}
412
-
413
-		// user properties
414
-		$properties = $this->propertyMapper->findPropertiesByPathsAndUsers($users);
415
-
416
-		$propsByPath = [];
417
-		foreach ($users as $paths) {
418
-			foreach ($paths as $path) {
419
-				$propsByPath[$path] = [];
420
-			}
421
-		}
422
-
423
-		foreach ($properties as $property) {
424
-			$propsByPath[$property->getPropertypath()][$property->getPropertyname()] = $this->decodeValueFromDatabase($property->getPropertyvalue(), $property->getValuetype());
425
-		}
426
-		$this->userCache = array_merge($this->userCache, $propsByPath);
427
-
428
-		// published properties
429
-		$allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
430
-		if (empty($allowedProps)) {
431
-			return;
432
-		}
433
-		$paths = [];
434
-		foreach ($users as $nestedPaths) {
435
-			$paths = array_merge($paths, $nestedPaths);
436
-		}
437
-		$paths = array_unique($paths);
438
-
439
-		$propsByPath = array_fill_keys(array_values($paths), []);
440
-		$properties = $this->propertyMapper->findPropertiesByPaths($paths, $allowedProps);
441
-		foreach ($properties as $property) {
442
-			$propsByPath[$property->getPropertypath()][$property->getPropertyname()] = $this->decodeValueFromDatabase($property->getPropertyvalue(), $property->getValuetype());
443
-		}
444
-		$this->publishedCache = array_merge($this->publishedCache, $propsByPath);
445
-	}
446
-
447
-	/**
448
-	 * Returns a list of properties for the given path and current user
449
-	 *
450
-	 * @param array $requestedProperties requested properties or empty array for "all"
451
-	 * @return array<string, mixed>
452
-	 * @note The properties list is a list of propertynames the client
453
-	 * requested, encoded as xmlnamespace#tagName, for example:
454
-	 * http://www.example.org/namespace#author If the array is empty, all
455
-	 * properties should be returned
456
-	 */
457
-	private function getUserProperties(string $path, array $requestedProperties): array {
458
-		if (isset($this->userCache[$path])) {
459
-			return $this->userCache[$path];
460
-		}
461
-
462
-		$props = [];
463
-
464
-		$qb = $this->connection->getQueryBuilder();
465
-		$qb->select('*')
466
-			->from('properties')
467
-			->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID(), IQueryBuilder::PARAM_STR)))
468
-			->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($path), IQueryBuilder::PARAM_STR)));
469
-
470
-		if (!empty($requestedProperties)) {
471
-			// request only a subset
472
-			$qb->andWhere($qb->expr()->in('propertyname', $qb->createParameter('requestedProperties')));
473
-			$chunks = array_chunk($requestedProperties, 1000);
474
-			foreach ($chunks as $chunk) {
475
-				$qb->setParameter('requestedProperties', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
476
-				$result = $qb->executeQuery();
477
-				while ($row = $result->fetchAssociative()) {
478
-					$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
479
-				}
480
-			}
481
-		} else {
482
-			$result = $qb->executeQuery();
483
-			while ($row = $result->fetchAssociative()) {
484
-				$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
485
-			}
486
-		}
487
-
488
-		$this->userCache[$path] = $props;
489
-		return $props;
490
-	}
491
-
492
-	private function isPropertyDefaultValue(string $name, mixed $value): bool {
493
-		if (!isset(self::PROPERTY_DEFAULT_VALUES[$name])) {
494
-			return false;
495
-		}
496
-
497
-		return self::PROPERTY_DEFAULT_VALUES[$name] === $value;
498
-	}
499
-
500
-	/**
501
-	 * @param array<string, string> $properties
502
-	 * @throws Exception
503
-	 */
504
-	private function updateProperties(string $path, array $properties): bool {
505
-		// TODO: use "insert or update" strategy ?
506
-		$existing = $this->getUserProperties($path, []);
507
-		try {
508
-			$this->connection->beginTransaction();
509
-			foreach ($properties as $propertyName => $propertyValue) {
510
-				// common parameters for all queries
511
-				$dbParameters = [
512
-					'userid' => $this->user->getUID(),
513
-					'propertyPath' => $this->formatPath($path),
514
-					'propertyName' => $propertyName,
515
-				];
516
-
517
-				// If it was null or set to the default value, we need to delete the property
518
-				if (is_null($propertyValue) || $this->isPropertyDefaultValue($propertyName, $propertyValue)) {
519
-					if (array_key_exists($propertyName, $existing)) {
520
-						$deleteQuery = $deleteQuery ?? $this->createDeleteQuery();
521
-						$deleteQuery
522
-							->setParameters($dbParameters)
523
-							->executeStatement();
524
-					}
525
-				} else {
526
-					[$value, $valueType] = $this->encodeValueForDatabase(
527
-						$path,
528
-						$propertyName,
529
-						$propertyValue,
530
-					);
531
-					$dbParameters['propertyValue'] = $value;
532
-					$dbParameters['valueType'] = $valueType;
533
-
534
-					if (!array_key_exists($propertyName, $existing)) {
535
-						$insertQuery = $insertQuery ?? $this->createInsertQuery();
536
-						$insertQuery
537
-							->setParameters($dbParameters)
538
-							->executeStatement();
539
-					} else {
540
-						$updateQuery = $updateQuery ?? $this->createUpdateQuery();
541
-						$updateQuery
542
-							->setParameters($dbParameters)
543
-							->executeStatement();
544
-					}
545
-				}
546
-			}
547
-
548
-			$this->connection->commit();
549
-			unset($this->userCache[$path]);
550
-		} catch (Exception $e) {
551
-			$this->connection->rollBack();
552
-			throw $e;
553
-		}
554
-
555
-		return true;
556
-	}
557
-
558
-	/**
559
-	 * Long paths are hashed to ensure they fit in the database
560
-	 */
561
-	private function formatPath(string $path): string {
562
-		if (strlen($path) > 250) {
563
-			return sha1($path);
564
-		}
565
-
566
-		return $path;
567
-	}
568
-
569
-	private static function checkIsArrayOfScalar(string $name, array $array): void {
570
-		foreach ($array as $item) {
571
-			if (is_array($item)) {
572
-				self::checkIsArrayOfScalar($name, $item);
573
-			} elseif ($item !== null && !is_scalar($item)) {
574
-				throw new DavException(
575
-					"Property \"$name\" has an invalid value of array containing " . gettype($item),
576
-				);
577
-			}
578
-		}
579
-	}
580
-
581
-	/**
582
-	 * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails
583
-	 * @throws DavException If the property value is invalid
584
-	 */
585
-	private function encodeValueForDatabase(string $path, string $name, mixed $value): array {
586
-		// Try to parse a more specialized property type first
587
-		if ($value instanceof Complex) {
588
-			$xml = $this->xmlService->write($name, [$value], $this->server->getBaseUri());
589
-			$value = $this->xmlService->parse($xml, $this->server->getBaseUri()) ?? $value;
590
-		}
591
-
592
-		if ($name === '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL') {
593
-			$value = $this->encodeDefaultCalendarUrl($value);
594
-		}
595
-
596
-		try {
597
-			$this->validateProperty($path, $name, $value);
598
-		} catch (DavException $e) {
599
-			throw new DavException(
600
-				"Property \"$name\" has an invalid value: " . $e->getMessage(),
601
-				0,
602
-				$e,
603
-			);
604
-		}
605
-
606
-		if (is_scalar($value)) {
607
-			$valueType = self::PROPERTY_TYPE_STRING;
608
-		} elseif ($value instanceof Complex) {
609
-			$valueType = self::PROPERTY_TYPE_XML;
610
-			$value = $value->getXml();
611
-		} elseif ($value instanceof Href) {
612
-			$valueType = self::PROPERTY_TYPE_HREF;
613
-			$value = $value->getHref();
614
-		} else {
615
-			if (is_array($value)) {
616
-				// For array only allow scalar values
617
-				self::checkIsArrayOfScalar($name, $value);
618
-			} elseif (!is_object($value)) {
619
-				throw new DavException(
620
-					"Property \"$name\" has an invalid value of type " . gettype($value),
621
-				);
622
-			} else {
623
-				if (!str_starts_with($value::class, 'Sabre\\DAV\\Xml\\Property\\')
624
-					&& !str_starts_with($value::class, 'Sabre\\CalDAV\\Xml\\Property\\')
625
-					&& !str_starts_with($value::class, 'Sabre\\CardDAV\\Xml\\Property\\')
626
-					&& !str_starts_with($value::class, 'OCA\\DAV\\')) {
627
-					throw new DavException(
628
-						"Property \"$name\" has an invalid value of class " . $value::class,
629
-					);
630
-				}
631
-			}
632
-			$valueType = self::PROPERTY_TYPE_OBJECT;
633
-			// serialize produces null character
634
-			// these can not be properly stored in some databases and need to be replaced
635
-			$value = str_replace(chr(0), '\x00', serialize($value));
636
-		}
637
-		return [$value, $valueType];
638
-	}
639
-
640
-	/**
641
-	 * @return mixed|Complex|string
642
-	 */
643
-	private function decodeValueFromDatabase(string $value, int $valueType): mixed {
644
-		switch ($valueType) {
645
-			case self::PROPERTY_TYPE_XML:
646
-				return new Complex($value);
647
-			case self::PROPERTY_TYPE_HREF:
648
-				return new Href($value);
649
-			case self::PROPERTY_TYPE_OBJECT:
650
-				if (preg_match('/^a:/', $value)) {
651
-					// Array, unserialize only scalar values
652
-					return unserialize(str_replace('\x00', chr(0), $value), ['allowed_classes' => false]);
653
-				}
654
-				if (!preg_match('/^O\:\d+\:\"(OCA\\\\DAV\\\\|Sabre\\\\(Cal|Card)?DAV\\\\Xml\\\\Property\\\\)/', $value)) {
655
-					throw new \LogicException('Found an object class serialized in DB that is not allowed');
656
-				}
657
-				// some databases can not handel null characters, these are custom encoded during serialization
658
-				// this custom encoding needs to be first reversed before unserializing
659
-				return unserialize(str_replace('\x00', chr(0), $value));
660
-			default:
661
-				return $value;
662
-		};
663
-	}
664
-
665
-	private function encodeDefaultCalendarUrl(Href $value): Href {
666
-		$href = $value->getHref();
667
-		if ($href === null) {
668
-			return $value;
669
-		}
670
-
671
-		if (!str_starts_with($href, '/')) {
672
-			return $value;
673
-		}
674
-
675
-		try {
676
-			// Build path relative to the dav base URI to be used later to find the node
677
-			$value = new LocalHref($this->server->calculateUri($href) . '/');
678
-		} catch (DavException\Forbidden) {
679
-			// Not existing calendars will be handled later when the value is validated
680
-		}
681
-
682
-		return $value;
683
-	}
684
-
685
-	private function createDeleteQuery(): IQueryBuilder {
686
-		$deleteQuery = $this->connection->getQueryBuilder();
687
-		$deleteQuery->delete('properties')
688
-			->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid')))
689
-			->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath')))
690
-			->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName')));
691
-		return $deleteQuery;
692
-	}
693
-
694
-	private function createInsertQuery(): IQueryBuilder {
695
-		$insertQuery = $this->connection->getQueryBuilder();
696
-		$insertQuery->insert('properties')
697
-			->values([
698
-				'userid' => $insertQuery->createParameter('userid'),
699
-				'propertypath' => $insertQuery->createParameter('propertyPath'),
700
-				'propertyname' => $insertQuery->createParameter('propertyName'),
701
-				'propertyvalue' => $insertQuery->createParameter('propertyValue'),
702
-				'valuetype' => $insertQuery->createParameter('valueType'),
703
-			]);
704
-		return $insertQuery;
705
-	}
706
-
707
-	private function createUpdateQuery(): IQueryBuilder {
708
-		$updateQuery = $this->connection->getQueryBuilder();
709
-		$updateQuery->update('properties')
710
-			->set('propertyvalue', $updateQuery->createParameter('propertyValue'))
711
-			->set('valuetype', $updateQuery->createParameter('valueType'))
712
-			->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid')))
713
-			->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath')))
714
-			->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName')));
715
-		return $updateQuery;
716
-	}
43
+    /** @var string */
44
+    private const TABLE_NAME = 'properties';
45
+
46
+    /**
47
+     * Value is stored as string.
48
+     */
49
+    public const PROPERTY_TYPE_STRING = 1;
50
+
51
+    /**
52
+     * Value is stored as XML fragment.
53
+     */
54
+    public const PROPERTY_TYPE_XML = 2;
55
+
56
+    /**
57
+     * Value is stored as a property object.
58
+     */
59
+    public const PROPERTY_TYPE_OBJECT = 3;
60
+
61
+    /**
62
+     * Value is stored as a {DAV:}href string.
63
+     */
64
+    public const PROPERTY_TYPE_HREF = 4;
65
+
66
+    /**
67
+     * Ignored properties
68
+     *
69
+     * @var string[]
70
+     */
71
+    private const IGNORED_PROPERTIES = [
72
+        '{DAV:}getcontentlength',
73
+        '{DAV:}getcontenttype',
74
+        '{DAV:}getetag',
75
+        '{DAV:}quota-used-bytes',
76
+        '{DAV:}quota-available-bytes',
77
+    ];
78
+
79
+    /**
80
+     * Allowed properties for the oc/nc namespace, all other properties in the namespace are ignored
81
+     *
82
+     * @var string[]
83
+     */
84
+    private const ALLOWED_NC_PROPERTIES = [
85
+        '{http://owncloud.org/ns}calendar-enabled',
86
+        '{http://owncloud.org/ns}enabled',
87
+    ];
88
+
89
+    /**
90
+     * Properties set by one user, readable by all others
91
+     *
92
+     * @var string[]
93
+     */
94
+    private const PUBLISHED_READ_ONLY_PROPERTIES = [
95
+        '{urn:ietf:params:xml:ns:caldav}calendar-availability',
96
+        '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
97
+    ];
98
+
99
+    /**
100
+     * Map of custom XML elements to parse when trying to deserialize an instance of
101
+     * \Sabre\DAV\Xml\Property\Complex to find a more specialized PROPERTY_TYPE_*
102
+     * @var array<string, class-string>
103
+     */
104
+    private const COMPLEX_XML_ELEMENT_MAP = [
105
+        '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class,
106
+    ];
107
+
108
+    /**
109
+     * Map of well-known property names to default values
110
+     * @var array<string, string>
111
+     */
112
+    private const PROPERTY_DEFAULT_VALUES = [
113
+        '{http://owncloud.org/ns}calendar-enabled' => '1',
114
+    ];
115
+
116
+    /**
117
+     * Properties cache
118
+     */
119
+    private array $userCache = [];
120
+    private array $publishedCache = [];
121
+    private XmlService $xmlService;
122
+
123
+    /**
124
+     * @param IUser $user owner of the tree and properties
125
+     */
126
+    public function __construct(
127
+        private readonly Server $server,
128
+        private readonly Tree $tree,
129
+        private readonly IDBConnection $connection,
130
+        private readonly IUser $user,
131
+        private readonly PropertyMapper $propertyMapper,
132
+        private readonly DefaultCalendarValidator $defaultCalendarValidator,
133
+    ) {
134
+        $this->xmlService = new XmlService();
135
+        $this->xmlService->elementMap = array_merge(
136
+            $this->xmlService->elementMap,
137
+            self::COMPLEX_XML_ELEMENT_MAP,
138
+        );
139
+    }
140
+
141
+    /**
142
+     * Fetches properties for a path.
143
+     *
144
+     * @param string $path
145
+     * @param PropFind $propFind
146
+     */
147
+    #[Override]
148
+    public function propFind($path, PropFind $propFind): void {
149
+        $requestedProps = $propFind->get404Properties();
150
+
151
+        $requestedProps = array_filter(
152
+            $requestedProps,
153
+            $this->isPropertyAllowed(...),
154
+        );
155
+
156
+        // substr of calendars/ => path is inside the CalDAV component
157
+        // two '/' => this a calendar (no calendar-home nor calendar object)
158
+        if (str_starts_with($path, 'calendars/') && substr_count($path, '/') === 2) {
159
+            $allRequestedProps = $propFind->getRequestedProperties();
160
+            $customPropertiesForShares = [
161
+                '{DAV:}displayname',
162
+                '{urn:ietf:params:xml:ns:caldav}calendar-description',
163
+                '{urn:ietf:params:xml:ns:caldav}calendar-timezone',
164
+                '{http://apple.com/ns/ical/}calendar-order',
165
+                '{http://apple.com/ns/ical/}calendar-color',
166
+                '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp',
167
+            ];
168
+
169
+            foreach ($customPropertiesForShares as $customPropertyForShares) {
170
+                if (in_array($customPropertyForShares, $allRequestedProps)) {
171
+                    $requestedProps[] = $customPropertyForShares;
172
+                }
173
+            }
174
+        }
175
+
176
+        // substr of addressbooks/ => path is inside the CardDAV component
177
+        // three '/' => this a addressbook (no addressbook-home nor contact object)
178
+        if (str_starts_with($path, 'addressbooks/') && substr_count($path, '/') === 3) {
179
+            $allRequestedProps = $propFind->getRequestedProperties();
180
+            $customPropertiesForShares = [
181
+                '{DAV:}displayname',
182
+            ];
183
+
184
+            foreach ($customPropertiesForShares as $customPropertyForShares) {
185
+                if (in_array($customPropertyForShares, $allRequestedProps, true)) {
186
+                    $requestedProps[] = $customPropertyForShares;
187
+                }
188
+            }
189
+        }
190
+
191
+        // substr of principals/users/ => path is a user principal
192
+        // two '/' => this a principal collection (and not some child object)
193
+        if (str_starts_with($path, 'principals/users/') && substr_count($path, '/') === 2) {
194
+            $allRequestedProps = $propFind->getRequestedProperties();
195
+            $customProperties = [
196
+                '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
197
+            ];
198
+
199
+            foreach ($customProperties as $customProperty) {
200
+                if (in_array($customProperty, $allRequestedProps, true)) {
201
+                    $requestedProps[] = $customProperty;
202
+                }
203
+            }
204
+        }
205
+
206
+        if (empty($requestedProps)) {
207
+            return;
208
+        }
209
+
210
+        $node = $this->tree->getNodeForPath($path);
211
+        if ($node instanceof Directory && $propFind->getDepth() !== 0) {
212
+            $this->cacheDirectory($path, $node);
213
+        }
214
+
215
+        if ($node instanceof CalendarHome && $propFind->getDepth() !== 0) {
216
+            $backend = $node->getCalDAVBackend();
217
+            if ($backend instanceof CalDavBackend) {
218
+                $this->cacheCalendars($node, $requestedProps);
219
+            }
220
+        }
221
+
222
+        if ($node instanceof CalendarObject) {
223
+            // No custom properties supported on individual events
224
+            return;
225
+        }
226
+
227
+        // First fetch the published properties (set by another user), then get the ones set by
228
+        // the current user. If both are set then the latter as priority.
229
+        foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) {
230
+            try {
231
+                $this->validateProperty($path, $propName, $propValue);
232
+            } catch (DavException $e) {
233
+                continue;
234
+            }
235
+            $propFind->set($propName, $propValue);
236
+        }
237
+        foreach ($this->getUserProperties($path, $requestedProps) as $propName => $propValue) {
238
+            try {
239
+                $this->validateProperty($path, $propName, $propValue);
240
+            } catch (DavException $e) {
241
+                continue;
242
+            }
243
+            $propFind->set($propName, $propValue);
244
+        }
245
+    }
246
+
247
+    private function isPropertyAllowed(string $property): bool {
248
+        if (in_array($property, self::IGNORED_PROPERTIES)) {
249
+            return false;
250
+        }
251
+        if (str_starts_with($property, '{http://owncloud.org/ns}') || str_starts_with($property, '{http://nextcloud.org/ns}')) {
252
+            return in_array($property, self::ALLOWED_NC_PROPERTIES);
253
+        }
254
+        return true;
255
+    }
256
+
257
+    /**
258
+     * Updates properties for a path
259
+     *
260
+     * @param string $path
261
+     */
262
+    #[Override]
263
+    public function propPatch($path, PropPatch $propPatch): void {
264
+        $propPatch->handleRemaining(function (array $changedProps) use ($path) {
265
+            return $this->updateProperties($path, $changedProps);
266
+        });
267
+    }
268
+
269
+    /**
270
+     * This method is called after a node is deleted.
271
+     *
272
+     * @param string $path path of node for which to delete properties
273
+     */
274
+    #[Override]
275
+    public function delete($path): void {
276
+        $qb = $this->connection->getQueryBuilder();
277
+        $qb->delete('properties')
278
+            ->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID())))
279
+            ->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($path))));
280
+        $qb->executeStatement();
281
+        unset($this->userCache[$path]);
282
+    }
283
+
284
+    /**
285
+     * This method is called after a successful MOVE
286
+     *
287
+     * @param string $source
288
+     * @param string $destination
289
+     */
290
+    #[Override]
291
+    public function move($source, $destination): void {
292
+        $qb = $this->connection->getQueryBuilder();
293
+        $qb->update('properties')
294
+            ->set('propertypath', $qb->createNamedParameter($this->formatPath($destination)))
295
+            ->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID())))
296
+            ->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($source))));
297
+        $qb->executeStatement();
298
+    }
299
+
300
+    /**
301
+     * Validate the value of a property. Will throw if a value is invalid.
302
+     *
303
+     * @throws DavException The value of the property is invalid
304
+     */
305
+    private function validateProperty(string $path, string $propName, mixed $propValue): void {
306
+        switch ($propName) {
307
+            case '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL':
308
+                /** @var Href $propValue */
309
+                $href = $propValue->getHref();
310
+                if ($href === null) {
311
+                    throw new DavException('Href is empty');
312
+                }
313
+
314
+                // $path is the principal here as this prop is only set on principals
315
+                $node = $this->tree->getNodeForPath($href);
316
+                if (!($node instanceof Calendar) || $node->getOwner() !== $path) {
317
+                    throw new DavException('No such calendar');
318
+                }
319
+
320
+                $this->defaultCalendarValidator->validateScheduleDefaultCalendar($node);
321
+                break;
322
+        }
323
+    }
324
+
325
+    /**
326
+     * @param string[] $requestedProperties
327
+     *
328
+     * @return array<string, mixed|Complex|Href|string>
329
+     * @throws \OCP\DB\Exception
330
+     */
331
+    private function getPublishedProperties(string $path, array $requestedProperties): array {
332
+        $allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
333
+
334
+        if (empty($allowedProps)) {
335
+            return [];
336
+        }
337
+
338
+        if (isset($this->publishedCache[$path])) {
339
+            return $this->publishedCache[$path];
340
+        }
341
+
342
+        $qb = $this->connection->getQueryBuilder();
343
+        $qb->select('*')
344
+            ->from(self::TABLE_NAME)
345
+            ->where($qb->expr()->eq('propertypath', $qb->createNamedParameter($path)));
346
+        $result = $qb->executeQuery();
347
+        $props = [];
348
+        while ($row = $result->fetchAssociative()) {
349
+            $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
350
+        }
351
+        $result->closeCursor();
352
+        $this->publishedCache[$path] = $props;
353
+        return $props;
354
+    }
355
+
356
+    /**
357
+     * Prefetch all user properties in a directory
358
+     */
359
+    private function cacheDirectory(string $path, Directory $node): void {
360
+        $prefix = ltrim($path . '/', '/');
361
+        $query = $this->connection->getQueryBuilder();
362
+        $query->select('name', 'p.propertypath', 'p.propertyname', 'p.propertyvalue', 'p.valuetype')
363
+            ->from('filecache', 'f')
364
+            ->hintShardKey('storage', $node->getNode()->getMountPoint()->getNumericStorageId())
365
+            ->leftJoin('f', 'properties', 'p', $query->expr()->eq('p.propertypath', $query->func()->concat(
366
+                $query->createNamedParameter($prefix),
367
+                'f.name'
368
+            )),
369
+            )
370
+            ->where($query->expr()->eq('parent', $query->createNamedParameter($node->getInternalFileId(), IQueryBuilder::PARAM_INT)))
371
+            ->andWhere($query->expr()->orX(
372
+                $query->expr()->eq('p.userid', $query->createNamedParameter($this->user->getUID())),
373
+                $query->expr()->isNull('p.userid'),
374
+            ));
375
+        $result = $query->executeQuery();
376
+
377
+        $propsByPath = [];
378
+
379
+        while ($row = $result->fetchAssociative()) {
380
+            $childPath = $prefix . $row['name'];
381
+            if (!isset($propsByPath[$childPath])) {
382
+                $propsByPath[$childPath] = [];
383
+            }
384
+            if (isset($row['propertyname'])) {
385
+                $propsByPath[$childPath][$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
386
+            }
387
+        }
388
+        $this->userCache = array_merge($this->userCache, $propsByPath);
389
+    }
390
+
391
+    private function cacheCalendars(CalendarHome $node, array $requestedProperties): void {
392
+        $calendars = $node->getChildren();
393
+
394
+        $users = [];
395
+        foreach ($calendars as $calendar) {
396
+            if ($calendar instanceof Calendar) {
397
+                $user = str_replace('principals/users/', '', $calendar->getPrincipalURI());
398
+                if (!isset($users[$user])) {
399
+                    $users[$user] = ['calendars/' . $user];
400
+                }
401
+                $users[$user][] = 'calendars/' . $user . '/' . $calendar->getUri();
402
+            } elseif ($calendar instanceof Inbox || $calendar instanceof Outbox || $calendar instanceof TrashbinHome || $calendar instanceof ExternalCalendar) {
403
+                if ($calendar->getOwner()) {
404
+                    $user = str_replace('principals/users/', '', $calendar->getOwner());
405
+                    if (!isset($users[$user])) {
406
+                        $users[$user] = ['calendars/' . $user];
407
+                    }
408
+                    $users[$user][] = 'calendars/' . $user . '/' . $calendar->getName();
409
+                }
410
+            }
411
+        }
412
+
413
+        // user properties
414
+        $properties = $this->propertyMapper->findPropertiesByPathsAndUsers($users);
415
+
416
+        $propsByPath = [];
417
+        foreach ($users as $paths) {
418
+            foreach ($paths as $path) {
419
+                $propsByPath[$path] = [];
420
+            }
421
+        }
422
+
423
+        foreach ($properties as $property) {
424
+            $propsByPath[$property->getPropertypath()][$property->getPropertyname()] = $this->decodeValueFromDatabase($property->getPropertyvalue(), $property->getValuetype());
425
+        }
426
+        $this->userCache = array_merge($this->userCache, $propsByPath);
427
+
428
+        // published properties
429
+        $allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
430
+        if (empty($allowedProps)) {
431
+            return;
432
+        }
433
+        $paths = [];
434
+        foreach ($users as $nestedPaths) {
435
+            $paths = array_merge($paths, $nestedPaths);
436
+        }
437
+        $paths = array_unique($paths);
438
+
439
+        $propsByPath = array_fill_keys(array_values($paths), []);
440
+        $properties = $this->propertyMapper->findPropertiesByPaths($paths, $allowedProps);
441
+        foreach ($properties as $property) {
442
+            $propsByPath[$property->getPropertypath()][$property->getPropertyname()] = $this->decodeValueFromDatabase($property->getPropertyvalue(), $property->getValuetype());
443
+        }
444
+        $this->publishedCache = array_merge($this->publishedCache, $propsByPath);
445
+    }
446
+
447
+    /**
448
+     * Returns a list of properties for the given path and current user
449
+     *
450
+     * @param array $requestedProperties requested properties or empty array for "all"
451
+     * @return array<string, mixed>
452
+     * @note The properties list is a list of propertynames the client
453
+     * requested, encoded as xmlnamespace#tagName, for example:
454
+     * http://www.example.org/namespace#author If the array is empty, all
455
+     * properties should be returned
456
+     */
457
+    private function getUserProperties(string $path, array $requestedProperties): array {
458
+        if (isset($this->userCache[$path])) {
459
+            return $this->userCache[$path];
460
+        }
461
+
462
+        $props = [];
463
+
464
+        $qb = $this->connection->getQueryBuilder();
465
+        $qb->select('*')
466
+            ->from('properties')
467
+            ->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID(), IQueryBuilder::PARAM_STR)))
468
+            ->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($path), IQueryBuilder::PARAM_STR)));
469
+
470
+        if (!empty($requestedProperties)) {
471
+            // request only a subset
472
+            $qb->andWhere($qb->expr()->in('propertyname', $qb->createParameter('requestedProperties')));
473
+            $chunks = array_chunk($requestedProperties, 1000);
474
+            foreach ($chunks as $chunk) {
475
+                $qb->setParameter('requestedProperties', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
476
+                $result = $qb->executeQuery();
477
+                while ($row = $result->fetchAssociative()) {
478
+                    $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
479
+                }
480
+            }
481
+        } else {
482
+            $result = $qb->executeQuery();
483
+            while ($row = $result->fetchAssociative()) {
484
+                $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
485
+            }
486
+        }
487
+
488
+        $this->userCache[$path] = $props;
489
+        return $props;
490
+    }
491
+
492
+    private function isPropertyDefaultValue(string $name, mixed $value): bool {
493
+        if (!isset(self::PROPERTY_DEFAULT_VALUES[$name])) {
494
+            return false;
495
+        }
496
+
497
+        return self::PROPERTY_DEFAULT_VALUES[$name] === $value;
498
+    }
499
+
500
+    /**
501
+     * @param array<string, string> $properties
502
+     * @throws Exception
503
+     */
504
+    private function updateProperties(string $path, array $properties): bool {
505
+        // TODO: use "insert or update" strategy ?
506
+        $existing = $this->getUserProperties($path, []);
507
+        try {
508
+            $this->connection->beginTransaction();
509
+            foreach ($properties as $propertyName => $propertyValue) {
510
+                // common parameters for all queries
511
+                $dbParameters = [
512
+                    'userid' => $this->user->getUID(),
513
+                    'propertyPath' => $this->formatPath($path),
514
+                    'propertyName' => $propertyName,
515
+                ];
516
+
517
+                // If it was null or set to the default value, we need to delete the property
518
+                if (is_null($propertyValue) || $this->isPropertyDefaultValue($propertyName, $propertyValue)) {
519
+                    if (array_key_exists($propertyName, $existing)) {
520
+                        $deleteQuery = $deleteQuery ?? $this->createDeleteQuery();
521
+                        $deleteQuery
522
+                            ->setParameters($dbParameters)
523
+                            ->executeStatement();
524
+                    }
525
+                } else {
526
+                    [$value, $valueType] = $this->encodeValueForDatabase(
527
+                        $path,
528
+                        $propertyName,
529
+                        $propertyValue,
530
+                    );
531
+                    $dbParameters['propertyValue'] = $value;
532
+                    $dbParameters['valueType'] = $valueType;
533
+
534
+                    if (!array_key_exists($propertyName, $existing)) {
535
+                        $insertQuery = $insertQuery ?? $this->createInsertQuery();
536
+                        $insertQuery
537
+                            ->setParameters($dbParameters)
538
+                            ->executeStatement();
539
+                    } else {
540
+                        $updateQuery = $updateQuery ?? $this->createUpdateQuery();
541
+                        $updateQuery
542
+                            ->setParameters($dbParameters)
543
+                            ->executeStatement();
544
+                    }
545
+                }
546
+            }
547
+
548
+            $this->connection->commit();
549
+            unset($this->userCache[$path]);
550
+        } catch (Exception $e) {
551
+            $this->connection->rollBack();
552
+            throw $e;
553
+        }
554
+
555
+        return true;
556
+    }
557
+
558
+    /**
559
+     * Long paths are hashed to ensure they fit in the database
560
+     */
561
+    private function formatPath(string $path): string {
562
+        if (strlen($path) > 250) {
563
+            return sha1($path);
564
+        }
565
+
566
+        return $path;
567
+    }
568
+
569
+    private static function checkIsArrayOfScalar(string $name, array $array): void {
570
+        foreach ($array as $item) {
571
+            if (is_array($item)) {
572
+                self::checkIsArrayOfScalar($name, $item);
573
+            } elseif ($item !== null && !is_scalar($item)) {
574
+                throw new DavException(
575
+                    "Property \"$name\" has an invalid value of array containing " . gettype($item),
576
+                );
577
+            }
578
+        }
579
+    }
580
+
581
+    /**
582
+     * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails
583
+     * @throws DavException If the property value is invalid
584
+     */
585
+    private function encodeValueForDatabase(string $path, string $name, mixed $value): array {
586
+        // Try to parse a more specialized property type first
587
+        if ($value instanceof Complex) {
588
+            $xml = $this->xmlService->write($name, [$value], $this->server->getBaseUri());
589
+            $value = $this->xmlService->parse($xml, $this->server->getBaseUri()) ?? $value;
590
+        }
591
+
592
+        if ($name === '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL') {
593
+            $value = $this->encodeDefaultCalendarUrl($value);
594
+        }
595
+
596
+        try {
597
+            $this->validateProperty($path, $name, $value);
598
+        } catch (DavException $e) {
599
+            throw new DavException(
600
+                "Property \"$name\" has an invalid value: " . $e->getMessage(),
601
+                0,
602
+                $e,
603
+            );
604
+        }
605
+
606
+        if (is_scalar($value)) {
607
+            $valueType = self::PROPERTY_TYPE_STRING;
608
+        } elseif ($value instanceof Complex) {
609
+            $valueType = self::PROPERTY_TYPE_XML;
610
+            $value = $value->getXml();
611
+        } elseif ($value instanceof Href) {
612
+            $valueType = self::PROPERTY_TYPE_HREF;
613
+            $value = $value->getHref();
614
+        } else {
615
+            if (is_array($value)) {
616
+                // For array only allow scalar values
617
+                self::checkIsArrayOfScalar($name, $value);
618
+            } elseif (!is_object($value)) {
619
+                throw new DavException(
620
+                    "Property \"$name\" has an invalid value of type " . gettype($value),
621
+                );
622
+            } else {
623
+                if (!str_starts_with($value::class, 'Sabre\\DAV\\Xml\\Property\\')
624
+                    && !str_starts_with($value::class, 'Sabre\\CalDAV\\Xml\\Property\\')
625
+                    && !str_starts_with($value::class, 'Sabre\\CardDAV\\Xml\\Property\\')
626
+                    && !str_starts_with($value::class, 'OCA\\DAV\\')) {
627
+                    throw new DavException(
628
+                        "Property \"$name\" has an invalid value of class " . $value::class,
629
+                    );
630
+                }
631
+            }
632
+            $valueType = self::PROPERTY_TYPE_OBJECT;
633
+            // serialize produces null character
634
+            // these can not be properly stored in some databases and need to be replaced
635
+            $value = str_replace(chr(0), '\x00', serialize($value));
636
+        }
637
+        return [$value, $valueType];
638
+    }
639
+
640
+    /**
641
+     * @return mixed|Complex|string
642
+     */
643
+    private function decodeValueFromDatabase(string $value, int $valueType): mixed {
644
+        switch ($valueType) {
645
+            case self::PROPERTY_TYPE_XML:
646
+                return new Complex($value);
647
+            case self::PROPERTY_TYPE_HREF:
648
+                return new Href($value);
649
+            case self::PROPERTY_TYPE_OBJECT:
650
+                if (preg_match('/^a:/', $value)) {
651
+                    // Array, unserialize only scalar values
652
+                    return unserialize(str_replace('\x00', chr(0), $value), ['allowed_classes' => false]);
653
+                }
654
+                if (!preg_match('/^O\:\d+\:\"(OCA\\\\DAV\\\\|Sabre\\\\(Cal|Card)?DAV\\\\Xml\\\\Property\\\\)/', $value)) {
655
+                    throw new \LogicException('Found an object class serialized in DB that is not allowed');
656
+                }
657
+                // some databases can not handel null characters, these are custom encoded during serialization
658
+                // this custom encoding needs to be first reversed before unserializing
659
+                return unserialize(str_replace('\x00', chr(0), $value));
660
+            default:
661
+                return $value;
662
+        };
663
+    }
664
+
665
+    private function encodeDefaultCalendarUrl(Href $value): Href {
666
+        $href = $value->getHref();
667
+        if ($href === null) {
668
+            return $value;
669
+        }
670
+
671
+        if (!str_starts_with($href, '/')) {
672
+            return $value;
673
+        }
674
+
675
+        try {
676
+            // Build path relative to the dav base URI to be used later to find the node
677
+            $value = new LocalHref($this->server->calculateUri($href) . '/');
678
+        } catch (DavException\Forbidden) {
679
+            // Not existing calendars will be handled later when the value is validated
680
+        }
681
+
682
+        return $value;
683
+    }
684
+
685
+    private function createDeleteQuery(): IQueryBuilder {
686
+        $deleteQuery = $this->connection->getQueryBuilder();
687
+        $deleteQuery->delete('properties')
688
+            ->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid')))
689
+            ->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath')))
690
+            ->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName')));
691
+        return $deleteQuery;
692
+    }
693
+
694
+    private function createInsertQuery(): IQueryBuilder {
695
+        $insertQuery = $this->connection->getQueryBuilder();
696
+        $insertQuery->insert('properties')
697
+            ->values([
698
+                'userid' => $insertQuery->createParameter('userid'),
699
+                'propertypath' => $insertQuery->createParameter('propertyPath'),
700
+                'propertyname' => $insertQuery->createParameter('propertyName'),
701
+                'propertyvalue' => $insertQuery->createParameter('propertyValue'),
702
+                'valuetype' => $insertQuery->createParameter('valueType'),
703
+            ]);
704
+        return $insertQuery;
705
+    }
706
+
707
+    private function createUpdateQuery(): IQueryBuilder {
708
+        $updateQuery = $this->connection->getQueryBuilder();
709
+        $updateQuery->update('properties')
710
+            ->set('propertyvalue', $updateQuery->createParameter('propertyValue'))
711
+            ->set('valuetype', $updateQuery->createParameter('valueType'))
712
+            ->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid')))
713
+            ->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath')))
714
+            ->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName')));
715
+        return $updateQuery;
716
+    }
717 717
 }
Please login to merge, or discard this patch.
Spacing   +12 added lines, -12 removed lines patch added patch discarded remove patch
@@ -261,7 +261,7 @@  discard block
 block discarded – undo
261 261
 	 */
262 262
 	#[Override]
263 263
 	public function propPatch($path, PropPatch $propPatch): void {
264
-		$propPatch->handleRemaining(function (array $changedProps) use ($path) {
264
+		$propPatch->handleRemaining(function(array $changedProps) use ($path) {
265 265
 			return $this->updateProperties($path, $changedProps);
266 266
 		});
267 267
 	}
@@ -357,7 +357,7 @@  discard block
 block discarded – undo
357 357
 	 * Prefetch all user properties in a directory
358 358
 	 */
359 359
 	private function cacheDirectory(string $path, Directory $node): void {
360
-		$prefix = ltrim($path . '/', '/');
360
+		$prefix = ltrim($path.'/', '/');
361 361
 		$query = $this->connection->getQueryBuilder();
362 362
 		$query->select('name', 'p.propertypath', 'p.propertyname', 'p.propertyvalue', 'p.valuetype')
363 363
 			->from('filecache', 'f')
@@ -377,7 +377,7 @@  discard block
 block discarded – undo
377 377
 		$propsByPath = [];
378 378
 
379 379
 		while ($row = $result->fetchAssociative()) {
380
-			$childPath = $prefix . $row['name'];
380
+			$childPath = $prefix.$row['name'];
381 381
 			if (!isset($propsByPath[$childPath])) {
382 382
 				$propsByPath[$childPath] = [];
383 383
 			}
@@ -396,16 +396,16 @@  discard block
 block discarded – undo
396 396
 			if ($calendar instanceof Calendar) {
397 397
 				$user = str_replace('principals/users/', '', $calendar->getPrincipalURI());
398 398
 				if (!isset($users[$user])) {
399
-					$users[$user] = ['calendars/' . $user];
399
+					$users[$user] = ['calendars/'.$user];
400 400
 				}
401
-				$users[$user][] = 'calendars/' . $user . '/' . $calendar->getUri();
401
+				$users[$user][] = 'calendars/'.$user.'/'.$calendar->getUri();
402 402
 			} elseif ($calendar instanceof Inbox || $calendar instanceof Outbox || $calendar instanceof TrashbinHome || $calendar instanceof ExternalCalendar) {
403 403
 				if ($calendar->getOwner()) {
404 404
 					$user = str_replace('principals/users/', '', $calendar->getOwner());
405 405
 					if (!isset($users[$user])) {
406
-						$users[$user] = ['calendars/' . $user];
406
+						$users[$user] = ['calendars/'.$user];
407 407
 					}
408
-					$users[$user][] = 'calendars/' . $user . '/' . $calendar->getName();
408
+					$users[$user][] = 'calendars/'.$user.'/'.$calendar->getName();
409 409
 				}
410 410
 			}
411 411
 		}
@@ -572,7 +572,7 @@  discard block
 block discarded – undo
572 572
 				self::checkIsArrayOfScalar($name, $item);
573 573
 			} elseif ($item !== null && !is_scalar($item)) {
574 574
 				throw new DavException(
575
-					"Property \"$name\" has an invalid value of array containing " . gettype($item),
575
+					"Property \"$name\" has an invalid value of array containing ".gettype($item),
576 576
 				);
577 577
 			}
578 578
 		}
@@ -597,7 +597,7 @@  discard block
 block discarded – undo
597 597
 			$this->validateProperty($path, $name, $value);
598 598
 		} catch (DavException $e) {
599 599
 			throw new DavException(
600
-				"Property \"$name\" has an invalid value: " . $e->getMessage(),
600
+				"Property \"$name\" has an invalid value: ".$e->getMessage(),
601 601
 				0,
602 602
 				$e,
603 603
 			);
@@ -617,7 +617,7 @@  discard block
 block discarded – undo
617 617
 				self::checkIsArrayOfScalar($name, $value);
618 618
 			} elseif (!is_object($value)) {
619 619
 				throw new DavException(
620
-					"Property \"$name\" has an invalid value of type " . gettype($value),
620
+					"Property \"$name\" has an invalid value of type ".gettype($value),
621 621
 				);
622 622
 			} else {
623 623
 				if (!str_starts_with($value::class, 'Sabre\\DAV\\Xml\\Property\\')
@@ -625,7 +625,7 @@  discard block
 block discarded – undo
625 625
 					&& !str_starts_with($value::class, 'Sabre\\CardDAV\\Xml\\Property\\')
626 626
 					&& !str_starts_with($value::class, 'OCA\\DAV\\')) {
627 627
 					throw new DavException(
628
-						"Property \"$name\" has an invalid value of class " . $value::class,
628
+						"Property \"$name\" has an invalid value of class ".$value::class,
629 629
 					);
630 630
 				}
631 631
 			}
@@ -674,7 +674,7 @@  discard block
 block discarded – undo
674 674
 
675 675
 		try {
676 676
 			// Build path relative to the dav base URI to be used later to find the node
677
-			$value = new LocalHref($this->server->calculateUri($href) . '/');
677
+			$value = new LocalHref($this->server->calculateUri($href).'/');
678 678
 		} catch (DavException\Forbidden) {
679 679
 			// Not existing calendars will be handled later when the value is validated
680 680
 		}
Please login to merge, or discard this patch.
apps/dav/lib/DAV/Sharing/SharingMapper.php 1 patch
Indentation   +237 added lines, -237 removed lines patch added patch discarded remove patch
@@ -11,241 +11,241 @@
 block discarded – undo
11 11
 use OCP\IDBConnection;
12 12
 
13 13
 class SharingMapper {
14
-	public function __construct(
15
-		private IDBConnection $db,
16
-	) {
17
-	}
18
-
19
-	protected function getSharesForIdByAccess(int $resourceId, string $resourceType, bool $sharesWithAccess): array {
20
-		$query = $this->db->getQueryBuilder();
21
-		$query->select(['principaluri', 'access'])
22
-			->from('dav_shares')
23
-			->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT)))
24
-			->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType, IQueryBuilder::PARAM_STR)))
25
-			->groupBy(['principaluri', 'access']);
26
-
27
-		if ($sharesWithAccess) {
28
-			$query->andWhere($query->expr()->neq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT)));
29
-		} else {
30
-			$query->andWhere($query->expr()->eq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT)));
31
-		}
32
-
33
-		$result = $query->executeQuery();
34
-		$rows = $result->fetchAllAssociative();
35
-		$result->closeCursor();
36
-		return $rows;
37
-	}
38
-
39
-	public function getSharesForId(int $resourceId, string $resourceType): array {
40
-		return $this->getSharesForIdByAccess($resourceId, $resourceType, true);
41
-	}
42
-
43
-	public function getUnsharesForId(int $resourceId, string $resourceType): array {
44
-		return $this->getSharesForIdByAccess($resourceId, $resourceType, false);
45
-	}
46
-
47
-	public function getSharesForIds(array $resourceIds, string $resourceType): array {
48
-		$query = $this->db->getQueryBuilder();
49
-		$result = $query->select(['resourceid', 'principaluri', 'access'])
50
-			->from('dav_shares')
51
-			->where($query->expr()->in('resourceid', $query->createNamedParameter($resourceIds, IQueryBuilder::PARAM_INT_ARRAY)))
52
-			->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
53
-			->andWhere($query->expr()->neq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT)))
54
-			->groupBy(['principaluri', 'access', 'resourceid'])
55
-			->executeQuery();
56
-
57
-		$rows = $result->fetchAllAssociative();
58
-		$result->closeCursor();
59
-		return $rows;
60
-	}
61
-
62
-	public function unshare(int $resourceId, string $resourceType, string $principal): void {
63
-		$query = $this->db->getQueryBuilder();
64
-		$query->insert('dav_shares')
65
-			->values([
66
-				'principaluri' => $query->createNamedParameter($principal),
67
-				'type' => $query->createNamedParameter($resourceType),
68
-				'access' => $query->createNamedParameter(Backend::ACCESS_UNSHARED),
69
-				'resourceid' => $query->createNamedParameter($resourceId)
70
-			]);
71
-		$query->executeStatement();
72
-	}
73
-
74
-	public function share(int $resourceId, string $resourceType, int $access, string $principal): void {
75
-		$query = $this->db->getQueryBuilder();
76
-		$query->insert('dav_shares')
77
-			->values([
78
-				'principaluri' => $query->createNamedParameter($principal),
79
-				'type' => $query->createNamedParameter($resourceType),
80
-				'access' => $query->createNamedParameter($access),
81
-				'resourceid' => $query->createNamedParameter($resourceId)
82
-			]);
83
-		$query->executeStatement();
84
-	}
85
-
86
-	public function shareWithToken(int $resourceId, string $resourceType, int $access, string $principal, string $token): void {
87
-		$query = $this->db->getQueryBuilder();
88
-		$query->insert('dav_shares')
89
-			->values([
90
-				'principaluri' => $query->createNamedParameter($principal),
91
-				'type' => $query->createNamedParameter($resourceType),
92
-				'access' => $query->createNamedParameter($access),
93
-				'resourceid' => $query->createNamedParameter($resourceId),
94
-				'token' => $query->createNamedParameter($token),
95
-			]);
96
-		$query->executeStatement();
97
-	}
98
-
99
-	public function deleteShare(int $resourceId, string $resourceType, string $principal): void {
100
-		$query = $this->db->getQueryBuilder();
101
-		$query->delete('dav_shares');
102
-		$query->where(
103
-			$query->expr()->eq('resourceid', $query->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT)),
104
-			$query->expr()->eq('type', $query->createNamedParameter($resourceType)),
105
-			$query->expr()->eq('principaluri', $query->createNamedParameter($principal))
106
-		);
107
-		$query->executeStatement();
108
-
109
-	}
110
-
111
-	public function deleteAllShares(int $resourceId, string $resourceType): void {
112
-		$query = $this->db->getQueryBuilder();
113
-		$query->delete('dav_shares')
114
-			->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId)))
115
-			->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
116
-			->executeStatement();
117
-	}
118
-
119
-	public function deleteAllSharesByUser(string $principaluri, string $resourceType): void {
120
-		$query = $this->db->getQueryBuilder();
121
-		$query->delete('dav_shares')
122
-			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principaluri)))
123
-			->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
124
-			->executeStatement();
125
-	}
126
-
127
-	public function getSharesByPrincipals(array $principals, string $resourceType): array {
128
-		$query = $this->db->getQueryBuilder();
129
-		$result = $query->select(['id', 'principaluri', 'type', 'access', 'resourceid'])
130
-			->from('dav_shares')
131
-			->where($query->expr()->in('principaluri', $query->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY))
132
-			->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
133
-			->orderBy('id')
134
-			->executeQuery();
135
-
136
-		$rows = $result->fetchAllAssociative();
137
-		$result->closeCursor();
138
-
139
-		return $rows;
140
-	}
141
-
142
-	public function deleteUnsharesByPrincipal(string $principal, string $resourceType): void {
143
-		$query = $this->db->getQueryBuilder();
144
-		$query->delete('dav_shares')
145
-			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
146
-			->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
147
-			->andWhere($query->expr()->eq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT)))
148
-			->executeStatement();
149
-	}
150
-
151
-	/**
152
-	 * @return list<array{principaluri: string}>
153
-	 * @throws \OCP\DB\Exception
154
-	 */
155
-	public function getPrincipalUrisByPrefix(string $resourceType, string $prefix): array {
156
-		$query = $this->db->getQueryBuilder();
157
-		$result = $query->selectDistinct('principaluri')
158
-			->from('dav_shares')
159
-			->where($query->expr()->like(
160
-				'principaluri',
161
-				$query->createNamedParameter("$prefix/%", IQueryBuilder::PARAM_STR),
162
-				IQueryBuilder::PARAM_STR,
163
-			))
164
-			->andWhere($query->expr()->eq(
165
-				'type',
166
-				$query->createNamedParameter($resourceType, IQueryBuilder::PARAM_STR)),
167
-				IQueryBuilder::PARAM_STR,
168
-			)
169
-			->executeQuery();
170
-
171
-		/** @var list<array{principaluri: string}> $rows */
172
-		$rows = $result->fetchAllAssociative();
173
-		$result->closeCursor();
174
-
175
-		return $rows;
176
-	}
177
-
178
-	/**
179
-	 * @psalm-return list<array{uri: string, principaluri: string}>
180
-	 * @throws \OCP\DB\Exception
181
-	 */
182
-	public function getSharedCalendarsForRemoteUser(
183
-		string $remoteUserPrincipalUri,
184
-		string $token,
185
-	): array {
186
-		$qb = $this->db->getQueryBuilder();
187
-		$qb->select('c.uri', 'c.principaluri')
188
-			->from('dav_shares', 'ds')
189
-			->join('ds', 'calendars', 'c', $qb->expr()->eq(
190
-				'ds.resourceid',
191
-				'c.id',
192
-				IQueryBuilder::PARAM_INT,
193
-			))
194
-			->where($qb->expr()->eq(
195
-				'ds.type',
196
-				$qb->createNamedParameter('calendar', IQueryBuilder::PARAM_STR),
197
-				IQueryBuilder::PARAM_STR,
198
-			))
199
-			->andWhere($qb->expr()->eq(
200
-				'ds.principaluri',
201
-				$qb->createNamedParameter($remoteUserPrincipalUri, IQueryBuilder::PARAM_STR),
202
-				IQueryBuilder::PARAM_STR,
203
-			))
204
-			->andWhere($qb->expr()->eq(
205
-				'ds.token',
206
-				$qb->createNamedParameter($token, IQueryBuilder::PARAM_STR),
207
-				IQueryBuilder::PARAM_STR,
208
-			));
209
-		$result = $qb->executeQuery();
210
-		/** @var list<array{uri: string, principaluri: string}> $rows */
211
-		$rows = $result->fetchAllAssociative();
212
-		$result->closeCursor();
213
-
214
-		return $rows;
215
-	}
216
-
217
-	/**
218
-	 * @param string[] $principalUris
219
-	 *
220
-	 * @throws \OCP\DB\Exception
221
-	 */
222
-	public function getSharesByPrincipalsAndResource(
223
-		array $principalUris,
224
-		int $resourceId,
225
-		string $resourceType,
226
-	): array {
227
-		$qb = $this->db->getQueryBuilder();
228
-		$qb->select('*')
229
-			->from('dav_shares')
230
-			->where($qb->expr()->in(
231
-				'principaluri',
232
-				$qb->createNamedParameter($principalUris, IQueryBuilder::PARAM_STR_ARRAY),
233
-				IQueryBuilder::PARAM_STR_ARRAY,
234
-			))
235
-			->andWhere($qb->expr()->eq(
236
-				'resourceid',
237
-				$qb->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT),
238
-				IQueryBuilder::PARAM_INT,
239
-			))
240
-			->andWhere($qb->expr()->eq(
241
-				'type',
242
-				$qb->createNamedParameter($resourceType, IQueryBuilder::PARAM_STR),
243
-				IQueryBuilder::PARAM_STR,
244
-			));
245
-		$result = $qb->executeQuery();
246
-		$rows = $result->fetchAllAssociative();
247
-		$result->closeCursor();
248
-
249
-		return $rows;
250
-	}
14
+    public function __construct(
15
+        private IDBConnection $db,
16
+    ) {
17
+    }
18
+
19
+    protected function getSharesForIdByAccess(int $resourceId, string $resourceType, bool $sharesWithAccess): array {
20
+        $query = $this->db->getQueryBuilder();
21
+        $query->select(['principaluri', 'access'])
22
+            ->from('dav_shares')
23
+            ->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT)))
24
+            ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType, IQueryBuilder::PARAM_STR)))
25
+            ->groupBy(['principaluri', 'access']);
26
+
27
+        if ($sharesWithAccess) {
28
+            $query->andWhere($query->expr()->neq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT)));
29
+        } else {
30
+            $query->andWhere($query->expr()->eq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT)));
31
+        }
32
+
33
+        $result = $query->executeQuery();
34
+        $rows = $result->fetchAllAssociative();
35
+        $result->closeCursor();
36
+        return $rows;
37
+    }
38
+
39
+    public function getSharesForId(int $resourceId, string $resourceType): array {
40
+        return $this->getSharesForIdByAccess($resourceId, $resourceType, true);
41
+    }
42
+
43
+    public function getUnsharesForId(int $resourceId, string $resourceType): array {
44
+        return $this->getSharesForIdByAccess($resourceId, $resourceType, false);
45
+    }
46
+
47
+    public function getSharesForIds(array $resourceIds, string $resourceType): array {
48
+        $query = $this->db->getQueryBuilder();
49
+        $result = $query->select(['resourceid', 'principaluri', 'access'])
50
+            ->from('dav_shares')
51
+            ->where($query->expr()->in('resourceid', $query->createNamedParameter($resourceIds, IQueryBuilder::PARAM_INT_ARRAY)))
52
+            ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
53
+            ->andWhere($query->expr()->neq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT)))
54
+            ->groupBy(['principaluri', 'access', 'resourceid'])
55
+            ->executeQuery();
56
+
57
+        $rows = $result->fetchAllAssociative();
58
+        $result->closeCursor();
59
+        return $rows;
60
+    }
61
+
62
+    public function unshare(int $resourceId, string $resourceType, string $principal): void {
63
+        $query = $this->db->getQueryBuilder();
64
+        $query->insert('dav_shares')
65
+            ->values([
66
+                'principaluri' => $query->createNamedParameter($principal),
67
+                'type' => $query->createNamedParameter($resourceType),
68
+                'access' => $query->createNamedParameter(Backend::ACCESS_UNSHARED),
69
+                'resourceid' => $query->createNamedParameter($resourceId)
70
+            ]);
71
+        $query->executeStatement();
72
+    }
73
+
74
+    public function share(int $resourceId, string $resourceType, int $access, string $principal): void {
75
+        $query = $this->db->getQueryBuilder();
76
+        $query->insert('dav_shares')
77
+            ->values([
78
+                'principaluri' => $query->createNamedParameter($principal),
79
+                'type' => $query->createNamedParameter($resourceType),
80
+                'access' => $query->createNamedParameter($access),
81
+                'resourceid' => $query->createNamedParameter($resourceId)
82
+            ]);
83
+        $query->executeStatement();
84
+    }
85
+
86
+    public function shareWithToken(int $resourceId, string $resourceType, int $access, string $principal, string $token): void {
87
+        $query = $this->db->getQueryBuilder();
88
+        $query->insert('dav_shares')
89
+            ->values([
90
+                'principaluri' => $query->createNamedParameter($principal),
91
+                'type' => $query->createNamedParameter($resourceType),
92
+                'access' => $query->createNamedParameter($access),
93
+                'resourceid' => $query->createNamedParameter($resourceId),
94
+                'token' => $query->createNamedParameter($token),
95
+            ]);
96
+        $query->executeStatement();
97
+    }
98
+
99
+    public function deleteShare(int $resourceId, string $resourceType, string $principal): void {
100
+        $query = $this->db->getQueryBuilder();
101
+        $query->delete('dav_shares');
102
+        $query->where(
103
+            $query->expr()->eq('resourceid', $query->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT)),
104
+            $query->expr()->eq('type', $query->createNamedParameter($resourceType)),
105
+            $query->expr()->eq('principaluri', $query->createNamedParameter($principal))
106
+        );
107
+        $query->executeStatement();
108
+
109
+    }
110
+
111
+    public function deleteAllShares(int $resourceId, string $resourceType): void {
112
+        $query = $this->db->getQueryBuilder();
113
+        $query->delete('dav_shares')
114
+            ->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId)))
115
+            ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
116
+            ->executeStatement();
117
+    }
118
+
119
+    public function deleteAllSharesByUser(string $principaluri, string $resourceType): void {
120
+        $query = $this->db->getQueryBuilder();
121
+        $query->delete('dav_shares')
122
+            ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principaluri)))
123
+            ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
124
+            ->executeStatement();
125
+    }
126
+
127
+    public function getSharesByPrincipals(array $principals, string $resourceType): array {
128
+        $query = $this->db->getQueryBuilder();
129
+        $result = $query->select(['id', 'principaluri', 'type', 'access', 'resourceid'])
130
+            ->from('dav_shares')
131
+            ->where($query->expr()->in('principaluri', $query->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY))
132
+            ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
133
+            ->orderBy('id')
134
+            ->executeQuery();
135
+
136
+        $rows = $result->fetchAllAssociative();
137
+        $result->closeCursor();
138
+
139
+        return $rows;
140
+    }
141
+
142
+    public function deleteUnsharesByPrincipal(string $principal, string $resourceType): void {
143
+        $query = $this->db->getQueryBuilder();
144
+        $query->delete('dav_shares')
145
+            ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
146
+            ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
147
+            ->andWhere($query->expr()->eq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT)))
148
+            ->executeStatement();
149
+    }
150
+
151
+    /**
152
+     * @return list<array{principaluri: string}>
153
+     * @throws \OCP\DB\Exception
154
+     */
155
+    public function getPrincipalUrisByPrefix(string $resourceType, string $prefix): array {
156
+        $query = $this->db->getQueryBuilder();
157
+        $result = $query->selectDistinct('principaluri')
158
+            ->from('dav_shares')
159
+            ->where($query->expr()->like(
160
+                'principaluri',
161
+                $query->createNamedParameter("$prefix/%", IQueryBuilder::PARAM_STR),
162
+                IQueryBuilder::PARAM_STR,
163
+            ))
164
+            ->andWhere($query->expr()->eq(
165
+                'type',
166
+                $query->createNamedParameter($resourceType, IQueryBuilder::PARAM_STR)),
167
+                IQueryBuilder::PARAM_STR,
168
+            )
169
+            ->executeQuery();
170
+
171
+        /** @var list<array{principaluri: string}> $rows */
172
+        $rows = $result->fetchAllAssociative();
173
+        $result->closeCursor();
174
+
175
+        return $rows;
176
+    }
177
+
178
+    /**
179
+     * @psalm-return list<array{uri: string, principaluri: string}>
180
+     * @throws \OCP\DB\Exception
181
+     */
182
+    public function getSharedCalendarsForRemoteUser(
183
+        string $remoteUserPrincipalUri,
184
+        string $token,
185
+    ): array {
186
+        $qb = $this->db->getQueryBuilder();
187
+        $qb->select('c.uri', 'c.principaluri')
188
+            ->from('dav_shares', 'ds')
189
+            ->join('ds', 'calendars', 'c', $qb->expr()->eq(
190
+                'ds.resourceid',
191
+                'c.id',
192
+                IQueryBuilder::PARAM_INT,
193
+            ))
194
+            ->where($qb->expr()->eq(
195
+                'ds.type',
196
+                $qb->createNamedParameter('calendar', IQueryBuilder::PARAM_STR),
197
+                IQueryBuilder::PARAM_STR,
198
+            ))
199
+            ->andWhere($qb->expr()->eq(
200
+                'ds.principaluri',
201
+                $qb->createNamedParameter($remoteUserPrincipalUri, IQueryBuilder::PARAM_STR),
202
+                IQueryBuilder::PARAM_STR,
203
+            ))
204
+            ->andWhere($qb->expr()->eq(
205
+                'ds.token',
206
+                $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR),
207
+                IQueryBuilder::PARAM_STR,
208
+            ));
209
+        $result = $qb->executeQuery();
210
+        /** @var list<array{uri: string, principaluri: string}> $rows */
211
+        $rows = $result->fetchAllAssociative();
212
+        $result->closeCursor();
213
+
214
+        return $rows;
215
+    }
216
+
217
+    /**
218
+     * @param string[] $principalUris
219
+     *
220
+     * @throws \OCP\DB\Exception
221
+     */
222
+    public function getSharesByPrincipalsAndResource(
223
+        array $principalUris,
224
+        int $resourceId,
225
+        string $resourceType,
226
+    ): array {
227
+        $qb = $this->db->getQueryBuilder();
228
+        $qb->select('*')
229
+            ->from('dav_shares')
230
+            ->where($qb->expr()->in(
231
+                'principaluri',
232
+                $qb->createNamedParameter($principalUris, IQueryBuilder::PARAM_STR_ARRAY),
233
+                IQueryBuilder::PARAM_STR_ARRAY,
234
+            ))
235
+            ->andWhere($qb->expr()->eq(
236
+                'resourceid',
237
+                $qb->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT),
238
+                IQueryBuilder::PARAM_INT,
239
+            ))
240
+            ->andWhere($qb->expr()->eq(
241
+                'type',
242
+                $qb->createNamedParameter($resourceType, IQueryBuilder::PARAM_STR),
243
+                IQueryBuilder::PARAM_STR,
244
+            ));
245
+        $result = $qb->executeQuery();
246
+        $rows = $result->fetchAllAssociative();
247
+        $result->closeCursor();
248
+
249
+        return $rows;
250
+    }
251 251
 }
Please login to merge, or discard this patch.
apps/dav/lib/BackgroundJob/BuildReminderIndexBackgroundJob.php 2 patches
Indentation   +74 added lines, -74 removed lines patch added patch discarded remove patch
@@ -22,78 +22,78 @@
 block discarded – undo
22 22
  */
23 23
 class BuildReminderIndexBackgroundJob extends QueuedJob {
24 24
 
25
-	/** @var ITimeFactory */
26
-	private $timeFactory;
27
-
28
-	/**
29
-	 * BuildReminderIndexBackgroundJob constructor.
30
-	 */
31
-	public function __construct(
32
-		private IDBConnection $db,
33
-		private ReminderService $reminderService,
34
-		private LoggerInterface $logger,
35
-		private IJobList $jobList,
36
-		ITimeFactory $timeFactory,
37
-	) {
38
-		parent::__construct($timeFactory);
39
-		$this->timeFactory = $timeFactory;
40
-	}
41
-
42
-	public function run($argument) {
43
-		$offset = (int)$argument['offset'];
44
-		$stopAt = (int)$argument['stopAt'];
45
-
46
-		$this->logger->info('Building calendar reminder index (' . $offset . '/' . $stopAt . ')');
47
-
48
-		$offset = $this->buildIndex($offset, $stopAt);
49
-
50
-		if ($offset >= $stopAt) {
51
-			$this->logger->info('Building calendar reminder index done');
52
-		} else {
53
-			$this->jobList->add(self::class, [
54
-				'offset' => $offset,
55
-				'stopAt' => $stopAt
56
-			]);
57
-			$this->logger->info('Scheduled a new BuildReminderIndexBackgroundJob with offset ' . $offset);
58
-		}
59
-	}
60
-
61
-	/**
62
-	 * @param int $offset
63
-	 * @param int $stopAt
64
-	 * @return int
65
-	 */
66
-	private function buildIndex(int $offset, int $stopAt):int {
67
-		$startTime = $this->timeFactory->getTime();
68
-
69
-		$query = $this->db->getQueryBuilder();
70
-		$query->select('*')
71
-			->from('calendarobjects')
72
-			->where($query->expr()->lte('id', $query->createNamedParameter($stopAt)))
73
-			->andWhere($query->expr()->gt('id', $query->createNamedParameter($offset)))
74
-			->orderBy('id', 'ASC');
75
-
76
-		$result = $query->executeQuery();
77
-		while ($row = $result->fetchAssociative()) {
78
-			$offset = (int)$row['id'];
79
-			if (is_resource($row['calendardata'])) {
80
-				$row['calendardata'] = stream_get_contents($row['calendardata']);
81
-			}
82
-			$row['component'] = $row['componenttype'];
83
-
84
-			try {
85
-				$this->reminderService->onCalendarObjectCreate($row);
86
-			} catch (\Exception $ex) {
87
-				$this->logger->error($ex->getMessage(), ['exception' => $ex]);
88
-			}
89
-
90
-			if (($this->timeFactory->getTime() - $startTime) > 15) {
91
-				$result->closeCursor();
92
-				return $offset;
93
-			}
94
-		}
95
-
96
-		$result->closeCursor();
97
-		return $stopAt;
98
-	}
25
+    /** @var ITimeFactory */
26
+    private $timeFactory;
27
+
28
+    /**
29
+     * BuildReminderIndexBackgroundJob constructor.
30
+     */
31
+    public function __construct(
32
+        private IDBConnection $db,
33
+        private ReminderService $reminderService,
34
+        private LoggerInterface $logger,
35
+        private IJobList $jobList,
36
+        ITimeFactory $timeFactory,
37
+    ) {
38
+        parent::__construct($timeFactory);
39
+        $this->timeFactory = $timeFactory;
40
+    }
41
+
42
+    public function run($argument) {
43
+        $offset = (int)$argument['offset'];
44
+        $stopAt = (int)$argument['stopAt'];
45
+
46
+        $this->logger->info('Building calendar reminder index (' . $offset . '/' . $stopAt . ')');
47
+
48
+        $offset = $this->buildIndex($offset, $stopAt);
49
+
50
+        if ($offset >= $stopAt) {
51
+            $this->logger->info('Building calendar reminder index done');
52
+        } else {
53
+            $this->jobList->add(self::class, [
54
+                'offset' => $offset,
55
+                'stopAt' => $stopAt
56
+            ]);
57
+            $this->logger->info('Scheduled a new BuildReminderIndexBackgroundJob with offset ' . $offset);
58
+        }
59
+    }
60
+
61
+    /**
62
+     * @param int $offset
63
+     * @param int $stopAt
64
+     * @return int
65
+     */
66
+    private function buildIndex(int $offset, int $stopAt):int {
67
+        $startTime = $this->timeFactory->getTime();
68
+
69
+        $query = $this->db->getQueryBuilder();
70
+        $query->select('*')
71
+            ->from('calendarobjects')
72
+            ->where($query->expr()->lte('id', $query->createNamedParameter($stopAt)))
73
+            ->andWhere($query->expr()->gt('id', $query->createNamedParameter($offset)))
74
+            ->orderBy('id', 'ASC');
75
+
76
+        $result = $query->executeQuery();
77
+        while ($row = $result->fetchAssociative()) {
78
+            $offset = (int)$row['id'];
79
+            if (is_resource($row['calendardata'])) {
80
+                $row['calendardata'] = stream_get_contents($row['calendardata']);
81
+            }
82
+            $row['component'] = $row['componenttype'];
83
+
84
+            try {
85
+                $this->reminderService->onCalendarObjectCreate($row);
86
+            } catch (\Exception $ex) {
87
+                $this->logger->error($ex->getMessage(), ['exception' => $ex]);
88
+            }
89
+
90
+            if (($this->timeFactory->getTime() - $startTime) > 15) {
91
+                $result->closeCursor();
92
+                return $offset;
93
+            }
94
+        }
95
+
96
+        $result->closeCursor();
97
+        return $stopAt;
98
+    }
99 99
 }
Please login to merge, or discard this patch.
Spacing   +5 added lines, -5 removed lines patch added patch discarded remove patch
@@ -40,10 +40,10 @@  discard block
 block discarded – undo
40 40
 	}
41 41
 
42 42
 	public function run($argument) {
43
-		$offset = (int)$argument['offset'];
44
-		$stopAt = (int)$argument['stopAt'];
43
+		$offset = (int) $argument['offset'];
44
+		$stopAt = (int) $argument['stopAt'];
45 45
 
46
-		$this->logger->info('Building calendar reminder index (' . $offset . '/' . $stopAt . ')');
46
+		$this->logger->info('Building calendar reminder index ('.$offset.'/'.$stopAt.')');
47 47
 
48 48
 		$offset = $this->buildIndex($offset, $stopAt);
49 49
 
@@ -54,7 +54,7 @@  discard block
 block discarded – undo
54 54
 				'offset' => $offset,
55 55
 				'stopAt' => $stopAt
56 56
 			]);
57
-			$this->logger->info('Scheduled a new BuildReminderIndexBackgroundJob with offset ' . $offset);
57
+			$this->logger->info('Scheduled a new BuildReminderIndexBackgroundJob with offset '.$offset);
58 58
 		}
59 59
 	}
60 60
 
@@ -75,7 +75,7 @@  discard block
 block discarded – undo
75 75
 
76 76
 		$result = $query->executeQuery();
77 77
 		while ($row = $result->fetchAssociative()) {
78
-			$offset = (int)$row['id'];
78
+			$offset = (int) $row['id'];
79 79
 			if (is_resource($row['calendardata'])) {
80 80
 				$row['calendardata'] = stream_get_contents($row['calendardata']);
81 81
 			}
Please login to merge, or discard this patch.
apps/dav/lib/BackgroundJob/CleanupOrphanedChildrenJob.php 1 patch
Indentation   +68 added lines, -68 removed lines patch added patch discarded remove patch
@@ -18,72 +18,72 @@
 block discarded – undo
18 18
 use Psr\Log\LoggerInterface;
19 19
 
20 20
 class CleanupOrphanedChildrenJob extends QueuedJob {
21
-	public const ARGUMENT_CHILD_TABLE = 'childTable';
22
-	public const ARGUMENT_PARENT_TABLE = 'parentTable';
23
-	public const ARGUMENT_PARENT_ID = 'parentId';
24
-	public const ARGUMENT_LOG_MESSAGE = 'logMessage';
25
-
26
-	private const BATCH_SIZE = 1000;
27
-
28
-	public function __construct(
29
-		ITimeFactory $time,
30
-		private readonly IDBConnection $connection,
31
-		private readonly LoggerInterface $logger,
32
-		private readonly IJobList $jobList,
33
-	) {
34
-		parent::__construct($time);
35
-	}
36
-
37
-	protected function run($argument): void {
38
-		$childTable = $argument[self::ARGUMENT_CHILD_TABLE];
39
-		$parentTable = $argument[self::ARGUMENT_PARENT_TABLE];
40
-		$parentId = $argument[self::ARGUMENT_PARENT_ID];
41
-		$logMessage = $argument[self::ARGUMENT_LOG_MESSAGE];
42
-
43
-		$orphanCount = $this->cleanUpOrphans($childTable, $parentTable, $parentId);
44
-		$this->logger->debug(sprintf($logMessage, $orphanCount));
45
-
46
-		// Requeue if there might be more orphans
47
-		if ($orphanCount >= self::BATCH_SIZE) {
48
-			$this->jobList->add(self::class, $argument);
49
-		}
50
-	}
51
-
52
-	private function cleanUpOrphans(
53
-		string $childTable,
54
-		string $parentTable,
55
-		string $parentId,
56
-	): int {
57
-		// We can't merge both queries into a single one here as DELETEing from a table while
58
-		// SELECTing it in a sub query is not supported by Oracle DB.
59
-		// Ref https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/delete.html#idm46006185488144
60
-
61
-		$selectQb = $this->connection->getQueryBuilder();
62
-
63
-		$selectQb->select('c.id')
64
-			->from($childTable, 'c')
65
-			->leftJoin('c', $parentTable, 'p', $selectQb->expr()->eq('c.' . $parentId, 'p.id'))
66
-			->where($selectQb->expr()->isNull('p.id'))
67
-			->setMaxResults(self::BATCH_SIZE);
68
-
69
-		if (\in_array($parentTable, ['calendars', 'calendarsubscriptions'], true)) {
70
-			$calendarType = $parentTable === 'calendarsubscriptions' ? CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION : CalDavBackend::CALENDAR_TYPE_CALENDAR;
71
-			$selectQb->andWhere($selectQb->expr()->eq('c.calendartype', $selectQb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT));
72
-		}
73
-
74
-		$result = $selectQb->executeQuery();
75
-		$rows = $result->fetchAllAssociative();
76
-		$result->closeCursor();
77
-		if (empty($rows)) {
78
-			return 0;
79
-		}
80
-
81
-		$orphanItems = array_map(static fn ($row) => $row['id'], $rows);
82
-		$deleteQb = $this->connection->getQueryBuilder();
83
-		$deleteQb->delete($childTable)
84
-			->where($deleteQb->expr()->in('id', $deleteQb->createNamedParameter($orphanItems, IQueryBuilder::PARAM_INT_ARRAY)));
85
-		$deleteQb->executeStatement();
86
-
87
-		return count($orphanItems);
88
-	}
21
+    public const ARGUMENT_CHILD_TABLE = 'childTable';
22
+    public const ARGUMENT_PARENT_TABLE = 'parentTable';
23
+    public const ARGUMENT_PARENT_ID = 'parentId';
24
+    public const ARGUMENT_LOG_MESSAGE = 'logMessage';
25
+
26
+    private const BATCH_SIZE = 1000;
27
+
28
+    public function __construct(
29
+        ITimeFactory $time,
30
+        private readonly IDBConnection $connection,
31
+        private readonly LoggerInterface $logger,
32
+        private readonly IJobList $jobList,
33
+    ) {
34
+        parent::__construct($time);
35
+    }
36
+
37
+    protected function run($argument): void {
38
+        $childTable = $argument[self::ARGUMENT_CHILD_TABLE];
39
+        $parentTable = $argument[self::ARGUMENT_PARENT_TABLE];
40
+        $parentId = $argument[self::ARGUMENT_PARENT_ID];
41
+        $logMessage = $argument[self::ARGUMENT_LOG_MESSAGE];
42
+
43
+        $orphanCount = $this->cleanUpOrphans($childTable, $parentTable, $parentId);
44
+        $this->logger->debug(sprintf($logMessage, $orphanCount));
45
+
46
+        // Requeue if there might be more orphans
47
+        if ($orphanCount >= self::BATCH_SIZE) {
48
+            $this->jobList->add(self::class, $argument);
49
+        }
50
+    }
51
+
52
+    private function cleanUpOrphans(
53
+        string $childTable,
54
+        string $parentTable,
55
+        string $parentId,
56
+    ): int {
57
+        // We can't merge both queries into a single one here as DELETEing from a table while
58
+        // SELECTing it in a sub query is not supported by Oracle DB.
59
+        // Ref https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/delete.html#idm46006185488144
60
+
61
+        $selectQb = $this->connection->getQueryBuilder();
62
+
63
+        $selectQb->select('c.id')
64
+            ->from($childTable, 'c')
65
+            ->leftJoin('c', $parentTable, 'p', $selectQb->expr()->eq('c.' . $parentId, 'p.id'))
66
+            ->where($selectQb->expr()->isNull('p.id'))
67
+            ->setMaxResults(self::BATCH_SIZE);
68
+
69
+        if (\in_array($parentTable, ['calendars', 'calendarsubscriptions'], true)) {
70
+            $calendarType = $parentTable === 'calendarsubscriptions' ? CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION : CalDavBackend::CALENDAR_TYPE_CALENDAR;
71
+            $selectQb->andWhere($selectQb->expr()->eq('c.calendartype', $selectQb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT));
72
+        }
73
+
74
+        $result = $selectQb->executeQuery();
75
+        $rows = $result->fetchAllAssociative();
76
+        $result->closeCursor();
77
+        if (empty($rows)) {
78
+            return 0;
79
+        }
80
+
81
+        $orphanItems = array_map(static fn ($row) => $row['id'], $rows);
82
+        $deleteQb = $this->connection->getQueryBuilder();
83
+        $deleteQb->delete($childTable)
84
+            ->where($deleteQb->expr()->in('id', $deleteQb->createNamedParameter($orphanItems, IQueryBuilder::PARAM_INT_ARRAY)));
85
+        $deleteQb->executeStatement();
86
+
87
+        return count($orphanItems);
88
+    }
89 89
 }
Please login to merge, or discard this patch.
apps/dav/lib/Controller/InvitationResponseController.php 1 patch
Indentation   +163 added lines, -163 removed lines patch added patch discarded remove patch
@@ -23,160 +23,160 @@  discard block
 block discarded – undo
23 23
 #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
24 24
 class InvitationResponseController extends Controller {
25 25
 
26
-	/**
27
-	 * InvitationResponseController constructor.
28
-	 *
29
-	 * @param string $appName
30
-	 * @param IRequest $request
31
-	 * @param IDBConnection $db
32
-	 * @param ITimeFactory $timeFactory
33
-	 * @param InvitationResponseServer $responseServer
34
-	 */
35
-	public function __construct(
36
-		string $appName,
37
-		IRequest $request,
38
-		private IDBConnection $db,
39
-		private ITimeFactory $timeFactory,
40
-		private InvitationResponseServer $responseServer,
41
-	) {
42
-		parent::__construct($appName, $request);
43
-		// Don't run `$server->exec()`, because we just need access to the
44
-		// fully initialized schedule plugin, but we don't want Sabre/DAV
45
-		// to actually handle and reply to the request
46
-	}
47
-
48
-	/**
49
-	 * @param string $token
50
-	 * @return TemplateResponse
51
-	 */
52
-	#[PublicPage]
53
-	#[NoCSRFRequired]
54
-	public function accept(string $token):TemplateResponse {
55
-		$row = $this->getTokenInformation($token);
56
-		if (!$row) {
57
-			return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest');
58
-		}
59
-
60
-		$iTipMessage = $this->buildITipResponse($row, 'ACCEPTED');
61
-		$this->responseServer->handleITipMessage($iTipMessage);
62
-		if ($iTipMessage->getScheduleStatus() === '1.2') {
63
-			return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest');
64
-		}
65
-
66
-		return new TemplateResponse($this->appName, 'schedule-response-error', [
67
-			'organizer' => $row['organizer'],
68
-		], 'guest');
69
-	}
70
-
71
-	/**
72
-	 * @param string $token
73
-	 * @return TemplateResponse
74
-	 */
75
-	#[PublicPage]
76
-	#[NoCSRFRequired]
77
-	public function decline(string $token):TemplateResponse {
78
-		$row = $this->getTokenInformation($token);
79
-		if (!$row) {
80
-			return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest');
81
-		}
82
-
83
-		$iTipMessage = $this->buildITipResponse($row, 'DECLINED');
84
-		$this->responseServer->handleITipMessage($iTipMessage);
85
-
86
-		if ($iTipMessage->getScheduleStatus() === '1.2') {
87
-			return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest');
88
-		}
89
-
90
-		return new TemplateResponse($this->appName, 'schedule-response-error', [
91
-			'organizer' => $row['organizer'],
92
-		], 'guest');
93
-	}
94
-
95
-	/**
96
-	 * @param string $token
97
-	 * @return TemplateResponse
98
-	 */
99
-	#[PublicPage]
100
-	#[NoCSRFRequired]
101
-	public function options(string $token):TemplateResponse {
102
-		return new TemplateResponse($this->appName, 'schedule-response-options', [
103
-			'token' => $token
104
-		], 'guest');
105
-	}
106
-
107
-	/**
108
-	 * @param string $token
109
-	 *
110
-	 * @return TemplateResponse
111
-	 */
112
-	#[PublicPage]
113
-	#[NoCSRFRequired]
114
-	public function processMoreOptionsResult(string $token):TemplateResponse {
115
-		$partstat = $this->request->getParam('partStat');
116
-
117
-		$row = $this->getTokenInformation($token);
118
-		if (!$row || !\in_array($partstat, ['ACCEPTED', 'DECLINED', 'TENTATIVE'])) {
119
-			return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest');
120
-		}
121
-
122
-		$iTipMessage = $this->buildITipResponse($row, $partstat);
123
-		$this->responseServer->handleITipMessage($iTipMessage);
124
-		if ($iTipMessage->getScheduleStatus() === '1.2') {
125
-			return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest');
126
-		}
127
-
128
-		return new TemplateResponse($this->appName, 'schedule-response-error', [
129
-			'organizer' => $row['organizer'],
130
-		], 'guest');
131
-	}
132
-
133
-	/**
134
-	 * @param string $token
135
-	 * @return array|null
136
-	 */
137
-	private function getTokenInformation(string $token) {
138
-		$query = $this->db->getQueryBuilder();
139
-		$query->select('*')
140
-			->from('calendar_invitations')
141
-			->where($query->expr()->eq('token', $query->createNamedParameter($token)));
142
-		$stmt = $query->executeQuery();
143
-		$row = $stmt->fetchAssociative();
144
-		$stmt->closeCursor();
145
-
146
-		if (!$row) {
147
-			return null;
148
-		}
149
-
150
-		$currentTime = $this->timeFactory->getTime();
151
-		if (((int)$row['expiration']) < $currentTime) {
152
-			return null;
153
-		}
154
-
155
-		return $row;
156
-	}
157
-
158
-	/**
159
-	 * @param array $row
160
-	 * @param string $partStat participation status of attendee - SEE RFC 5545
161
-	 * @param int|null $guests
162
-	 * @param string|null $comment
163
-	 * @return Message
164
-	 */
165
-	private function buildITipResponse(array $row, string $partStat):Message {
166
-		$iTipMessage = new Message();
167
-		$iTipMessage->uid = $row['uid'];
168
-		$iTipMessage->component = 'VEVENT';
169
-		$iTipMessage->method = 'REPLY';
170
-		$iTipMessage->sequence = $row['sequence'];
171
-		$iTipMessage->sender = $row['attendee'];
172
-
173
-		if ($this->responseServer->isExternalAttendee($row['attendee'])) {
174
-			$iTipMessage->recipient = $row['organizer'];
175
-		} else {
176
-			$iTipMessage->recipient = $row['attendee'];
177
-		}
178
-
179
-		$message = <<<EOF
26
+    /**
27
+     * InvitationResponseController constructor.
28
+     *
29
+     * @param string $appName
30
+     * @param IRequest $request
31
+     * @param IDBConnection $db
32
+     * @param ITimeFactory $timeFactory
33
+     * @param InvitationResponseServer $responseServer
34
+     */
35
+    public function __construct(
36
+        string $appName,
37
+        IRequest $request,
38
+        private IDBConnection $db,
39
+        private ITimeFactory $timeFactory,
40
+        private InvitationResponseServer $responseServer,
41
+    ) {
42
+        parent::__construct($appName, $request);
43
+        // Don't run `$server->exec()`, because we just need access to the
44
+        // fully initialized schedule plugin, but we don't want Sabre/DAV
45
+        // to actually handle and reply to the request
46
+    }
47
+
48
+    /**
49
+     * @param string $token
50
+     * @return TemplateResponse
51
+     */
52
+    #[PublicPage]
53
+    #[NoCSRFRequired]
54
+    public function accept(string $token):TemplateResponse {
55
+        $row = $this->getTokenInformation($token);
56
+        if (!$row) {
57
+            return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest');
58
+        }
59
+
60
+        $iTipMessage = $this->buildITipResponse($row, 'ACCEPTED');
61
+        $this->responseServer->handleITipMessage($iTipMessage);
62
+        if ($iTipMessage->getScheduleStatus() === '1.2') {
63
+            return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest');
64
+        }
65
+
66
+        return new TemplateResponse($this->appName, 'schedule-response-error', [
67
+            'organizer' => $row['organizer'],
68
+        ], 'guest');
69
+    }
70
+
71
+    /**
72
+     * @param string $token
73
+     * @return TemplateResponse
74
+     */
75
+    #[PublicPage]
76
+    #[NoCSRFRequired]
77
+    public function decline(string $token):TemplateResponse {
78
+        $row = $this->getTokenInformation($token);
79
+        if (!$row) {
80
+            return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest');
81
+        }
82
+
83
+        $iTipMessage = $this->buildITipResponse($row, 'DECLINED');
84
+        $this->responseServer->handleITipMessage($iTipMessage);
85
+
86
+        if ($iTipMessage->getScheduleStatus() === '1.2') {
87
+            return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest');
88
+        }
89
+
90
+        return new TemplateResponse($this->appName, 'schedule-response-error', [
91
+            'organizer' => $row['organizer'],
92
+        ], 'guest');
93
+    }
94
+
95
+    /**
96
+     * @param string $token
97
+     * @return TemplateResponse
98
+     */
99
+    #[PublicPage]
100
+    #[NoCSRFRequired]
101
+    public function options(string $token):TemplateResponse {
102
+        return new TemplateResponse($this->appName, 'schedule-response-options', [
103
+            'token' => $token
104
+        ], 'guest');
105
+    }
106
+
107
+    /**
108
+     * @param string $token
109
+     *
110
+     * @return TemplateResponse
111
+     */
112
+    #[PublicPage]
113
+    #[NoCSRFRequired]
114
+    public function processMoreOptionsResult(string $token):TemplateResponse {
115
+        $partstat = $this->request->getParam('partStat');
116
+
117
+        $row = $this->getTokenInformation($token);
118
+        if (!$row || !\in_array($partstat, ['ACCEPTED', 'DECLINED', 'TENTATIVE'])) {
119
+            return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest');
120
+        }
121
+
122
+        $iTipMessage = $this->buildITipResponse($row, $partstat);
123
+        $this->responseServer->handleITipMessage($iTipMessage);
124
+        if ($iTipMessage->getScheduleStatus() === '1.2') {
125
+            return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest');
126
+        }
127
+
128
+        return new TemplateResponse($this->appName, 'schedule-response-error', [
129
+            'organizer' => $row['organizer'],
130
+        ], 'guest');
131
+    }
132
+
133
+    /**
134
+     * @param string $token
135
+     * @return array|null
136
+     */
137
+    private function getTokenInformation(string $token) {
138
+        $query = $this->db->getQueryBuilder();
139
+        $query->select('*')
140
+            ->from('calendar_invitations')
141
+            ->where($query->expr()->eq('token', $query->createNamedParameter($token)));
142
+        $stmt = $query->executeQuery();
143
+        $row = $stmt->fetchAssociative();
144
+        $stmt->closeCursor();
145
+
146
+        if (!$row) {
147
+            return null;
148
+        }
149
+
150
+        $currentTime = $this->timeFactory->getTime();
151
+        if (((int)$row['expiration']) < $currentTime) {
152
+            return null;
153
+        }
154
+
155
+        return $row;
156
+    }
157
+
158
+    /**
159
+     * @param array $row
160
+     * @param string $partStat participation status of attendee - SEE RFC 5545
161
+     * @param int|null $guests
162
+     * @param string|null $comment
163
+     * @return Message
164
+     */
165
+    private function buildITipResponse(array $row, string $partStat):Message {
166
+        $iTipMessage = new Message();
167
+        $iTipMessage->uid = $row['uid'];
168
+        $iTipMessage->component = 'VEVENT';
169
+        $iTipMessage->method = 'REPLY';
170
+        $iTipMessage->sequence = $row['sequence'];
171
+        $iTipMessage->sender = $row['attendee'];
172
+
173
+        if ($this->responseServer->isExternalAttendee($row['attendee'])) {
174
+            $iTipMessage->recipient = $row['organizer'];
175
+        } else {
176
+            $iTipMessage->recipient = $row['attendee'];
177
+        }
178
+
179
+        $message = <<<EOF
180 180
 BEGIN:VCALENDAR
181 181
 PRODID:-//Nextcloud/Nextcloud CalDAV Server//EN
182 182
 METHOD:REPLY
@@ -191,14 +191,14 @@  discard block
 block discarded – undo
191 191
 END:VCALENDAR
192 192
 EOF;
193 193
 
194
-		$vObject = Reader::read(vsprintf($message, [
195
-			$partStat, $row['attendee'], $row['organizer'],
196
-			$row['uid'], $row['sequence'] ?? 0, $row['recurrenceid'] ?? ''
197
-		]));
198
-		$vEvent = $vObject->{'VEVENT'};
199
-		$vEvent->DTSTAMP = date('Ymd\\THis\\Z', $this->timeFactory->getTime());
200
-		$iTipMessage->message = $vObject;
194
+        $vObject = Reader::read(vsprintf($message, [
195
+            $partStat, $row['attendee'], $row['organizer'],
196
+            $row['uid'], $row['sequence'] ?? 0, $row['recurrenceid'] ?? ''
197
+        ]));
198
+        $vEvent = $vObject->{'VEVENT'};
199
+        $vEvent->DTSTAMP = date('Ymd\\THis\\Z', $this->timeFactory->getTime());
200
+        $iTipMessage->message = $vObject;
201 201
 
202
-		return $iTipMessage;
203
-	}
202
+        return $iTipMessage;
203
+    }
204 204
 }
Please login to merge, or discard this patch.
apps/dav/lib/CardDAV/CardDavBackend.php 2 patches
Indentation   +1490 added lines, -1490 removed lines patch added patch discarded remove patch
@@ -34,926 +34,926 @@  discard block
 block discarded – undo
34 34
 use Sabre\VObject\Reader;
35 35
 
36 36
 class CardDavBackend implements BackendInterface, SyncSupport {
37
-	use TTransactional;
38
-	public const PERSONAL_ADDRESSBOOK_URI = 'contacts';
39
-	public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts';
40
-
41
-	private string $dbCardsTable = 'cards';
42
-	private string $dbCardsPropertiesTable = 'cards_properties';
43
-
44
-	/** @var array properties to index */
45
-	public static array $indexProperties = [
46
-		'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME',
47
-		'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO',
48
-		'CLOUD', 'X-SOCIALPROFILE'];
49
-
50
-	/**
51
-	 * @var string[] Map of uid => display name
52
-	 */
53
-	protected array $userDisplayNames;
54
-	private array $etagCache = [];
55
-
56
-	public function __construct(
57
-		private IDBConnection $db,
58
-		private Principal $principalBackend,
59
-		private IUserManager $userManager,
60
-		private IEventDispatcher $dispatcher,
61
-		private Sharing\Backend $sharingBackend,
62
-		private IConfig $config,
63
-	) {
64
-	}
65
-
66
-	/**
67
-	 * Return the number of address books for a principal
68
-	 *
69
-	 * @param $principalUri
70
-	 * @return int
71
-	 */
72
-	public function getAddressBooksForUserCount($principalUri) {
73
-		$principalUri = $this->convertPrincipal($principalUri, true);
74
-		$query = $this->db->getQueryBuilder();
75
-		$query->select($query->func()->count('*'))
76
-			->from('addressbooks')
77
-			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
78
-
79
-		$result = $query->executeQuery();
80
-		$column = (int)$result->fetchOne();
81
-		$result->closeCursor();
82
-		return $column;
83
-	}
84
-
85
-	/**
86
-	 * Returns the list of address books for a specific user.
87
-	 *
88
-	 * Every addressbook should have the following properties:
89
-	 *   id - an arbitrary unique id
90
-	 *   uri - the 'basename' part of the url
91
-	 *   principaluri - Same as the passed parameter
92
-	 *
93
-	 * Any additional clark-notation property may be passed besides this. Some
94
-	 * common ones are :
95
-	 *   {DAV:}displayname
96
-	 *   {urn:ietf:params:xml:ns:carddav}addressbook-description
97
-	 *   {http://calendarserver.org/ns/}getctag
98
-	 *
99
-	 * @param string $principalUri
100
-	 * @return array
101
-	 */
102
-	public function getAddressBooksForUser($principalUri) {
103
-		return $this->atomic(function () use ($principalUri) {
104
-			$principalUriOriginal = $principalUri;
105
-			$principalUri = $this->convertPrincipal($principalUri, true);
106
-			$select = $this->db->getQueryBuilder();
107
-			$select->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
108
-				->from('addressbooks')
109
-				->where($select->expr()->eq('principaluri', $select->createNamedParameter($principalUri)));
110
-
111
-			$addressBooks = [];
112
-
113
-			$result = $select->executeQuery();
114
-			while ($row = $result->fetchAssociative()) {
115
-				$addressBooks[$row['id']] = [
116
-					'id' => $row['id'],
117
-					'uri' => $row['uri'],
118
-					'principaluri' => $this->convertPrincipal($row['principaluri'], false),
119
-					'{DAV:}displayname' => $row['displayname'],
120
-					'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
121
-					'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
122
-					'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
123
-				];
124
-
125
-				$this->addOwnerPrincipal($addressBooks[$row['id']]);
126
-			}
127
-			$result->closeCursor();
128
-
129
-			// query for shared addressbooks
130
-			$principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true);
131
-
132
-			$principals[] = $principalUri;
133
-
134
-			$select = $this->db->getQueryBuilder();
135
-			$subSelect = $this->db->getQueryBuilder();
136
-
137
-			$subSelect->select('id')
138
-				->from('dav_shares', 'd')
139
-				->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(\OCA\DAV\CardDAV\Sharing\Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
140
-				->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY));
141
-
142
-
143
-			$select->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access'])
144
-				->from('dav_shares', 's')
145
-				->join('s', 'addressbooks', 'a', $select->expr()->eq('s.resourceid', 'a.id'))
146
-				->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY)))
147
-				->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('addressbook', IQueryBuilder::PARAM_STR)))
148
-				->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
149
-			$result = $select->executeQuery();
150
-
151
-			$readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
152
-			while ($row = $result->fetchAssociative()) {
153
-				if ($row['principaluri'] === $principalUri) {
154
-					continue;
155
-				}
156
-
157
-				$readOnly = (int)$row['access'] === Backend::ACCESS_READ;
158
-				if (isset($addressBooks[$row['id']])) {
159
-					if ($readOnly) {
160
-						// New share can not have more permissions then the old one.
161
-						continue;
162
-					}
163
-					if (isset($addressBooks[$row['id']][$readOnlyPropertyName])
164
-						&& $addressBooks[$row['id']][$readOnlyPropertyName] === 0) {
165
-						// Old share is already read-write, no more permissions can be gained
166
-						continue;
167
-					}
168
-				}
169
-
170
-				[, $name] = \Sabre\Uri\split($row['principaluri']);
171
-				$uri = $row['uri'] . '_shared_by_' . $name;
172
-				$displayName = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? $name ?? '') . ')';
173
-
174
-				$addressBooks[$row['id']] = [
175
-					'id' => $row['id'],
176
-					'uri' => $uri,
177
-					'principaluri' => $principalUriOriginal,
178
-					'{DAV:}displayname' => $displayName,
179
-					'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
180
-					'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
181
-					'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
182
-					'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'],
183
-					$readOnlyPropertyName => $readOnly,
184
-				];
185
-
186
-				$this->addOwnerPrincipal($addressBooks[$row['id']]);
187
-			}
188
-			$result->closeCursor();
189
-
190
-			return array_values($addressBooks);
191
-		}, $this->db);
192
-	}
193
-
194
-	public function getUsersOwnAddressBooks($principalUri) {
195
-		$principalUri = $this->convertPrincipal($principalUri, true);
196
-		$query = $this->db->getQueryBuilder();
197
-		$query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
198
-			->from('addressbooks')
199
-			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
200
-
201
-		$addressBooks = [];
202
-
203
-		$result = $query->executeQuery();
204
-		while ($row = $result->fetchAssociative()) {
205
-			$addressBooks[$row['id']] = [
206
-				'id' => $row['id'],
207
-				'uri' => $row['uri'],
208
-				'principaluri' => $this->convertPrincipal($row['principaluri'], false),
209
-				'{DAV:}displayname' => $row['displayname'],
210
-				'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
211
-				'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
212
-				'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
213
-			];
214
-
215
-			$this->addOwnerPrincipal($addressBooks[$row['id']]);
216
-		}
217
-		$result->closeCursor();
218
-
219
-		return array_values($addressBooks);
220
-	}
221
-
222
-	/**
223
-	 * @param int $addressBookId
224
-	 */
225
-	public function getAddressBookById(int $addressBookId): ?array {
226
-		$query = $this->db->getQueryBuilder();
227
-		$result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
228
-			->from('addressbooks')
229
-			->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
230
-			->executeQuery();
231
-		$row = $result->fetchAssociative();
232
-		$result->closeCursor();
233
-		if (!$row) {
234
-			return null;
235
-		}
236
-
237
-		$addressBook = [
238
-			'id' => $row['id'],
239
-			'uri' => $row['uri'],
240
-			'principaluri' => $row['principaluri'],
241
-			'{DAV:}displayname' => $row['displayname'],
242
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
243
-			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
244
-			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
245
-		];
246
-
247
-		$this->addOwnerPrincipal($addressBook);
248
-
249
-		return $addressBook;
250
-	}
251
-
252
-	public function getAddressBooksByUri(string $principal, string $addressBookUri): ?array {
253
-		$query = $this->db->getQueryBuilder();
254
-		$result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
255
-			->from('addressbooks')
256
-			->where($query->expr()->eq('uri', $query->createNamedParameter($addressBookUri)))
257
-			->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
258
-			->setMaxResults(1)
259
-			->executeQuery();
260
-
261
-		$row = $result->fetchAssociative();
262
-		$result->closeCursor();
263
-		if ($row === false) {
264
-			return null;
265
-		}
266
-
267
-		$addressBook = [
268
-			'id' => $row['id'],
269
-			'uri' => $row['uri'],
270
-			'principaluri' => $row['principaluri'],
271
-			'{DAV:}displayname' => $row['displayname'],
272
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
273
-			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
274
-			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
275
-
276
-		];
277
-
278
-		// system address books are always read only
279
-		if ($principal === 'principals/system/system') {
280
-			$addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] = $row['principaluri'];
281
-			$addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'] = true;
282
-		}
283
-
284
-		$this->addOwnerPrincipal($addressBook);
285
-
286
-		return $addressBook;
287
-	}
288
-
289
-	/**
290
-	 * Updates properties for an address book.
291
-	 *
292
-	 * The list of mutations is stored in a Sabre\DAV\PropPatch object.
293
-	 * To do the actual updates, you must tell this object which properties
294
-	 * you're going to process with the handle() method.
295
-	 *
296
-	 * Calling the handle method is like telling the PropPatch object "I
297
-	 * promise I can handle updating this property".
298
-	 *
299
-	 * Read the PropPatch documentation for more info and examples.
300
-	 *
301
-	 * @param string $addressBookId
302
-	 * @param \Sabre\DAV\PropPatch $propPatch
303
-	 * @return void
304
-	 */
305
-	public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
306
-		$supportedProperties = [
307
-			'{DAV:}displayname',
308
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description',
309
-		];
310
-
311
-		$propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) {
312
-			$updates = [];
313
-			foreach ($mutations as $property => $newValue) {
314
-				switch ($property) {
315
-					case '{DAV:}displayname':
316
-						$updates['displayname'] = $newValue;
317
-						break;
318
-					case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
319
-						$updates['description'] = $newValue;
320
-						break;
321
-				}
322
-			}
323
-			[$addressBookRow, $shares] = $this->atomic(function () use ($addressBookId, $updates) {
324
-				$query = $this->db->getQueryBuilder();
325
-				$query->update('addressbooks');
326
-
327
-				foreach ($updates as $key => $value) {
328
-					$query->set($key, $query->createNamedParameter($value));
329
-				}
330
-				$query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
331
-					->executeStatement();
332
-
333
-				$this->addChange($addressBookId, '', 2);
334
-
335
-				$addressBookRow = $this->getAddressBookById((int)$addressBookId);
336
-				$shares = $this->getShares((int)$addressBookId);
337
-				return [$addressBookRow, $shares];
338
-			}, $this->db);
339
-
340
-			$this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations));
341
-
342
-			return true;
343
-		});
344
-	}
345
-
346
-	/**
347
-	 * Creates a new address book
348
-	 *
349
-	 * @param string $principalUri
350
-	 * @param string $url Just the 'basename' of the url.
351
-	 * @param array $properties
352
-	 * @return int
353
-	 * @throws BadRequest
354
-	 * @throws Exception
355
-	 */
356
-	public function createAddressBook($principalUri, $url, array $properties) {
357
-		if (strlen($url) > 255) {
358
-			throw new BadRequest('URI too long. Address book not created');
359
-		}
360
-
361
-		$values = [
362
-			'displayname' => null,
363
-			'description' => null,
364
-			'principaluri' => $principalUri,
365
-			'uri' => $url,
366
-			'synctoken' => 1
367
-		];
368
-
369
-		foreach ($properties as $property => $newValue) {
370
-			switch ($property) {
371
-				case '{DAV:}displayname':
372
-					$values['displayname'] = $newValue;
373
-					break;
374
-				case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
375
-					$values['description'] = $newValue;
376
-					break;
377
-				default:
378
-					throw new BadRequest('Unknown property: ' . $property);
379
-			}
380
-		}
381
-
382
-		// Fallback to make sure the displayname is set. Some clients may refuse
383
-		// to work with addressbooks not having a displayname.
384
-		if (is_null($values['displayname'])) {
385
-			$values['displayname'] = $url;
386
-		}
387
-
388
-		[$addressBookId, $addressBookRow] = $this->atomic(function () use ($values) {
389
-			$query = $this->db->getQueryBuilder();
390
-			$query->insert('addressbooks')
391
-				->values([
392
-					'uri' => $query->createParameter('uri'),
393
-					'displayname' => $query->createParameter('displayname'),
394
-					'description' => $query->createParameter('description'),
395
-					'principaluri' => $query->createParameter('principaluri'),
396
-					'synctoken' => $query->createParameter('synctoken'),
397
-				])
398
-				->setParameters($values)
399
-				->executeStatement();
400
-
401
-			$addressBookId = $query->getLastInsertId();
402
-			return [
403
-				$addressBookId,
404
-				$this->getAddressBookById($addressBookId),
405
-			];
406
-		}, $this->db);
407
-
408
-		$this->dispatcher->dispatchTyped(new AddressBookCreatedEvent($addressBookId, $addressBookRow));
409
-
410
-		return $addressBookId;
411
-	}
412
-
413
-	/**
414
-	 * Deletes an entire addressbook and all its contents
415
-	 *
416
-	 * @param mixed $addressBookId
417
-	 * @return void
418
-	 */
419
-	public function deleteAddressBook($addressBookId) {
420
-		$this->atomic(function () use ($addressBookId): void {
421
-			$addressBookId = (int)$addressBookId;
422
-			$addressBookData = $this->getAddressBookById($addressBookId);
423
-			$shares = $this->getShares($addressBookId);
424
-
425
-			$query = $this->db->getQueryBuilder();
426
-			$query->delete($this->dbCardsTable)
427
-				->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
428
-				->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
429
-				->executeStatement();
430
-
431
-			$query = $this->db->getQueryBuilder();
432
-			$query->delete('addressbookchanges')
433
-				->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
434
-				->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
435
-				->executeStatement();
436
-
437
-			$query = $this->db->getQueryBuilder();
438
-			$query->delete('addressbooks')
439
-				->where($query->expr()->eq('id', $query->createParameter('id')))
440
-				->setParameter('id', $addressBookId, IQueryBuilder::PARAM_INT)
441
-				->executeStatement();
442
-
443
-			$this->sharingBackend->deleteAllShares($addressBookId);
444
-
445
-			$query = $this->db->getQueryBuilder();
446
-			$query->delete($this->dbCardsPropertiesTable)
447
-				->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
448
-				->executeStatement();
449
-
450
-			if ($addressBookData) {
451
-				$this->dispatcher->dispatchTyped(new AddressBookDeletedEvent($addressBookId, $addressBookData, $shares));
452
-			}
453
-		}, $this->db);
454
-	}
455
-
456
-	/**
457
-	 * Returns all cards for a specific addressbook id.
458
-	 *
459
-	 * This method should return the following properties for each card:
460
-	 *   * carddata - raw vcard data
461
-	 *   * uri - Some unique url
462
-	 *   * lastmodified - A unix timestamp
463
-	 *
464
-	 * It's recommended to also return the following properties:
465
-	 *   * etag - A unique etag. This must change every time the card changes.
466
-	 *   * size - The size of the card in bytes.
467
-	 *
468
-	 * If these last two properties are provided, less time will be spent
469
-	 * calculating them. If they are specified, you can also omit carddata.
470
-	 * This may speed up certain requests, especially with large cards.
471
-	 *
472
-	 * @param mixed $addressbookId
473
-	 * @return array
474
-	 */
475
-	public function getCards($addressbookId) {
476
-		$query = $this->db->getQueryBuilder();
477
-		$query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
478
-			->from($this->dbCardsTable)
479
-			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId)));
480
-
481
-		$cards = [];
482
-
483
-		$result = $query->executeQuery();
484
-		while ($row = $result->fetchAssociative()) {
485
-			$row['etag'] = '"' . $row['etag'] . '"';
486
-
487
-			$modified = false;
488
-			$row['carddata'] = $this->readBlob($row['carddata'], $modified);
489
-			if ($modified) {
490
-				$row['size'] = strlen($row['carddata']);
491
-			}
492
-
493
-			$cards[] = $row;
494
-		}
495
-		$result->closeCursor();
496
-
497
-		return $cards;
498
-	}
499
-
500
-	/**
501
-	 * Returns a specific card.
502
-	 *
503
-	 * The same set of properties must be returned as with getCards. The only
504
-	 * exception is that 'carddata' is absolutely required.
505
-	 *
506
-	 * If the card does not exist, you must return false.
507
-	 *
508
-	 * @param mixed $addressBookId
509
-	 * @param string $cardUri
510
-	 * @return array
511
-	 */
512
-	public function getCard($addressBookId, $cardUri) {
513
-		$query = $this->db->getQueryBuilder();
514
-		$query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
515
-			->from($this->dbCardsTable)
516
-			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
517
-			->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
518
-			->setMaxResults(1);
519
-
520
-		$result = $query->executeQuery();
521
-		$row = $result->fetchAssociative();
522
-		if (!$row) {
523
-			return false;
524
-		}
525
-		$row['etag'] = '"' . $row['etag'] . '"';
526
-
527
-		$modified = false;
528
-		$row['carddata'] = $this->readBlob($row['carddata'], $modified);
529
-		if ($modified) {
530
-			$row['size'] = strlen($row['carddata']);
531
-		}
532
-
533
-		return $row;
534
-	}
535
-
536
-	/**
537
-	 * Returns a list of cards.
538
-	 *
539
-	 * This method should work identical to getCard, but instead return all the
540
-	 * cards in the list as an array.
541
-	 *
542
-	 * If the backend supports this, it may allow for some speed-ups.
543
-	 *
544
-	 * @param mixed $addressBookId
545
-	 * @param array $uris
546
-	 * @return array
547
-	 */
548
-	public function getMultipleCards($addressBookId, array $uris) {
549
-		if (empty($uris)) {
550
-			return [];
551
-		}
552
-
553
-		$chunks = array_chunk($uris, 100);
554
-		$cards = [];
555
-
556
-		$query = $this->db->getQueryBuilder();
557
-		$query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
558
-			->from($this->dbCardsTable)
559
-			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
560
-			->andWhere($query->expr()->in('uri', $query->createParameter('uri')));
561
-
562
-		foreach ($chunks as $uris) {
563
-			$query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY);
564
-			$result = $query->executeQuery();
565
-
566
-			while ($row = $result->fetchAssociative()) {
567
-				$row['etag'] = '"' . $row['etag'] . '"';
568
-
569
-				$modified = false;
570
-				$row['carddata'] = $this->readBlob($row['carddata'], $modified);
571
-				if ($modified) {
572
-					$row['size'] = strlen($row['carddata']);
573
-				}
574
-
575
-				$cards[] = $row;
576
-			}
577
-			$result->closeCursor();
578
-		}
579
-		return $cards;
580
-	}
581
-
582
-	/**
583
-	 * Creates a new card.
584
-	 *
585
-	 * The addressbook id will be passed as the first argument. This is the
586
-	 * same id as it is returned from the getAddressBooksForUser method.
587
-	 *
588
-	 * The cardUri is a base uri, and doesn't include the full path. The
589
-	 * cardData argument is the vcard body, and is passed as a string.
590
-	 *
591
-	 * It is possible to return an ETag from this method. This ETag is for the
592
-	 * newly created resource, and must be enclosed with double quotes (that
593
-	 * is, the string itself must contain the double quotes).
594
-	 *
595
-	 * You should only return the ETag if you store the carddata as-is. If a
596
-	 * subsequent GET request on the same card does not have the same body,
597
-	 * byte-by-byte and you did return an ETag here, clients tend to get
598
-	 * confused.
599
-	 *
600
-	 * If you don't return an ETag, you can just return null.
601
-	 *
602
-	 * @param mixed $addressBookId
603
-	 * @param string $cardUri
604
-	 * @param string $cardData
605
-	 * @param bool $checkAlreadyExists
606
-	 * @return string
607
-	 */
608
-	public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
609
-		$etag = md5($cardData);
610
-		$uid = $this->getUID($cardData);
611
-		return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
612
-			if ($checkAlreadyExists) {
613
-				$q = $this->db->getQueryBuilder();
614
-				$q->select('uid')
615
-					->from($this->dbCardsTable)
616
-					->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId)))
617
-					->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
618
-					->setMaxResults(1);
619
-				$result = $q->executeQuery();
620
-				$count = (bool)$result->fetchOne();
621
-				$result->closeCursor();
622
-				if ($count) {
623
-					throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
624
-				}
625
-			}
626
-
627
-			$query = $this->db->getQueryBuilder();
628
-			$query->insert('cards')
629
-				->values([
630
-					'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB),
631
-					'uri' => $query->createNamedParameter($cardUri),
632
-					'lastmodified' => $query->createNamedParameter(time()),
633
-					'addressbookid' => $query->createNamedParameter($addressBookId),
634
-					'size' => $query->createNamedParameter(strlen($cardData)),
635
-					'etag' => $query->createNamedParameter($etag),
636
-					'uid' => $query->createNamedParameter($uid),
637
-				])
638
-				->executeStatement();
639
-
640
-			$etagCacheKey = "$addressBookId#$cardUri";
641
-			$this->etagCache[$etagCacheKey] = $etag;
642
-
643
-			$this->addChange($addressBookId, $cardUri, 1);
644
-			$this->updateProperties($addressBookId, $cardUri, $cardData);
645
-
646
-			$addressBookData = $this->getAddressBookById($addressBookId);
647
-			$shares = $this->getShares($addressBookId);
648
-			$objectRow = $this->getCard($addressBookId, $cardUri);
649
-			$this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
650
-
651
-			return '"' . $etag . '"';
652
-		}, $this->db);
653
-	}
654
-
655
-	/**
656
-	 * Updates a card.
657
-	 *
658
-	 * The addressbook id will be passed as the first argument. This is the
659
-	 * same id as it is returned from the getAddressBooksForUser method.
660
-	 *
661
-	 * The cardUri is a base uri, and doesn't include the full path. The
662
-	 * cardData argument is the vcard body, and is passed as a string.
663
-	 *
664
-	 * It is possible to return an ETag from this method. This ETag should
665
-	 * match that of the updated resource, and must be enclosed with double
666
-	 * quotes (that is: the string itself must contain the actual quotes).
667
-	 *
668
-	 * You should only return the ETag if you store the carddata as-is. If a
669
-	 * subsequent GET request on the same card does not have the same body,
670
-	 * byte-by-byte and you did return an ETag here, clients tend to get
671
-	 * confused.
672
-	 *
673
-	 * If you don't return an ETag, you can just return null.
674
-	 *
675
-	 * @param mixed $addressBookId
676
-	 * @param string $cardUri
677
-	 * @param string $cardData
678
-	 * @return string
679
-	 */
680
-	public function updateCard($addressBookId, $cardUri, $cardData) {
681
-		$uid = $this->getUID($cardData);
682
-		$etag = md5($cardData);
683
-
684
-		return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $uid, $etag) {
685
-			$query = $this->db->getQueryBuilder();
686
-
687
-			// check for recently stored etag and stop if it is the same
688
-			$etagCacheKey = "$addressBookId#$cardUri";
689
-			if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) {
690
-				return '"' . $etag . '"';
691
-			}
692
-
693
-			$query->update($this->dbCardsTable)
694
-				->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB))
695
-				->set('lastmodified', $query->createNamedParameter(time()))
696
-				->set('size', $query->createNamedParameter(strlen($cardData)))
697
-				->set('etag', $query->createNamedParameter($etag))
698
-				->set('uid', $query->createNamedParameter($uid))
699
-				->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
700
-				->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
701
-				->executeStatement();
702
-
703
-			$this->etagCache[$etagCacheKey] = $etag;
704
-
705
-			$this->addChange($addressBookId, $cardUri, 2);
706
-			$this->updateProperties($addressBookId, $cardUri, $cardData);
707
-
708
-			$addressBookData = $this->getAddressBookById($addressBookId);
709
-			$shares = $this->getShares($addressBookId);
710
-			$objectRow = $this->getCard($addressBookId, $cardUri);
711
-			$this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
712
-			return '"' . $etag . '"';
713
-		}, $this->db);
714
-	}
715
-
716
-	/**
717
-	 * @throws Exception
718
-	 */
719
-	public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int $targetAddressBookId, string $tragetObjectUri): bool {
720
-		return $this->atomic(function () use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) {
721
-			$card = $this->getCard($sourceAddressBookId, $sourceObjectUri);
722
-			if (empty($card)) {
723
-				return false;
724
-			}
725
-			$sourceObjectId = (int)$card['id'];
726
-
727
-			$query = $this->db->getQueryBuilder();
728
-			$query->update('cards')
729
-				->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT))
730
-				->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR))
731
-				->where($query->expr()->eq('uri', $query->createNamedParameter($sourceObjectUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
732
-				->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($sourceAddressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
733
-				->executeStatement();
734
-
735
-			$this->purgeProperties($sourceAddressBookId, $sourceObjectId);
736
-			$this->updateProperties($targetAddressBookId, $tragetObjectUri, $card['carddata']);
737
-
738
-			$this->addChange($sourceAddressBookId, $sourceObjectUri, 3);
739
-			$this->addChange($targetAddressBookId, $tragetObjectUri, 1);
740
-
741
-			$card = $this->getCard($targetAddressBookId, $tragetObjectUri);
742
-			// Card wasn't found - possibly because it was deleted in the meantime by a different client
743
-			if (empty($card)) {
744
-				return false;
745
-			}
746
-			$targetAddressBookRow = $this->getAddressBookById($targetAddressBookId);
747
-			// the address book this card is being moved to does not exist any longer
748
-			if (empty($targetAddressBookRow)) {
749
-				return false;
750
-			}
751
-
752
-			$sourceShares = $this->getShares($sourceAddressBookId);
753
-			$targetShares = $this->getShares($targetAddressBookId);
754
-			$sourceAddressBookRow = $this->getAddressBookById($sourceAddressBookId);
755
-			$this->dispatcher->dispatchTyped(new CardMovedEvent($sourceAddressBookId, $sourceAddressBookRow, $targetAddressBookId, $targetAddressBookRow, $sourceShares, $targetShares, $card));
756
-			return true;
757
-		}, $this->db);
758
-	}
759
-
760
-	/**
761
-	 * Deletes a card
762
-	 *
763
-	 * @param mixed $addressBookId
764
-	 * @param string $cardUri
765
-	 * @return bool
766
-	 */
767
-	public function deleteCard($addressBookId, $cardUri) {
768
-		return $this->atomic(function () use ($addressBookId, $cardUri) {
769
-			$addressBookData = $this->getAddressBookById($addressBookId);
770
-			$shares = $this->getShares($addressBookId);
771
-			$objectRow = $this->getCard($addressBookId, $cardUri);
772
-
773
-			try {
774
-				$cardId = $this->getCardId($addressBookId, $cardUri);
775
-			} catch (\InvalidArgumentException $e) {
776
-				$cardId = null;
777
-			}
778
-			$query = $this->db->getQueryBuilder();
779
-			$ret = $query->delete($this->dbCardsTable)
780
-				->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
781
-				->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
782
-				->executeStatement();
783
-
784
-			$this->addChange($addressBookId, $cardUri, 3);
785
-
786
-			if ($ret === 1) {
787
-				if ($cardId !== null) {
788
-					$this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow));
789
-					$this->purgeProperties($addressBookId, $cardId);
790
-				}
791
-				return true;
792
-			}
793
-
794
-			return false;
795
-		}, $this->db);
796
-	}
797
-
798
-	/**
799
-	 * The getChanges method returns all the changes that have happened, since
800
-	 * the specified syncToken in the specified address book.
801
-	 *
802
-	 * This function should return an array, such as the following:
803
-	 *
804
-	 * [
805
-	 *   'syncToken' => 'The current synctoken',
806
-	 *   'added'   => [
807
-	 *      'new.txt',
808
-	 *   ],
809
-	 *   'modified'   => [
810
-	 *      'modified.txt',
811
-	 *   ],
812
-	 *   'deleted' => [
813
-	 *      'foo.php.bak',
814
-	 *      'old.txt'
815
-	 *   ]
816
-	 * ];
817
-	 *
818
-	 * The returned syncToken property should reflect the *current* syncToken
819
-	 * of the calendar, as reported in the {http://sabredav.org/ns}sync-token
820
-	 * property. This is needed here too, to ensure the operation is atomic.
821
-	 *
822
-	 * If the $syncToken argument is specified as null, this is an initial
823
-	 * sync, and all members should be reported.
824
-	 *
825
-	 * The modified property is an array of nodenames that have changed since
826
-	 * the last token.
827
-	 *
828
-	 * The deleted property is an array with nodenames, that have been deleted
829
-	 * from collection.
830
-	 *
831
-	 * The $syncLevel argument is basically the 'depth' of the report. If it's
832
-	 * 1, you only have to report changes that happened only directly in
833
-	 * immediate descendants. If it's 2, it should also include changes from
834
-	 * the nodes below the child collections. (grandchildren)
835
-	 *
836
-	 * The $limit argument allows a client to specify how many results should
837
-	 * be returned at most. If the limit is not specified, it should be treated
838
-	 * as infinite.
839
-	 *
840
-	 * If the limit (infinite or not) is higher than you're willing to return,
841
-	 * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception.
842
-	 *
843
-	 * If the syncToken is expired (due to data cleanup) or unknown, you must
844
-	 * return null.
845
-	 *
846
-	 * The limit is 'suggestive'. You are free to ignore it.
847
-	 *
848
-	 * @param string $addressBookId
849
-	 * @param string $syncToken
850
-	 * @param int $syncLevel
851
-	 * @param int|null $limit
852
-	 * @return array
853
-	 */
854
-	public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
855
-		$maxLimit = $this->config->getSystemValueInt('carddav_sync_request_truncation', 2500);
856
-		$limit = ($limit === null) ? $maxLimit : min($limit, $maxLimit);
857
-		// Current synctoken
858
-		return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) {
859
-			$qb = $this->db->getQueryBuilder();
860
-			$qb->select('synctoken')
861
-				->from('addressbooks')
862
-				->where(
863
-					$qb->expr()->eq('id', $qb->createNamedParameter($addressBookId))
864
-				);
865
-			$stmt = $qb->executeQuery();
866
-			$currentToken = $stmt->fetchOne();
867
-			$stmt->closeCursor();
868
-
869
-			if (is_null($currentToken)) {
870
-				return [];
871
-			}
872
-
873
-			$result = [
874
-				'syncToken' => $currentToken,
875
-				'added' => [],
876
-				'modified' => [],
877
-				'deleted' => [],
878
-			];
879
-			if (str_starts_with($syncToken, 'init_')) {
880
-				$syncValues = explode('_', $syncToken);
881
-				$lastID = $syncValues[1];
882
-				$initialSyncToken = $syncValues[2];
883
-				$qb = $this->db->getQueryBuilder();
884
-				$qb->select('id', 'uri')
885
-					->from('cards')
886
-					->where(
887
-						$qb->expr()->andX(
888
-							$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)),
889
-							$qb->expr()->gt('id', $qb->createNamedParameter($lastID)))
890
-					)->orderBy('id')
891
-					->setMaxResults($limit);
892
-				$stmt = $qb->executeQuery();
893
-				$values = $stmt->fetchAllAssociative();
894
-				$stmt->closeCursor();
895
-				if (count($values) === 0) {
896
-					$result['syncToken'] = $initialSyncToken;
897
-					$result['result_truncated'] = false;
898
-					$result['added'] = [];
899
-				} else {
900
-					$lastID = $values[array_key_last($values)]['id'];
901
-					$result['added'] = array_column($values, 'uri');
902
-					$result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken ;
903
-					$result['result_truncated'] = count($result['added']) >= $limit;
904
-				}
905
-			} elseif ($syncToken) {
906
-				$qb = $this->db->getQueryBuilder();
907
-				$qb->select('uri', 'operation', 'synctoken')
908
-					->from('addressbookchanges')
909
-					->where(
910
-						$qb->expr()->andX(
911
-							$qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)),
912
-							$qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)),
913
-							$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
914
-						)
915
-					)->orderBy('synctoken');
916
-
917
-				if ($limit > 0) {
918
-					$qb->setMaxResults($limit);
919
-				}
920
-
921
-				// Fetching all changes
922
-				$stmt = $qb->executeQuery();
923
-				$rowCount = $stmt->rowCount();
924
-
925
-				$changes = [];
926
-				$highestSyncToken = 0;
927
-
928
-				// This loop ensures that any duplicates are overwritten, only the
929
-				// last change on a node is relevant.
930
-				while ($row = $stmt->fetchAssociative()) {
931
-					$changes[$row['uri']] = $row['operation'];
932
-					$highestSyncToken = $row['synctoken'];
933
-				}
934
-
935
-				$stmt->closeCursor();
936
-
937
-				// No changes found, use current token
938
-				if (empty($changes)) {
939
-					$result['syncToken'] = $currentToken;
940
-				}
941
-
942
-				foreach ($changes as $uri => $operation) {
943
-					switch ($operation) {
944
-						case 1:
945
-							$result['added'][] = $uri;
946
-							break;
947
-						case 2:
948
-							$result['modified'][] = $uri;
949
-							break;
950
-						case 3:
951
-							$result['deleted'][] = $uri;
952
-							break;
953
-					}
954
-				}
955
-
956
-				/*
37
+    use TTransactional;
38
+    public const PERSONAL_ADDRESSBOOK_URI = 'contacts';
39
+    public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts';
40
+
41
+    private string $dbCardsTable = 'cards';
42
+    private string $dbCardsPropertiesTable = 'cards_properties';
43
+
44
+    /** @var array properties to index */
45
+    public static array $indexProperties = [
46
+        'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME',
47
+        'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO',
48
+        'CLOUD', 'X-SOCIALPROFILE'];
49
+
50
+    /**
51
+     * @var string[] Map of uid => display name
52
+     */
53
+    protected array $userDisplayNames;
54
+    private array $etagCache = [];
55
+
56
+    public function __construct(
57
+        private IDBConnection $db,
58
+        private Principal $principalBackend,
59
+        private IUserManager $userManager,
60
+        private IEventDispatcher $dispatcher,
61
+        private Sharing\Backend $sharingBackend,
62
+        private IConfig $config,
63
+    ) {
64
+    }
65
+
66
+    /**
67
+     * Return the number of address books for a principal
68
+     *
69
+     * @param $principalUri
70
+     * @return int
71
+     */
72
+    public function getAddressBooksForUserCount($principalUri) {
73
+        $principalUri = $this->convertPrincipal($principalUri, true);
74
+        $query = $this->db->getQueryBuilder();
75
+        $query->select($query->func()->count('*'))
76
+            ->from('addressbooks')
77
+            ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
78
+
79
+        $result = $query->executeQuery();
80
+        $column = (int)$result->fetchOne();
81
+        $result->closeCursor();
82
+        return $column;
83
+    }
84
+
85
+    /**
86
+     * Returns the list of address books for a specific user.
87
+     *
88
+     * Every addressbook should have the following properties:
89
+     *   id - an arbitrary unique id
90
+     *   uri - the 'basename' part of the url
91
+     *   principaluri - Same as the passed parameter
92
+     *
93
+     * Any additional clark-notation property may be passed besides this. Some
94
+     * common ones are :
95
+     *   {DAV:}displayname
96
+     *   {urn:ietf:params:xml:ns:carddav}addressbook-description
97
+     *   {http://calendarserver.org/ns/}getctag
98
+     *
99
+     * @param string $principalUri
100
+     * @return array
101
+     */
102
+    public function getAddressBooksForUser($principalUri) {
103
+        return $this->atomic(function () use ($principalUri) {
104
+            $principalUriOriginal = $principalUri;
105
+            $principalUri = $this->convertPrincipal($principalUri, true);
106
+            $select = $this->db->getQueryBuilder();
107
+            $select->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
108
+                ->from('addressbooks')
109
+                ->where($select->expr()->eq('principaluri', $select->createNamedParameter($principalUri)));
110
+
111
+            $addressBooks = [];
112
+
113
+            $result = $select->executeQuery();
114
+            while ($row = $result->fetchAssociative()) {
115
+                $addressBooks[$row['id']] = [
116
+                    'id' => $row['id'],
117
+                    'uri' => $row['uri'],
118
+                    'principaluri' => $this->convertPrincipal($row['principaluri'], false),
119
+                    '{DAV:}displayname' => $row['displayname'],
120
+                    '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
121
+                    '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
122
+                    '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
123
+                ];
124
+
125
+                $this->addOwnerPrincipal($addressBooks[$row['id']]);
126
+            }
127
+            $result->closeCursor();
128
+
129
+            // query for shared addressbooks
130
+            $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true);
131
+
132
+            $principals[] = $principalUri;
133
+
134
+            $select = $this->db->getQueryBuilder();
135
+            $subSelect = $this->db->getQueryBuilder();
136
+
137
+            $subSelect->select('id')
138
+                ->from('dav_shares', 'd')
139
+                ->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(\OCA\DAV\CardDAV\Sharing\Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
140
+                ->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY));
141
+
142
+
143
+            $select->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access'])
144
+                ->from('dav_shares', 's')
145
+                ->join('s', 'addressbooks', 'a', $select->expr()->eq('s.resourceid', 'a.id'))
146
+                ->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY)))
147
+                ->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('addressbook', IQueryBuilder::PARAM_STR)))
148
+                ->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
149
+            $result = $select->executeQuery();
150
+
151
+            $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
152
+            while ($row = $result->fetchAssociative()) {
153
+                if ($row['principaluri'] === $principalUri) {
154
+                    continue;
155
+                }
156
+
157
+                $readOnly = (int)$row['access'] === Backend::ACCESS_READ;
158
+                if (isset($addressBooks[$row['id']])) {
159
+                    if ($readOnly) {
160
+                        // New share can not have more permissions then the old one.
161
+                        continue;
162
+                    }
163
+                    if (isset($addressBooks[$row['id']][$readOnlyPropertyName])
164
+                        && $addressBooks[$row['id']][$readOnlyPropertyName] === 0) {
165
+                        // Old share is already read-write, no more permissions can be gained
166
+                        continue;
167
+                    }
168
+                }
169
+
170
+                [, $name] = \Sabre\Uri\split($row['principaluri']);
171
+                $uri = $row['uri'] . '_shared_by_' . $name;
172
+                $displayName = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? $name ?? '') . ')';
173
+
174
+                $addressBooks[$row['id']] = [
175
+                    'id' => $row['id'],
176
+                    'uri' => $uri,
177
+                    'principaluri' => $principalUriOriginal,
178
+                    '{DAV:}displayname' => $displayName,
179
+                    '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
180
+                    '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
181
+                    '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
182
+                    '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'],
183
+                    $readOnlyPropertyName => $readOnly,
184
+                ];
185
+
186
+                $this->addOwnerPrincipal($addressBooks[$row['id']]);
187
+            }
188
+            $result->closeCursor();
189
+
190
+            return array_values($addressBooks);
191
+        }, $this->db);
192
+    }
193
+
194
+    public function getUsersOwnAddressBooks($principalUri) {
195
+        $principalUri = $this->convertPrincipal($principalUri, true);
196
+        $query = $this->db->getQueryBuilder();
197
+        $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
198
+            ->from('addressbooks')
199
+            ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
200
+
201
+        $addressBooks = [];
202
+
203
+        $result = $query->executeQuery();
204
+        while ($row = $result->fetchAssociative()) {
205
+            $addressBooks[$row['id']] = [
206
+                'id' => $row['id'],
207
+                'uri' => $row['uri'],
208
+                'principaluri' => $this->convertPrincipal($row['principaluri'], false),
209
+                '{DAV:}displayname' => $row['displayname'],
210
+                '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
211
+                '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
212
+                '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
213
+            ];
214
+
215
+            $this->addOwnerPrincipal($addressBooks[$row['id']]);
216
+        }
217
+        $result->closeCursor();
218
+
219
+        return array_values($addressBooks);
220
+    }
221
+
222
+    /**
223
+     * @param int $addressBookId
224
+     */
225
+    public function getAddressBookById(int $addressBookId): ?array {
226
+        $query = $this->db->getQueryBuilder();
227
+        $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
228
+            ->from('addressbooks')
229
+            ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
230
+            ->executeQuery();
231
+        $row = $result->fetchAssociative();
232
+        $result->closeCursor();
233
+        if (!$row) {
234
+            return null;
235
+        }
236
+
237
+        $addressBook = [
238
+            'id' => $row['id'],
239
+            'uri' => $row['uri'],
240
+            'principaluri' => $row['principaluri'],
241
+            '{DAV:}displayname' => $row['displayname'],
242
+            '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
243
+            '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
244
+            '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
245
+        ];
246
+
247
+        $this->addOwnerPrincipal($addressBook);
248
+
249
+        return $addressBook;
250
+    }
251
+
252
+    public function getAddressBooksByUri(string $principal, string $addressBookUri): ?array {
253
+        $query = $this->db->getQueryBuilder();
254
+        $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
255
+            ->from('addressbooks')
256
+            ->where($query->expr()->eq('uri', $query->createNamedParameter($addressBookUri)))
257
+            ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
258
+            ->setMaxResults(1)
259
+            ->executeQuery();
260
+
261
+        $row = $result->fetchAssociative();
262
+        $result->closeCursor();
263
+        if ($row === false) {
264
+            return null;
265
+        }
266
+
267
+        $addressBook = [
268
+            'id' => $row['id'],
269
+            'uri' => $row['uri'],
270
+            'principaluri' => $row['principaluri'],
271
+            '{DAV:}displayname' => $row['displayname'],
272
+            '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
273
+            '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
274
+            '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
275
+
276
+        ];
277
+
278
+        // system address books are always read only
279
+        if ($principal === 'principals/system/system') {
280
+            $addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] = $row['principaluri'];
281
+            $addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'] = true;
282
+        }
283
+
284
+        $this->addOwnerPrincipal($addressBook);
285
+
286
+        return $addressBook;
287
+    }
288
+
289
+    /**
290
+     * Updates properties for an address book.
291
+     *
292
+     * The list of mutations is stored in a Sabre\DAV\PropPatch object.
293
+     * To do the actual updates, you must tell this object which properties
294
+     * you're going to process with the handle() method.
295
+     *
296
+     * Calling the handle method is like telling the PropPatch object "I
297
+     * promise I can handle updating this property".
298
+     *
299
+     * Read the PropPatch documentation for more info and examples.
300
+     *
301
+     * @param string $addressBookId
302
+     * @param \Sabre\DAV\PropPatch $propPatch
303
+     * @return void
304
+     */
305
+    public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
306
+        $supportedProperties = [
307
+            '{DAV:}displayname',
308
+            '{' . Plugin::NS_CARDDAV . '}addressbook-description',
309
+        ];
310
+
311
+        $propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) {
312
+            $updates = [];
313
+            foreach ($mutations as $property => $newValue) {
314
+                switch ($property) {
315
+                    case '{DAV:}displayname':
316
+                        $updates['displayname'] = $newValue;
317
+                        break;
318
+                    case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
319
+                        $updates['description'] = $newValue;
320
+                        break;
321
+                }
322
+            }
323
+            [$addressBookRow, $shares] = $this->atomic(function () use ($addressBookId, $updates) {
324
+                $query = $this->db->getQueryBuilder();
325
+                $query->update('addressbooks');
326
+
327
+                foreach ($updates as $key => $value) {
328
+                    $query->set($key, $query->createNamedParameter($value));
329
+                }
330
+                $query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
331
+                    ->executeStatement();
332
+
333
+                $this->addChange($addressBookId, '', 2);
334
+
335
+                $addressBookRow = $this->getAddressBookById((int)$addressBookId);
336
+                $shares = $this->getShares((int)$addressBookId);
337
+                return [$addressBookRow, $shares];
338
+            }, $this->db);
339
+
340
+            $this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations));
341
+
342
+            return true;
343
+        });
344
+    }
345
+
346
+    /**
347
+     * Creates a new address book
348
+     *
349
+     * @param string $principalUri
350
+     * @param string $url Just the 'basename' of the url.
351
+     * @param array $properties
352
+     * @return int
353
+     * @throws BadRequest
354
+     * @throws Exception
355
+     */
356
+    public function createAddressBook($principalUri, $url, array $properties) {
357
+        if (strlen($url) > 255) {
358
+            throw new BadRequest('URI too long. Address book not created');
359
+        }
360
+
361
+        $values = [
362
+            'displayname' => null,
363
+            'description' => null,
364
+            'principaluri' => $principalUri,
365
+            'uri' => $url,
366
+            'synctoken' => 1
367
+        ];
368
+
369
+        foreach ($properties as $property => $newValue) {
370
+            switch ($property) {
371
+                case '{DAV:}displayname':
372
+                    $values['displayname'] = $newValue;
373
+                    break;
374
+                case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
375
+                    $values['description'] = $newValue;
376
+                    break;
377
+                default:
378
+                    throw new BadRequest('Unknown property: ' . $property);
379
+            }
380
+        }
381
+
382
+        // Fallback to make sure the displayname is set. Some clients may refuse
383
+        // to work with addressbooks not having a displayname.
384
+        if (is_null($values['displayname'])) {
385
+            $values['displayname'] = $url;
386
+        }
387
+
388
+        [$addressBookId, $addressBookRow] = $this->atomic(function () use ($values) {
389
+            $query = $this->db->getQueryBuilder();
390
+            $query->insert('addressbooks')
391
+                ->values([
392
+                    'uri' => $query->createParameter('uri'),
393
+                    'displayname' => $query->createParameter('displayname'),
394
+                    'description' => $query->createParameter('description'),
395
+                    'principaluri' => $query->createParameter('principaluri'),
396
+                    'synctoken' => $query->createParameter('synctoken'),
397
+                ])
398
+                ->setParameters($values)
399
+                ->executeStatement();
400
+
401
+            $addressBookId = $query->getLastInsertId();
402
+            return [
403
+                $addressBookId,
404
+                $this->getAddressBookById($addressBookId),
405
+            ];
406
+        }, $this->db);
407
+
408
+        $this->dispatcher->dispatchTyped(new AddressBookCreatedEvent($addressBookId, $addressBookRow));
409
+
410
+        return $addressBookId;
411
+    }
412
+
413
+    /**
414
+     * Deletes an entire addressbook and all its contents
415
+     *
416
+     * @param mixed $addressBookId
417
+     * @return void
418
+     */
419
+    public function deleteAddressBook($addressBookId) {
420
+        $this->atomic(function () use ($addressBookId): void {
421
+            $addressBookId = (int)$addressBookId;
422
+            $addressBookData = $this->getAddressBookById($addressBookId);
423
+            $shares = $this->getShares($addressBookId);
424
+
425
+            $query = $this->db->getQueryBuilder();
426
+            $query->delete($this->dbCardsTable)
427
+                ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
428
+                ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
429
+                ->executeStatement();
430
+
431
+            $query = $this->db->getQueryBuilder();
432
+            $query->delete('addressbookchanges')
433
+                ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
434
+                ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
435
+                ->executeStatement();
436
+
437
+            $query = $this->db->getQueryBuilder();
438
+            $query->delete('addressbooks')
439
+                ->where($query->expr()->eq('id', $query->createParameter('id')))
440
+                ->setParameter('id', $addressBookId, IQueryBuilder::PARAM_INT)
441
+                ->executeStatement();
442
+
443
+            $this->sharingBackend->deleteAllShares($addressBookId);
444
+
445
+            $query = $this->db->getQueryBuilder();
446
+            $query->delete($this->dbCardsPropertiesTable)
447
+                ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
448
+                ->executeStatement();
449
+
450
+            if ($addressBookData) {
451
+                $this->dispatcher->dispatchTyped(new AddressBookDeletedEvent($addressBookId, $addressBookData, $shares));
452
+            }
453
+        }, $this->db);
454
+    }
455
+
456
+    /**
457
+     * Returns all cards for a specific addressbook id.
458
+     *
459
+     * This method should return the following properties for each card:
460
+     *   * carddata - raw vcard data
461
+     *   * uri - Some unique url
462
+     *   * lastmodified - A unix timestamp
463
+     *
464
+     * It's recommended to also return the following properties:
465
+     *   * etag - A unique etag. This must change every time the card changes.
466
+     *   * size - The size of the card in bytes.
467
+     *
468
+     * If these last two properties are provided, less time will be spent
469
+     * calculating them. If they are specified, you can also omit carddata.
470
+     * This may speed up certain requests, especially with large cards.
471
+     *
472
+     * @param mixed $addressbookId
473
+     * @return array
474
+     */
475
+    public function getCards($addressbookId) {
476
+        $query = $this->db->getQueryBuilder();
477
+        $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
478
+            ->from($this->dbCardsTable)
479
+            ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId)));
480
+
481
+        $cards = [];
482
+
483
+        $result = $query->executeQuery();
484
+        while ($row = $result->fetchAssociative()) {
485
+            $row['etag'] = '"' . $row['etag'] . '"';
486
+
487
+            $modified = false;
488
+            $row['carddata'] = $this->readBlob($row['carddata'], $modified);
489
+            if ($modified) {
490
+                $row['size'] = strlen($row['carddata']);
491
+            }
492
+
493
+            $cards[] = $row;
494
+        }
495
+        $result->closeCursor();
496
+
497
+        return $cards;
498
+    }
499
+
500
+    /**
501
+     * Returns a specific card.
502
+     *
503
+     * The same set of properties must be returned as with getCards. The only
504
+     * exception is that 'carddata' is absolutely required.
505
+     *
506
+     * If the card does not exist, you must return false.
507
+     *
508
+     * @param mixed $addressBookId
509
+     * @param string $cardUri
510
+     * @return array
511
+     */
512
+    public function getCard($addressBookId, $cardUri) {
513
+        $query = $this->db->getQueryBuilder();
514
+        $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
515
+            ->from($this->dbCardsTable)
516
+            ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
517
+            ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
518
+            ->setMaxResults(1);
519
+
520
+        $result = $query->executeQuery();
521
+        $row = $result->fetchAssociative();
522
+        if (!$row) {
523
+            return false;
524
+        }
525
+        $row['etag'] = '"' . $row['etag'] . '"';
526
+
527
+        $modified = false;
528
+        $row['carddata'] = $this->readBlob($row['carddata'], $modified);
529
+        if ($modified) {
530
+            $row['size'] = strlen($row['carddata']);
531
+        }
532
+
533
+        return $row;
534
+    }
535
+
536
+    /**
537
+     * Returns a list of cards.
538
+     *
539
+     * This method should work identical to getCard, but instead return all the
540
+     * cards in the list as an array.
541
+     *
542
+     * If the backend supports this, it may allow for some speed-ups.
543
+     *
544
+     * @param mixed $addressBookId
545
+     * @param array $uris
546
+     * @return array
547
+     */
548
+    public function getMultipleCards($addressBookId, array $uris) {
549
+        if (empty($uris)) {
550
+            return [];
551
+        }
552
+
553
+        $chunks = array_chunk($uris, 100);
554
+        $cards = [];
555
+
556
+        $query = $this->db->getQueryBuilder();
557
+        $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
558
+            ->from($this->dbCardsTable)
559
+            ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
560
+            ->andWhere($query->expr()->in('uri', $query->createParameter('uri')));
561
+
562
+        foreach ($chunks as $uris) {
563
+            $query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY);
564
+            $result = $query->executeQuery();
565
+
566
+            while ($row = $result->fetchAssociative()) {
567
+                $row['etag'] = '"' . $row['etag'] . '"';
568
+
569
+                $modified = false;
570
+                $row['carddata'] = $this->readBlob($row['carddata'], $modified);
571
+                if ($modified) {
572
+                    $row['size'] = strlen($row['carddata']);
573
+                }
574
+
575
+                $cards[] = $row;
576
+            }
577
+            $result->closeCursor();
578
+        }
579
+        return $cards;
580
+    }
581
+
582
+    /**
583
+     * Creates a new card.
584
+     *
585
+     * The addressbook id will be passed as the first argument. This is the
586
+     * same id as it is returned from the getAddressBooksForUser method.
587
+     *
588
+     * The cardUri is a base uri, and doesn't include the full path. The
589
+     * cardData argument is the vcard body, and is passed as a string.
590
+     *
591
+     * It is possible to return an ETag from this method. This ETag is for the
592
+     * newly created resource, and must be enclosed with double quotes (that
593
+     * is, the string itself must contain the double quotes).
594
+     *
595
+     * You should only return the ETag if you store the carddata as-is. If a
596
+     * subsequent GET request on the same card does not have the same body,
597
+     * byte-by-byte and you did return an ETag here, clients tend to get
598
+     * confused.
599
+     *
600
+     * If you don't return an ETag, you can just return null.
601
+     *
602
+     * @param mixed $addressBookId
603
+     * @param string $cardUri
604
+     * @param string $cardData
605
+     * @param bool $checkAlreadyExists
606
+     * @return string
607
+     */
608
+    public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
609
+        $etag = md5($cardData);
610
+        $uid = $this->getUID($cardData);
611
+        return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
612
+            if ($checkAlreadyExists) {
613
+                $q = $this->db->getQueryBuilder();
614
+                $q->select('uid')
615
+                    ->from($this->dbCardsTable)
616
+                    ->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId)))
617
+                    ->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
618
+                    ->setMaxResults(1);
619
+                $result = $q->executeQuery();
620
+                $count = (bool)$result->fetchOne();
621
+                $result->closeCursor();
622
+                if ($count) {
623
+                    throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
624
+                }
625
+            }
626
+
627
+            $query = $this->db->getQueryBuilder();
628
+            $query->insert('cards')
629
+                ->values([
630
+                    'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB),
631
+                    'uri' => $query->createNamedParameter($cardUri),
632
+                    'lastmodified' => $query->createNamedParameter(time()),
633
+                    'addressbookid' => $query->createNamedParameter($addressBookId),
634
+                    'size' => $query->createNamedParameter(strlen($cardData)),
635
+                    'etag' => $query->createNamedParameter($etag),
636
+                    'uid' => $query->createNamedParameter($uid),
637
+                ])
638
+                ->executeStatement();
639
+
640
+            $etagCacheKey = "$addressBookId#$cardUri";
641
+            $this->etagCache[$etagCacheKey] = $etag;
642
+
643
+            $this->addChange($addressBookId, $cardUri, 1);
644
+            $this->updateProperties($addressBookId, $cardUri, $cardData);
645
+
646
+            $addressBookData = $this->getAddressBookById($addressBookId);
647
+            $shares = $this->getShares($addressBookId);
648
+            $objectRow = $this->getCard($addressBookId, $cardUri);
649
+            $this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
650
+
651
+            return '"' . $etag . '"';
652
+        }, $this->db);
653
+    }
654
+
655
+    /**
656
+     * Updates a card.
657
+     *
658
+     * The addressbook id will be passed as the first argument. This is the
659
+     * same id as it is returned from the getAddressBooksForUser method.
660
+     *
661
+     * The cardUri is a base uri, and doesn't include the full path. The
662
+     * cardData argument is the vcard body, and is passed as a string.
663
+     *
664
+     * It is possible to return an ETag from this method. This ETag should
665
+     * match that of the updated resource, and must be enclosed with double
666
+     * quotes (that is: the string itself must contain the actual quotes).
667
+     *
668
+     * You should only return the ETag if you store the carddata as-is. If a
669
+     * subsequent GET request on the same card does not have the same body,
670
+     * byte-by-byte and you did return an ETag here, clients tend to get
671
+     * confused.
672
+     *
673
+     * If you don't return an ETag, you can just return null.
674
+     *
675
+     * @param mixed $addressBookId
676
+     * @param string $cardUri
677
+     * @param string $cardData
678
+     * @return string
679
+     */
680
+    public function updateCard($addressBookId, $cardUri, $cardData) {
681
+        $uid = $this->getUID($cardData);
682
+        $etag = md5($cardData);
683
+
684
+        return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $uid, $etag) {
685
+            $query = $this->db->getQueryBuilder();
686
+
687
+            // check for recently stored etag and stop if it is the same
688
+            $etagCacheKey = "$addressBookId#$cardUri";
689
+            if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) {
690
+                return '"' . $etag . '"';
691
+            }
692
+
693
+            $query->update($this->dbCardsTable)
694
+                ->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB))
695
+                ->set('lastmodified', $query->createNamedParameter(time()))
696
+                ->set('size', $query->createNamedParameter(strlen($cardData)))
697
+                ->set('etag', $query->createNamedParameter($etag))
698
+                ->set('uid', $query->createNamedParameter($uid))
699
+                ->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
700
+                ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
701
+                ->executeStatement();
702
+
703
+            $this->etagCache[$etagCacheKey] = $etag;
704
+
705
+            $this->addChange($addressBookId, $cardUri, 2);
706
+            $this->updateProperties($addressBookId, $cardUri, $cardData);
707
+
708
+            $addressBookData = $this->getAddressBookById($addressBookId);
709
+            $shares = $this->getShares($addressBookId);
710
+            $objectRow = $this->getCard($addressBookId, $cardUri);
711
+            $this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
712
+            return '"' . $etag . '"';
713
+        }, $this->db);
714
+    }
715
+
716
+    /**
717
+     * @throws Exception
718
+     */
719
+    public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int $targetAddressBookId, string $tragetObjectUri): bool {
720
+        return $this->atomic(function () use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) {
721
+            $card = $this->getCard($sourceAddressBookId, $sourceObjectUri);
722
+            if (empty($card)) {
723
+                return false;
724
+            }
725
+            $sourceObjectId = (int)$card['id'];
726
+
727
+            $query = $this->db->getQueryBuilder();
728
+            $query->update('cards')
729
+                ->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT))
730
+                ->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR))
731
+                ->where($query->expr()->eq('uri', $query->createNamedParameter($sourceObjectUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
732
+                ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($sourceAddressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
733
+                ->executeStatement();
734
+
735
+            $this->purgeProperties($sourceAddressBookId, $sourceObjectId);
736
+            $this->updateProperties($targetAddressBookId, $tragetObjectUri, $card['carddata']);
737
+
738
+            $this->addChange($sourceAddressBookId, $sourceObjectUri, 3);
739
+            $this->addChange($targetAddressBookId, $tragetObjectUri, 1);
740
+
741
+            $card = $this->getCard($targetAddressBookId, $tragetObjectUri);
742
+            // Card wasn't found - possibly because it was deleted in the meantime by a different client
743
+            if (empty($card)) {
744
+                return false;
745
+            }
746
+            $targetAddressBookRow = $this->getAddressBookById($targetAddressBookId);
747
+            // the address book this card is being moved to does not exist any longer
748
+            if (empty($targetAddressBookRow)) {
749
+                return false;
750
+            }
751
+
752
+            $sourceShares = $this->getShares($sourceAddressBookId);
753
+            $targetShares = $this->getShares($targetAddressBookId);
754
+            $sourceAddressBookRow = $this->getAddressBookById($sourceAddressBookId);
755
+            $this->dispatcher->dispatchTyped(new CardMovedEvent($sourceAddressBookId, $sourceAddressBookRow, $targetAddressBookId, $targetAddressBookRow, $sourceShares, $targetShares, $card));
756
+            return true;
757
+        }, $this->db);
758
+    }
759
+
760
+    /**
761
+     * Deletes a card
762
+     *
763
+     * @param mixed $addressBookId
764
+     * @param string $cardUri
765
+     * @return bool
766
+     */
767
+    public function deleteCard($addressBookId, $cardUri) {
768
+        return $this->atomic(function () use ($addressBookId, $cardUri) {
769
+            $addressBookData = $this->getAddressBookById($addressBookId);
770
+            $shares = $this->getShares($addressBookId);
771
+            $objectRow = $this->getCard($addressBookId, $cardUri);
772
+
773
+            try {
774
+                $cardId = $this->getCardId($addressBookId, $cardUri);
775
+            } catch (\InvalidArgumentException $e) {
776
+                $cardId = null;
777
+            }
778
+            $query = $this->db->getQueryBuilder();
779
+            $ret = $query->delete($this->dbCardsTable)
780
+                ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
781
+                ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
782
+                ->executeStatement();
783
+
784
+            $this->addChange($addressBookId, $cardUri, 3);
785
+
786
+            if ($ret === 1) {
787
+                if ($cardId !== null) {
788
+                    $this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow));
789
+                    $this->purgeProperties($addressBookId, $cardId);
790
+                }
791
+                return true;
792
+            }
793
+
794
+            return false;
795
+        }, $this->db);
796
+    }
797
+
798
+    /**
799
+     * The getChanges method returns all the changes that have happened, since
800
+     * the specified syncToken in the specified address book.
801
+     *
802
+     * This function should return an array, such as the following:
803
+     *
804
+     * [
805
+     *   'syncToken' => 'The current synctoken',
806
+     *   'added'   => [
807
+     *      'new.txt',
808
+     *   ],
809
+     *   'modified'   => [
810
+     *      'modified.txt',
811
+     *   ],
812
+     *   'deleted' => [
813
+     *      'foo.php.bak',
814
+     *      'old.txt'
815
+     *   ]
816
+     * ];
817
+     *
818
+     * The returned syncToken property should reflect the *current* syncToken
819
+     * of the calendar, as reported in the {http://sabredav.org/ns}sync-token
820
+     * property. This is needed here too, to ensure the operation is atomic.
821
+     *
822
+     * If the $syncToken argument is specified as null, this is an initial
823
+     * sync, and all members should be reported.
824
+     *
825
+     * The modified property is an array of nodenames that have changed since
826
+     * the last token.
827
+     *
828
+     * The deleted property is an array with nodenames, that have been deleted
829
+     * from collection.
830
+     *
831
+     * The $syncLevel argument is basically the 'depth' of the report. If it's
832
+     * 1, you only have to report changes that happened only directly in
833
+     * immediate descendants. If it's 2, it should also include changes from
834
+     * the nodes below the child collections. (grandchildren)
835
+     *
836
+     * The $limit argument allows a client to specify how many results should
837
+     * be returned at most. If the limit is not specified, it should be treated
838
+     * as infinite.
839
+     *
840
+     * If the limit (infinite or not) is higher than you're willing to return,
841
+     * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception.
842
+     *
843
+     * If the syncToken is expired (due to data cleanup) or unknown, you must
844
+     * return null.
845
+     *
846
+     * The limit is 'suggestive'. You are free to ignore it.
847
+     *
848
+     * @param string $addressBookId
849
+     * @param string $syncToken
850
+     * @param int $syncLevel
851
+     * @param int|null $limit
852
+     * @return array
853
+     */
854
+    public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
855
+        $maxLimit = $this->config->getSystemValueInt('carddav_sync_request_truncation', 2500);
856
+        $limit = ($limit === null) ? $maxLimit : min($limit, $maxLimit);
857
+        // Current synctoken
858
+        return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) {
859
+            $qb = $this->db->getQueryBuilder();
860
+            $qb->select('synctoken')
861
+                ->from('addressbooks')
862
+                ->where(
863
+                    $qb->expr()->eq('id', $qb->createNamedParameter($addressBookId))
864
+                );
865
+            $stmt = $qb->executeQuery();
866
+            $currentToken = $stmt->fetchOne();
867
+            $stmt->closeCursor();
868
+
869
+            if (is_null($currentToken)) {
870
+                return [];
871
+            }
872
+
873
+            $result = [
874
+                'syncToken' => $currentToken,
875
+                'added' => [],
876
+                'modified' => [],
877
+                'deleted' => [],
878
+            ];
879
+            if (str_starts_with($syncToken, 'init_')) {
880
+                $syncValues = explode('_', $syncToken);
881
+                $lastID = $syncValues[1];
882
+                $initialSyncToken = $syncValues[2];
883
+                $qb = $this->db->getQueryBuilder();
884
+                $qb->select('id', 'uri')
885
+                    ->from('cards')
886
+                    ->where(
887
+                        $qb->expr()->andX(
888
+                            $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)),
889
+                            $qb->expr()->gt('id', $qb->createNamedParameter($lastID)))
890
+                    )->orderBy('id')
891
+                    ->setMaxResults($limit);
892
+                $stmt = $qb->executeQuery();
893
+                $values = $stmt->fetchAllAssociative();
894
+                $stmt->closeCursor();
895
+                if (count($values) === 0) {
896
+                    $result['syncToken'] = $initialSyncToken;
897
+                    $result['result_truncated'] = false;
898
+                    $result['added'] = [];
899
+                } else {
900
+                    $lastID = $values[array_key_last($values)]['id'];
901
+                    $result['added'] = array_column($values, 'uri');
902
+                    $result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken ;
903
+                    $result['result_truncated'] = count($result['added']) >= $limit;
904
+                }
905
+            } elseif ($syncToken) {
906
+                $qb = $this->db->getQueryBuilder();
907
+                $qb->select('uri', 'operation', 'synctoken')
908
+                    ->from('addressbookchanges')
909
+                    ->where(
910
+                        $qb->expr()->andX(
911
+                            $qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)),
912
+                            $qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)),
913
+                            $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
914
+                        )
915
+                    )->orderBy('synctoken');
916
+
917
+                if ($limit > 0) {
918
+                    $qb->setMaxResults($limit);
919
+                }
920
+
921
+                // Fetching all changes
922
+                $stmt = $qb->executeQuery();
923
+                $rowCount = $stmt->rowCount();
924
+
925
+                $changes = [];
926
+                $highestSyncToken = 0;
927
+
928
+                // This loop ensures that any duplicates are overwritten, only the
929
+                // last change on a node is relevant.
930
+                while ($row = $stmt->fetchAssociative()) {
931
+                    $changes[$row['uri']] = $row['operation'];
932
+                    $highestSyncToken = $row['synctoken'];
933
+                }
934
+
935
+                $stmt->closeCursor();
936
+
937
+                // No changes found, use current token
938
+                if (empty($changes)) {
939
+                    $result['syncToken'] = $currentToken;
940
+                }
941
+
942
+                foreach ($changes as $uri => $operation) {
943
+                    switch ($operation) {
944
+                        case 1:
945
+                            $result['added'][] = $uri;
946
+                            break;
947
+                        case 2:
948
+                            $result['modified'][] = $uri;
949
+                            break;
950
+                        case 3:
951
+                            $result['deleted'][] = $uri;
952
+                            break;
953
+                    }
954
+                }
955
+
956
+                /*
957 957
 				 * The synctoken in oc_addressbooks is always the highest synctoken in oc_addressbookchanges for a given addressbook plus one (see addChange).
958 958
 				 *
959 959
 				 * For truncated results, it is expected that we return the highest token from the response, so the client can continue from the latest change.
@@ -962,574 +962,574 @@  discard block
 block discarded – undo
962 962
 				 *
963 963
 				 * Therefore, we differentiate between truncated and non-truncated results when returning the synctoken.
964 964
 				 */
965
-				if ($rowCount === $limit && $highestSyncToken < $currentToken) {
966
-					$result['syncToken'] = $highestSyncToken;
967
-					$result['result_truncated'] = true;
968
-				}
969
-			} else {
970
-				$qb = $this->db->getQueryBuilder();
971
-				$qb->select('id', 'uri')
972
-					->from('cards')
973
-					->where(
974
-						$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
975
-					);
976
-				// No synctoken supplied, this is the initial sync.
977
-				$qb->setMaxResults($limit);
978
-				$stmt = $qb->executeQuery();
979
-				$values = $stmt->fetchAllAssociative();
980
-				if (empty($values)) {
981
-					$result['added'] = [];
982
-					return $result;
983
-				}
984
-				$lastID = $values[array_key_last($values)]['id'];
985
-				if (count($values) >= $limit) {
986
-					$result['syncToken'] = 'init_' . $lastID . '_' . $currentToken;
987
-					$result['result_truncated'] = true;
988
-				}
989
-
990
-				$result['added'] = array_column($values, 'uri');
991
-
992
-				$stmt->closeCursor();
993
-			}
994
-			return $result;
995
-		}, $this->db);
996
-	}
997
-
998
-	/**
999
-	 * Adds a change record to the addressbookchanges table.
1000
-	 *
1001
-	 * @param mixed $addressBookId
1002
-	 * @param string $objectUri
1003
-	 * @param int $operation 1 = add, 2 = modify, 3 = delete
1004
-	 * @return void
1005
-	 */
1006
-	protected function addChange(int $addressBookId, string $objectUri, int $operation): void {
1007
-		$this->atomic(function () use ($addressBookId, $objectUri, $operation): void {
1008
-			$query = $this->db->getQueryBuilder();
1009
-			$query->select('synctoken')
1010
-				->from('addressbooks')
1011
-				->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)));
1012
-			$result = $query->executeQuery();
1013
-			$syncToken = (int)$result->fetchOne();
1014
-			$result->closeCursor();
1015
-
1016
-			$query = $this->db->getQueryBuilder();
1017
-			$query->insert('addressbookchanges')
1018
-				->values([
1019
-					'uri' => $query->createNamedParameter($objectUri),
1020
-					'synctoken' => $query->createNamedParameter($syncToken),
1021
-					'addressbookid' => $query->createNamedParameter($addressBookId),
1022
-					'operation' => $query->createNamedParameter($operation),
1023
-					'created_at' => time(),
1024
-				])
1025
-				->executeStatement();
1026
-
1027
-			$query = $this->db->getQueryBuilder();
1028
-			$query->update('addressbooks')
1029
-				->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT))
1030
-				->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
1031
-				->executeStatement();
1032
-		}, $this->db);
1033
-	}
1034
-
1035
-	/**
1036
-	 * @param resource|string $cardData
1037
-	 * @param bool $modified
1038
-	 * @return string
1039
-	 */
1040
-	private function readBlob($cardData, &$modified = false) {
1041
-		if (is_resource($cardData)) {
1042
-			$cardData = stream_get_contents($cardData);
1043
-		}
1044
-
1045
-		// Micro optimisation
1046
-		// don't loop through
1047
-		if (str_starts_with($cardData, 'PHOTO:data:')) {
1048
-			return $cardData;
1049
-		}
1050
-
1051
-		$cardDataArray = explode("\r\n", $cardData);
1052
-
1053
-		$cardDataFiltered = [];
1054
-		$removingPhoto = false;
1055
-		foreach ($cardDataArray as $line) {
1056
-			if (str_starts_with($line, 'PHOTO:data:')
1057
-				&& !str_starts_with($line, 'PHOTO:data:image/')) {
1058
-				// Filter out PHOTO data of non-images
1059
-				$removingPhoto = true;
1060
-				$modified = true;
1061
-				continue;
1062
-			}
1063
-
1064
-			if ($removingPhoto) {
1065
-				if (str_starts_with($line, ' ')) {
1066
-					continue;
1067
-				}
1068
-				// No leading space means this is a new property
1069
-				$removingPhoto = false;
1070
-			}
1071
-
1072
-			$cardDataFiltered[] = $line;
1073
-		}
1074
-		return implode("\r\n", $cardDataFiltered);
1075
-	}
1076
-
1077
-	/**
1078
-	 * @param list<array{href: string, commonName: string, readOnly: bool}> $add
1079
-	 * @param list<string> $remove
1080
-	 */
1081
-	public function updateShares(IShareable $shareable, array $add, array $remove): void {
1082
-		$this->atomic(function () use ($shareable, $add, $remove): void {
1083
-			$addressBookId = $shareable->getResourceId();
1084
-			$addressBookData = $this->getAddressBookById($addressBookId);
1085
-			$oldShares = $this->getShares($addressBookId);
1086
-
1087
-			$this->sharingBackend->updateShares($shareable, $add, $remove, $oldShares);
1088
-
1089
-			$this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove));
1090
-		}, $this->db);
1091
-	}
1092
-
1093
-	/**
1094
-	 * Delete all of a user's shares
1095
-	 */
1096
-	public function deleteAllSharesByUser(string $principaluri): void {
1097
-		$this->sharingBackend->deleteAllSharesByUser($principaluri);
1098
-	}
1099
-
1100
-	/**
1101
-	 * Search contacts in a specific address-book
1102
-	 *
1103
-	 * @param int $addressBookId
1104
-	 * @param string $pattern which should match within the $searchProperties
1105
-	 * @param array $searchProperties defines the properties within the query pattern should match
1106
-	 * @param array $options = array() to define the search behavior
1107
-	 *                       - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array
1108
-	 *                       - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are
1109
-	 *                       - 'limit' - Set a numeric limit for the search results
1110
-	 *                       - 'offset' - Set the offset for the limited search results
1111
-	 *                       - 'wildcard' - Whether the search should use wildcards
1112
-	 * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options
1113
-	 * @return array an array of contacts which are arrays of key-value-pairs
1114
-	 */
1115
-	public function search($addressBookId, $pattern, $searchProperties, $options = []): array {
1116
-		return $this->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) {
1117
-			return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options);
1118
-		}, $this->db);
1119
-	}
1120
-
1121
-	/**
1122
-	 * Search contacts in all address-books accessible by a user
1123
-	 *
1124
-	 * @param string $principalUri
1125
-	 * @param string $pattern
1126
-	 * @param array $searchProperties
1127
-	 * @param array $options
1128
-	 * @return array
1129
-	 */
1130
-	public function searchPrincipalUri(string $principalUri,
1131
-		string $pattern,
1132
-		array $searchProperties,
1133
-		array $options = []): array {
1134
-		return $this->atomic(function () use ($principalUri, $pattern, $searchProperties, $options) {
1135
-			$addressBookIds = array_map(static function ($row):int {
1136
-				return (int)$row['id'];
1137
-			}, $this->getAddressBooksForUser($principalUri));
1138
-
1139
-			return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options);
1140
-		}, $this->db);
1141
-	}
1142
-
1143
-	/**
1144
-	 * @param int[] $addressBookIds
1145
-	 * @param string $pattern
1146
-	 * @param array $searchProperties
1147
-	 * @param array $options
1148
-	 * @psalm-param array{
1149
-	 *   types?: bool,
1150
-	 *   escape_like_param?: bool,
1151
-	 *   limit?: int,
1152
-	 *   offset?: int,
1153
-	 *   wildcard?: bool,
1154
-	 *   since?: DateTimeFilter|null,
1155
-	 *   until?: DateTimeFilter|null,
1156
-	 *   person?: string
1157
-	 * } $options
1158
-	 * @return array
1159
-	 */
1160
-	private function searchByAddressBookIds(array $addressBookIds,
1161
-		string $pattern,
1162
-		array $searchProperties,
1163
-		array $options = []): array {
1164
-		if (empty($addressBookIds)) {
1165
-			return [];
1166
-		}
1167
-		$escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false;
1168
-		$useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false;
1169
-
1170
-		if ($escapePattern) {
1171
-			$searchProperties = array_filter($searchProperties, function ($property) use ($pattern) {
1172
-				if ($property === 'EMAIL' && str_contains($pattern, ' ')) {
1173
-					// There can be no spaces in emails
1174
-					return false;
1175
-				}
1176
-
1177
-				if ($property === 'CLOUD' && preg_match('/[^a-zA-Z0-9 :_.@\/\-\']/', $pattern) === 1) {
1178
-					// There can be no chars in cloud ids which are not valid for user ids plus :/
1179
-					// worst case: CA61590A-BBBC-423E-84AF-E6DF01455A53@https://my.nxt/srv/
1180
-					return false;
1181
-				}
1182
-
1183
-				return true;
1184
-			});
1185
-		}
1186
-
1187
-		if (empty($searchProperties)) {
1188
-			return [];
1189
-		}
1190
-
1191
-		$query2 = $this->db->getQueryBuilder();
1192
-		$query2->selectDistinct('cp.cardid')
1193
-			->from($this->dbCardsPropertiesTable, 'cp')
1194
-			->where($query2->expr()->in('cp.addressbookid', $query2->createNamedParameter($addressBookIds, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
1195
-			->andWhere($query2->expr()->in('cp.name', $query2->createNamedParameter($searchProperties, IQueryBuilder::PARAM_STR_ARRAY)));
1196
-
1197
-		// No need for like when the pattern is empty
1198
-		if ($pattern !== '') {
1199
-			if (!$useWildcards) {
1200
-				$query2->andWhere($query2->expr()->eq('cp.value', $query2->createNamedParameter($pattern)));
1201
-			} elseif (!$escapePattern) {
1202
-				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter($pattern)));
1203
-			} else {
1204
-				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
1205
-			}
1206
-		}
1207
-		if (isset($options['limit'])) {
1208
-			$query2->setMaxResults($options['limit']);
1209
-		}
1210
-		if (isset($options['offset'])) {
1211
-			$query2->setFirstResult($options['offset']);
1212
-		}
1213
-
1214
-		if (isset($options['person'])) {
1215
-			$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($options['person']) . '%')));
1216
-		}
1217
-		if (isset($options['since']) || isset($options['until'])) {
1218
-			$query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid');
1219
-			$query2->andWhere($query2->expr()->eq('cp_bday.name', $query2->createNamedParameter('BDAY')));
1220
-			/**
1221
-			 * FIXME Find a way to match only 4 last digits
1222
-			 * BDAY can be --1018 without year or 20001019 with it
1223
-			 * $bDayOr = [];
1224
-			 * if ($options['since'] instanceof DateTimeFilter) {
1225
-			 * $bDayOr[] =
1226
-			 * $query2->expr()->gte('SUBSTR(cp_bday.value, -4)',
1227
-			 * $query2->createNamedParameter($options['since']->get()->format('md'))
1228
-			 * );
1229
-			 * }
1230
-			 * if ($options['until'] instanceof DateTimeFilter) {
1231
-			 * $bDayOr[] =
1232
-			 * $query2->expr()->lte('SUBSTR(cp_bday.value, -4)',
1233
-			 * $query2->createNamedParameter($options['until']->get()->format('md'))
1234
-			 * );
1235
-			 * }
1236
-			 * $query2->andWhere($query2->expr()->orX(...$bDayOr));
1237
-			 */
1238
-		}
1239
-
1240
-		$result = $query2->executeQuery();
1241
-		$matches = $result->fetchAllAssociative();
1242
-		$result->closeCursor();
1243
-		$matches = array_map(function ($match) {
1244
-			return (int)$match['cardid'];
1245
-		}, $matches);
1246
-
1247
-		$cardResults = [];
1248
-		$query = $this->db->getQueryBuilder();
1249
-		$query->select('c.addressbookid', 'c.carddata', 'c.uri')
1250
-			->from($this->dbCardsTable, 'c')
1251
-			->where($query->expr()->in('c.id', $query->createParameter('matches')));
1252
-
1253
-		foreach (array_chunk($matches, 1000) as $matchesChunk) {
1254
-			$query->setParameter('matches', $matchesChunk, IQueryBuilder::PARAM_INT_ARRAY);
1255
-			$result = $query->executeQuery();
1256
-			$cardResults[] = $result->fetchAllAssociative();
1257
-			$result->closeCursor();
1258
-		}
1259
-
1260
-		$cards = array_merge(...$cardResults);
1261
-		return array_map(function ($array) {
1262
-			$array['addressbookid'] = (int)$array['addressbookid'];
1263
-			$modified = false;
1264
-			$array['carddata'] = $this->readBlob($array['carddata'], $modified);
1265
-			if ($modified) {
1266
-				$array['size'] = strlen($array['carddata']);
1267
-			}
1268
-			return $array;
1269
-		}, $cards);
1270
-	}
1271
-
1272
-	/**
1273
-	 * @param int $bookId
1274
-	 * @param string $name
1275
-	 * @return array
1276
-	 */
1277
-	public function collectCardProperties($bookId, $name) {
1278
-		$query = $this->db->getQueryBuilder();
1279
-		$result = $query->selectDistinct('value')
1280
-			->from($this->dbCardsPropertiesTable)
1281
-			->where($query->expr()->eq('name', $query->createNamedParameter($name)))
1282
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId)))
1283
-			->executeQuery();
1284
-
1285
-		$all = $result->fetchFirstColumn();
1286
-		$result->closeCursor();
1287
-
1288
-		return $all;
1289
-	}
1290
-
1291
-	/**
1292
-	 * get URI from a given contact
1293
-	 *
1294
-	 * @param int $id
1295
-	 * @return string
1296
-	 */
1297
-	public function getCardUri($id) {
1298
-		$query = $this->db->getQueryBuilder();
1299
-		$query->select('uri')->from($this->dbCardsTable)
1300
-			->where($query->expr()->eq('id', $query->createParameter('id')))
1301
-			->setParameter('id', $id);
1302
-
1303
-		$result = $query->executeQuery();
1304
-		$uri = $result->fetchAssociative();
1305
-		$result->closeCursor();
1306
-
1307
-		if (!isset($uri['uri'])) {
1308
-			throw new \InvalidArgumentException('Card does not exists: ' . $id);
1309
-		}
1310
-
1311
-		return $uri['uri'];
1312
-	}
1313
-
1314
-	/**
1315
-	 * return contact with the given URI
1316
-	 *
1317
-	 * @param int $addressBookId
1318
-	 * @param string $uri
1319
-	 * @returns array
1320
-	 */
1321
-	public function getContact($addressBookId, $uri) {
1322
-		$result = [];
1323
-		$query = $this->db->getQueryBuilder();
1324
-		$query->select('*')->from($this->dbCardsTable)
1325
-			->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1326
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1327
-		$queryResult = $query->executeQuery();
1328
-		$contact = $queryResult->fetchAssociative();
1329
-		$queryResult->closeCursor();
1330
-
1331
-		if (is_array($contact)) {
1332
-			$modified = false;
1333
-			$contact['etag'] = '"' . $contact['etag'] . '"';
1334
-			$contact['carddata'] = $this->readBlob($contact['carddata'], $modified);
1335
-			if ($modified) {
1336
-				$contact['size'] = strlen($contact['carddata']);
1337
-			}
1338
-
1339
-			$result = $contact;
1340
-		}
1341
-
1342
-		return $result;
1343
-	}
1344
-
1345
-	/**
1346
-	 * Returns the list of people whom this address book is shared with.
1347
-	 *
1348
-	 * Every element in this array should have the following properties:
1349
-	 *   * href - Often a mailto: address
1350
-	 *   * commonName - Optional, for example a first + last name
1351
-	 *   * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants.
1352
-	 *   * readOnly - boolean
1353
-	 *
1354
-	 * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}>
1355
-	 */
1356
-	public function getShares(int $addressBookId): array {
1357
-		return $this->sharingBackend->getShares($addressBookId);
1358
-	}
1359
-
1360
-	/**
1361
-	 * update properties table
1362
-	 *
1363
-	 * @param int $addressBookId
1364
-	 * @param string $cardUri
1365
-	 * @param string $vCardSerialized
1366
-	 */
1367
-	protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) {
1368
-		$this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void {
1369
-			$cardId = $this->getCardId($addressBookId, $cardUri);
1370
-			$vCard = $this->readCard($vCardSerialized);
1371
-
1372
-			$this->purgeProperties($addressBookId, $cardId);
1373
-
1374
-			$query = $this->db->getQueryBuilder();
1375
-			$query->insert($this->dbCardsPropertiesTable)
1376
-				->values(
1377
-					[
1378
-						'addressbookid' => $query->createNamedParameter($addressBookId),
1379
-						'cardid' => $query->createNamedParameter($cardId),
1380
-						'name' => $query->createParameter('name'),
1381
-						'value' => $query->createParameter('value'),
1382
-						'preferred' => $query->createParameter('preferred')
1383
-					]
1384
-				);
1385
-
1386
-			foreach ($vCard->children() as $property) {
1387
-				if (!in_array($property->name, self::$indexProperties)) {
1388
-					continue;
1389
-				}
1390
-				$preferred = 0;
1391
-				foreach ($property->parameters as $parameter) {
1392
-					if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') {
1393
-						$preferred = 1;
1394
-						break;
1395
-					}
1396
-				}
1397
-				$query->setParameter('name', $property->name);
1398
-				$query->setParameter('value', mb_strcut($property->getValue(), 0, 254));
1399
-				$query->setParameter('preferred', $preferred);
1400
-				$query->executeStatement();
1401
-			}
1402
-		}, $this->db);
1403
-	}
1404
-
1405
-	/**
1406
-	 * read vCard data into a vCard object
1407
-	 *
1408
-	 * @param string $cardData
1409
-	 * @return VCard
1410
-	 */
1411
-	protected function readCard($cardData) {
1412
-		return Reader::read($cardData);
1413
-	}
1414
-
1415
-	/**
1416
-	 * delete all properties from a given card
1417
-	 *
1418
-	 * @param int $addressBookId
1419
-	 * @param int $cardId
1420
-	 */
1421
-	protected function purgeProperties($addressBookId, $cardId) {
1422
-		$query = $this->db->getQueryBuilder();
1423
-		$query->delete($this->dbCardsPropertiesTable)
1424
-			->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId)))
1425
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1426
-		$query->executeStatement();
1427
-	}
1428
-
1429
-	/**
1430
-	 * Get ID from a given contact
1431
-	 */
1432
-	protected function getCardId(int $addressBookId, string $uri): int {
1433
-		$query = $this->db->getQueryBuilder();
1434
-		$query->select('id')->from($this->dbCardsTable)
1435
-			->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1436
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1437
-
1438
-		$result = $query->executeQuery();
1439
-		$cardIds = $result->fetchAssociative();
1440
-		$result->closeCursor();
1441
-
1442
-		if (!isset($cardIds['id'])) {
1443
-			throw new \InvalidArgumentException('Card does not exists: ' . $uri);
1444
-		}
1445
-
1446
-		return (int)$cardIds['id'];
1447
-	}
1448
-
1449
-	/**
1450
-	 * For shared address books the sharee is set in the ACL of the address book
1451
-	 *
1452
-	 * @param int $addressBookId
1453
-	 * @param list<array{privilege: string, principal: string, protected: bool}> $acl
1454
-	 * @return list<array{privilege: string, principal: string, protected: bool}>
1455
-	 */
1456
-	public function applyShareAcl(int $addressBookId, array $acl): array {
1457
-		$shares = $this->sharingBackend->getShares($addressBookId);
1458
-		return $this->sharingBackend->applyShareAcl($shares, $acl);
1459
-	}
1460
-
1461
-	/**
1462
-	 * @throws \InvalidArgumentException
1463
-	 */
1464
-	public function pruneOutdatedSyncTokens(int $keep, int $retention): int {
1465
-		if ($keep < 0) {
1466
-			throw new \InvalidArgumentException();
1467
-		}
1468
-
1469
-		$query = $this->db->getQueryBuilder();
1470
-		$query->select($query->func()->max('id'))
1471
-			->from('addressbookchanges');
1472
-
1473
-		$result = $query->executeQuery();
1474
-		$maxId = (int)$result->fetchOne();
1475
-		$result->closeCursor();
1476
-		if (!$maxId || $maxId < $keep) {
1477
-			return 0;
1478
-		}
1479
-
1480
-		$query = $this->db->getQueryBuilder();
1481
-		$query->delete('addressbookchanges')
1482
-			->where(
1483
-				$query->expr()->lte('id', $query->createNamedParameter($maxId - $keep, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
1484
-				$query->expr()->lte('created_at', $query->createNamedParameter($retention)),
1485
-			);
1486
-		return $query->executeStatement();
1487
-	}
1488
-
1489
-	private function convertPrincipal(string $principalUri, bool $toV2): string {
1490
-		if ($this->principalBackend->getPrincipalPrefix() === 'principals') {
1491
-			[, $name] = \Sabre\Uri\split($principalUri);
1492
-			if ($toV2 === true) {
1493
-				return "principals/users/$name";
1494
-			}
1495
-			return "principals/$name";
1496
-		}
1497
-		return $principalUri;
1498
-	}
1499
-
1500
-	private function addOwnerPrincipal(array &$addressbookInfo): void {
1501
-		$ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
1502
-		$displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
1503
-		if (isset($addressbookInfo[$ownerPrincipalKey])) {
1504
-			$uri = $addressbookInfo[$ownerPrincipalKey];
1505
-		} else {
1506
-			$uri = $addressbookInfo['principaluri'];
1507
-		}
1508
-
1509
-		$principalInformation = $this->principalBackend->getPrincipalByPath($uri);
1510
-		if (isset($principalInformation['{DAV:}displayname'])) {
1511
-			$addressbookInfo[$displaynameKey] = $principalInformation['{DAV:}displayname'];
1512
-		}
1513
-	}
1514
-
1515
-	/**
1516
-	 * Extract UID from vcard
1517
-	 *
1518
-	 * @param string $cardData the vcard raw data
1519
-	 * @return string the uid
1520
-	 * @throws BadRequest if no UID is available or vcard is empty
1521
-	 */
1522
-	private function getUID(string $cardData): string {
1523
-		if ($cardData !== '') {
1524
-			$vCard = Reader::read($cardData);
1525
-			if ($vCard->UID) {
1526
-				$uid = $vCard->UID->getValue();
1527
-				return $uid;
1528
-			}
1529
-			// should already be handled, but just in case
1530
-			throw new BadRequest('vCards on CardDAV servers MUST have a UID property');
1531
-		}
1532
-		// should already be handled, but just in case
1533
-		throw new BadRequest('vCard can not be empty');
1534
-	}
965
+                if ($rowCount === $limit && $highestSyncToken < $currentToken) {
966
+                    $result['syncToken'] = $highestSyncToken;
967
+                    $result['result_truncated'] = true;
968
+                }
969
+            } else {
970
+                $qb = $this->db->getQueryBuilder();
971
+                $qb->select('id', 'uri')
972
+                    ->from('cards')
973
+                    ->where(
974
+                        $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
975
+                    );
976
+                // No synctoken supplied, this is the initial sync.
977
+                $qb->setMaxResults($limit);
978
+                $stmt = $qb->executeQuery();
979
+                $values = $stmt->fetchAllAssociative();
980
+                if (empty($values)) {
981
+                    $result['added'] = [];
982
+                    return $result;
983
+                }
984
+                $lastID = $values[array_key_last($values)]['id'];
985
+                if (count($values) >= $limit) {
986
+                    $result['syncToken'] = 'init_' . $lastID . '_' . $currentToken;
987
+                    $result['result_truncated'] = true;
988
+                }
989
+
990
+                $result['added'] = array_column($values, 'uri');
991
+
992
+                $stmt->closeCursor();
993
+            }
994
+            return $result;
995
+        }, $this->db);
996
+    }
997
+
998
+    /**
999
+     * Adds a change record to the addressbookchanges table.
1000
+     *
1001
+     * @param mixed $addressBookId
1002
+     * @param string $objectUri
1003
+     * @param int $operation 1 = add, 2 = modify, 3 = delete
1004
+     * @return void
1005
+     */
1006
+    protected function addChange(int $addressBookId, string $objectUri, int $operation): void {
1007
+        $this->atomic(function () use ($addressBookId, $objectUri, $operation): void {
1008
+            $query = $this->db->getQueryBuilder();
1009
+            $query->select('synctoken')
1010
+                ->from('addressbooks')
1011
+                ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)));
1012
+            $result = $query->executeQuery();
1013
+            $syncToken = (int)$result->fetchOne();
1014
+            $result->closeCursor();
1015
+
1016
+            $query = $this->db->getQueryBuilder();
1017
+            $query->insert('addressbookchanges')
1018
+                ->values([
1019
+                    'uri' => $query->createNamedParameter($objectUri),
1020
+                    'synctoken' => $query->createNamedParameter($syncToken),
1021
+                    'addressbookid' => $query->createNamedParameter($addressBookId),
1022
+                    'operation' => $query->createNamedParameter($operation),
1023
+                    'created_at' => time(),
1024
+                ])
1025
+                ->executeStatement();
1026
+
1027
+            $query = $this->db->getQueryBuilder();
1028
+            $query->update('addressbooks')
1029
+                ->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT))
1030
+                ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
1031
+                ->executeStatement();
1032
+        }, $this->db);
1033
+    }
1034
+
1035
+    /**
1036
+     * @param resource|string $cardData
1037
+     * @param bool $modified
1038
+     * @return string
1039
+     */
1040
+    private function readBlob($cardData, &$modified = false) {
1041
+        if (is_resource($cardData)) {
1042
+            $cardData = stream_get_contents($cardData);
1043
+        }
1044
+
1045
+        // Micro optimisation
1046
+        // don't loop through
1047
+        if (str_starts_with($cardData, 'PHOTO:data:')) {
1048
+            return $cardData;
1049
+        }
1050
+
1051
+        $cardDataArray = explode("\r\n", $cardData);
1052
+
1053
+        $cardDataFiltered = [];
1054
+        $removingPhoto = false;
1055
+        foreach ($cardDataArray as $line) {
1056
+            if (str_starts_with($line, 'PHOTO:data:')
1057
+                && !str_starts_with($line, 'PHOTO:data:image/')) {
1058
+                // Filter out PHOTO data of non-images
1059
+                $removingPhoto = true;
1060
+                $modified = true;
1061
+                continue;
1062
+            }
1063
+
1064
+            if ($removingPhoto) {
1065
+                if (str_starts_with($line, ' ')) {
1066
+                    continue;
1067
+                }
1068
+                // No leading space means this is a new property
1069
+                $removingPhoto = false;
1070
+            }
1071
+
1072
+            $cardDataFiltered[] = $line;
1073
+        }
1074
+        return implode("\r\n", $cardDataFiltered);
1075
+    }
1076
+
1077
+    /**
1078
+     * @param list<array{href: string, commonName: string, readOnly: bool}> $add
1079
+     * @param list<string> $remove
1080
+     */
1081
+    public function updateShares(IShareable $shareable, array $add, array $remove): void {
1082
+        $this->atomic(function () use ($shareable, $add, $remove): void {
1083
+            $addressBookId = $shareable->getResourceId();
1084
+            $addressBookData = $this->getAddressBookById($addressBookId);
1085
+            $oldShares = $this->getShares($addressBookId);
1086
+
1087
+            $this->sharingBackend->updateShares($shareable, $add, $remove, $oldShares);
1088
+
1089
+            $this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove));
1090
+        }, $this->db);
1091
+    }
1092
+
1093
+    /**
1094
+     * Delete all of a user's shares
1095
+     */
1096
+    public function deleteAllSharesByUser(string $principaluri): void {
1097
+        $this->sharingBackend->deleteAllSharesByUser($principaluri);
1098
+    }
1099
+
1100
+    /**
1101
+     * Search contacts in a specific address-book
1102
+     *
1103
+     * @param int $addressBookId
1104
+     * @param string $pattern which should match within the $searchProperties
1105
+     * @param array $searchProperties defines the properties within the query pattern should match
1106
+     * @param array $options = array() to define the search behavior
1107
+     *                       - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array
1108
+     *                       - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are
1109
+     *                       - 'limit' - Set a numeric limit for the search results
1110
+     *                       - 'offset' - Set the offset for the limited search results
1111
+     *                       - 'wildcard' - Whether the search should use wildcards
1112
+     * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options
1113
+     * @return array an array of contacts which are arrays of key-value-pairs
1114
+     */
1115
+    public function search($addressBookId, $pattern, $searchProperties, $options = []): array {
1116
+        return $this->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) {
1117
+            return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options);
1118
+        }, $this->db);
1119
+    }
1120
+
1121
+    /**
1122
+     * Search contacts in all address-books accessible by a user
1123
+     *
1124
+     * @param string $principalUri
1125
+     * @param string $pattern
1126
+     * @param array $searchProperties
1127
+     * @param array $options
1128
+     * @return array
1129
+     */
1130
+    public function searchPrincipalUri(string $principalUri,
1131
+        string $pattern,
1132
+        array $searchProperties,
1133
+        array $options = []): array {
1134
+        return $this->atomic(function () use ($principalUri, $pattern, $searchProperties, $options) {
1135
+            $addressBookIds = array_map(static function ($row):int {
1136
+                return (int)$row['id'];
1137
+            }, $this->getAddressBooksForUser($principalUri));
1138
+
1139
+            return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options);
1140
+        }, $this->db);
1141
+    }
1142
+
1143
+    /**
1144
+     * @param int[] $addressBookIds
1145
+     * @param string $pattern
1146
+     * @param array $searchProperties
1147
+     * @param array $options
1148
+     * @psalm-param array{
1149
+     *   types?: bool,
1150
+     *   escape_like_param?: bool,
1151
+     *   limit?: int,
1152
+     *   offset?: int,
1153
+     *   wildcard?: bool,
1154
+     *   since?: DateTimeFilter|null,
1155
+     *   until?: DateTimeFilter|null,
1156
+     *   person?: string
1157
+     * } $options
1158
+     * @return array
1159
+     */
1160
+    private function searchByAddressBookIds(array $addressBookIds,
1161
+        string $pattern,
1162
+        array $searchProperties,
1163
+        array $options = []): array {
1164
+        if (empty($addressBookIds)) {
1165
+            return [];
1166
+        }
1167
+        $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false;
1168
+        $useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false;
1169
+
1170
+        if ($escapePattern) {
1171
+            $searchProperties = array_filter($searchProperties, function ($property) use ($pattern) {
1172
+                if ($property === 'EMAIL' && str_contains($pattern, ' ')) {
1173
+                    // There can be no spaces in emails
1174
+                    return false;
1175
+                }
1176
+
1177
+                if ($property === 'CLOUD' && preg_match('/[^a-zA-Z0-9 :_.@\/\-\']/', $pattern) === 1) {
1178
+                    // There can be no chars in cloud ids which are not valid for user ids plus :/
1179
+                    // worst case: CA61590A-BBBC-423E-84AF-E6DF01455A53@https://my.nxt/srv/
1180
+                    return false;
1181
+                }
1182
+
1183
+                return true;
1184
+            });
1185
+        }
1186
+
1187
+        if (empty($searchProperties)) {
1188
+            return [];
1189
+        }
1190
+
1191
+        $query2 = $this->db->getQueryBuilder();
1192
+        $query2->selectDistinct('cp.cardid')
1193
+            ->from($this->dbCardsPropertiesTable, 'cp')
1194
+            ->where($query2->expr()->in('cp.addressbookid', $query2->createNamedParameter($addressBookIds, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
1195
+            ->andWhere($query2->expr()->in('cp.name', $query2->createNamedParameter($searchProperties, IQueryBuilder::PARAM_STR_ARRAY)));
1196
+
1197
+        // No need for like when the pattern is empty
1198
+        if ($pattern !== '') {
1199
+            if (!$useWildcards) {
1200
+                $query2->andWhere($query2->expr()->eq('cp.value', $query2->createNamedParameter($pattern)));
1201
+            } elseif (!$escapePattern) {
1202
+                $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter($pattern)));
1203
+            } else {
1204
+                $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
1205
+            }
1206
+        }
1207
+        if (isset($options['limit'])) {
1208
+            $query2->setMaxResults($options['limit']);
1209
+        }
1210
+        if (isset($options['offset'])) {
1211
+            $query2->setFirstResult($options['offset']);
1212
+        }
1213
+
1214
+        if (isset($options['person'])) {
1215
+            $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($options['person']) . '%')));
1216
+        }
1217
+        if (isset($options['since']) || isset($options['until'])) {
1218
+            $query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid');
1219
+            $query2->andWhere($query2->expr()->eq('cp_bday.name', $query2->createNamedParameter('BDAY')));
1220
+            /**
1221
+             * FIXME Find a way to match only 4 last digits
1222
+             * BDAY can be --1018 without year or 20001019 with it
1223
+             * $bDayOr = [];
1224
+             * if ($options['since'] instanceof DateTimeFilter) {
1225
+             * $bDayOr[] =
1226
+             * $query2->expr()->gte('SUBSTR(cp_bday.value, -4)',
1227
+             * $query2->createNamedParameter($options['since']->get()->format('md'))
1228
+             * );
1229
+             * }
1230
+             * if ($options['until'] instanceof DateTimeFilter) {
1231
+             * $bDayOr[] =
1232
+             * $query2->expr()->lte('SUBSTR(cp_bday.value, -4)',
1233
+             * $query2->createNamedParameter($options['until']->get()->format('md'))
1234
+             * );
1235
+             * }
1236
+             * $query2->andWhere($query2->expr()->orX(...$bDayOr));
1237
+             */
1238
+        }
1239
+
1240
+        $result = $query2->executeQuery();
1241
+        $matches = $result->fetchAllAssociative();
1242
+        $result->closeCursor();
1243
+        $matches = array_map(function ($match) {
1244
+            return (int)$match['cardid'];
1245
+        }, $matches);
1246
+
1247
+        $cardResults = [];
1248
+        $query = $this->db->getQueryBuilder();
1249
+        $query->select('c.addressbookid', 'c.carddata', 'c.uri')
1250
+            ->from($this->dbCardsTable, 'c')
1251
+            ->where($query->expr()->in('c.id', $query->createParameter('matches')));
1252
+
1253
+        foreach (array_chunk($matches, 1000) as $matchesChunk) {
1254
+            $query->setParameter('matches', $matchesChunk, IQueryBuilder::PARAM_INT_ARRAY);
1255
+            $result = $query->executeQuery();
1256
+            $cardResults[] = $result->fetchAllAssociative();
1257
+            $result->closeCursor();
1258
+        }
1259
+
1260
+        $cards = array_merge(...$cardResults);
1261
+        return array_map(function ($array) {
1262
+            $array['addressbookid'] = (int)$array['addressbookid'];
1263
+            $modified = false;
1264
+            $array['carddata'] = $this->readBlob($array['carddata'], $modified);
1265
+            if ($modified) {
1266
+                $array['size'] = strlen($array['carddata']);
1267
+            }
1268
+            return $array;
1269
+        }, $cards);
1270
+    }
1271
+
1272
+    /**
1273
+     * @param int $bookId
1274
+     * @param string $name
1275
+     * @return array
1276
+     */
1277
+    public function collectCardProperties($bookId, $name) {
1278
+        $query = $this->db->getQueryBuilder();
1279
+        $result = $query->selectDistinct('value')
1280
+            ->from($this->dbCardsPropertiesTable)
1281
+            ->where($query->expr()->eq('name', $query->createNamedParameter($name)))
1282
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId)))
1283
+            ->executeQuery();
1284
+
1285
+        $all = $result->fetchFirstColumn();
1286
+        $result->closeCursor();
1287
+
1288
+        return $all;
1289
+    }
1290
+
1291
+    /**
1292
+     * get URI from a given contact
1293
+     *
1294
+     * @param int $id
1295
+     * @return string
1296
+     */
1297
+    public function getCardUri($id) {
1298
+        $query = $this->db->getQueryBuilder();
1299
+        $query->select('uri')->from($this->dbCardsTable)
1300
+            ->where($query->expr()->eq('id', $query->createParameter('id')))
1301
+            ->setParameter('id', $id);
1302
+
1303
+        $result = $query->executeQuery();
1304
+        $uri = $result->fetchAssociative();
1305
+        $result->closeCursor();
1306
+
1307
+        if (!isset($uri['uri'])) {
1308
+            throw new \InvalidArgumentException('Card does not exists: ' . $id);
1309
+        }
1310
+
1311
+        return $uri['uri'];
1312
+    }
1313
+
1314
+    /**
1315
+     * return contact with the given URI
1316
+     *
1317
+     * @param int $addressBookId
1318
+     * @param string $uri
1319
+     * @returns array
1320
+     */
1321
+    public function getContact($addressBookId, $uri) {
1322
+        $result = [];
1323
+        $query = $this->db->getQueryBuilder();
1324
+        $query->select('*')->from($this->dbCardsTable)
1325
+            ->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1326
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1327
+        $queryResult = $query->executeQuery();
1328
+        $contact = $queryResult->fetchAssociative();
1329
+        $queryResult->closeCursor();
1330
+
1331
+        if (is_array($contact)) {
1332
+            $modified = false;
1333
+            $contact['etag'] = '"' . $contact['etag'] . '"';
1334
+            $contact['carddata'] = $this->readBlob($contact['carddata'], $modified);
1335
+            if ($modified) {
1336
+                $contact['size'] = strlen($contact['carddata']);
1337
+            }
1338
+
1339
+            $result = $contact;
1340
+        }
1341
+
1342
+        return $result;
1343
+    }
1344
+
1345
+    /**
1346
+     * Returns the list of people whom this address book is shared with.
1347
+     *
1348
+     * Every element in this array should have the following properties:
1349
+     *   * href - Often a mailto: address
1350
+     *   * commonName - Optional, for example a first + last name
1351
+     *   * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants.
1352
+     *   * readOnly - boolean
1353
+     *
1354
+     * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}>
1355
+     */
1356
+    public function getShares(int $addressBookId): array {
1357
+        return $this->sharingBackend->getShares($addressBookId);
1358
+    }
1359
+
1360
+    /**
1361
+     * update properties table
1362
+     *
1363
+     * @param int $addressBookId
1364
+     * @param string $cardUri
1365
+     * @param string $vCardSerialized
1366
+     */
1367
+    protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) {
1368
+        $this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void {
1369
+            $cardId = $this->getCardId($addressBookId, $cardUri);
1370
+            $vCard = $this->readCard($vCardSerialized);
1371
+
1372
+            $this->purgeProperties($addressBookId, $cardId);
1373
+
1374
+            $query = $this->db->getQueryBuilder();
1375
+            $query->insert($this->dbCardsPropertiesTable)
1376
+                ->values(
1377
+                    [
1378
+                        'addressbookid' => $query->createNamedParameter($addressBookId),
1379
+                        'cardid' => $query->createNamedParameter($cardId),
1380
+                        'name' => $query->createParameter('name'),
1381
+                        'value' => $query->createParameter('value'),
1382
+                        'preferred' => $query->createParameter('preferred')
1383
+                    ]
1384
+                );
1385
+
1386
+            foreach ($vCard->children() as $property) {
1387
+                if (!in_array($property->name, self::$indexProperties)) {
1388
+                    continue;
1389
+                }
1390
+                $preferred = 0;
1391
+                foreach ($property->parameters as $parameter) {
1392
+                    if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') {
1393
+                        $preferred = 1;
1394
+                        break;
1395
+                    }
1396
+                }
1397
+                $query->setParameter('name', $property->name);
1398
+                $query->setParameter('value', mb_strcut($property->getValue(), 0, 254));
1399
+                $query->setParameter('preferred', $preferred);
1400
+                $query->executeStatement();
1401
+            }
1402
+        }, $this->db);
1403
+    }
1404
+
1405
+    /**
1406
+     * read vCard data into a vCard object
1407
+     *
1408
+     * @param string $cardData
1409
+     * @return VCard
1410
+     */
1411
+    protected function readCard($cardData) {
1412
+        return Reader::read($cardData);
1413
+    }
1414
+
1415
+    /**
1416
+     * delete all properties from a given card
1417
+     *
1418
+     * @param int $addressBookId
1419
+     * @param int $cardId
1420
+     */
1421
+    protected function purgeProperties($addressBookId, $cardId) {
1422
+        $query = $this->db->getQueryBuilder();
1423
+        $query->delete($this->dbCardsPropertiesTable)
1424
+            ->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId)))
1425
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1426
+        $query->executeStatement();
1427
+    }
1428
+
1429
+    /**
1430
+     * Get ID from a given contact
1431
+     */
1432
+    protected function getCardId(int $addressBookId, string $uri): int {
1433
+        $query = $this->db->getQueryBuilder();
1434
+        $query->select('id')->from($this->dbCardsTable)
1435
+            ->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1436
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1437
+
1438
+        $result = $query->executeQuery();
1439
+        $cardIds = $result->fetchAssociative();
1440
+        $result->closeCursor();
1441
+
1442
+        if (!isset($cardIds['id'])) {
1443
+            throw new \InvalidArgumentException('Card does not exists: ' . $uri);
1444
+        }
1445
+
1446
+        return (int)$cardIds['id'];
1447
+    }
1448
+
1449
+    /**
1450
+     * For shared address books the sharee is set in the ACL of the address book
1451
+     *
1452
+     * @param int $addressBookId
1453
+     * @param list<array{privilege: string, principal: string, protected: bool}> $acl
1454
+     * @return list<array{privilege: string, principal: string, protected: bool}>
1455
+     */
1456
+    public function applyShareAcl(int $addressBookId, array $acl): array {
1457
+        $shares = $this->sharingBackend->getShares($addressBookId);
1458
+        return $this->sharingBackend->applyShareAcl($shares, $acl);
1459
+    }
1460
+
1461
+    /**
1462
+     * @throws \InvalidArgumentException
1463
+     */
1464
+    public function pruneOutdatedSyncTokens(int $keep, int $retention): int {
1465
+        if ($keep < 0) {
1466
+            throw new \InvalidArgumentException();
1467
+        }
1468
+
1469
+        $query = $this->db->getQueryBuilder();
1470
+        $query->select($query->func()->max('id'))
1471
+            ->from('addressbookchanges');
1472
+
1473
+        $result = $query->executeQuery();
1474
+        $maxId = (int)$result->fetchOne();
1475
+        $result->closeCursor();
1476
+        if (!$maxId || $maxId < $keep) {
1477
+            return 0;
1478
+        }
1479
+
1480
+        $query = $this->db->getQueryBuilder();
1481
+        $query->delete('addressbookchanges')
1482
+            ->where(
1483
+                $query->expr()->lte('id', $query->createNamedParameter($maxId - $keep, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
1484
+                $query->expr()->lte('created_at', $query->createNamedParameter($retention)),
1485
+            );
1486
+        return $query->executeStatement();
1487
+    }
1488
+
1489
+    private function convertPrincipal(string $principalUri, bool $toV2): string {
1490
+        if ($this->principalBackend->getPrincipalPrefix() === 'principals') {
1491
+            [, $name] = \Sabre\Uri\split($principalUri);
1492
+            if ($toV2 === true) {
1493
+                return "principals/users/$name";
1494
+            }
1495
+            return "principals/$name";
1496
+        }
1497
+        return $principalUri;
1498
+    }
1499
+
1500
+    private function addOwnerPrincipal(array &$addressbookInfo): void {
1501
+        $ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
1502
+        $displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
1503
+        if (isset($addressbookInfo[$ownerPrincipalKey])) {
1504
+            $uri = $addressbookInfo[$ownerPrincipalKey];
1505
+        } else {
1506
+            $uri = $addressbookInfo['principaluri'];
1507
+        }
1508
+
1509
+        $principalInformation = $this->principalBackend->getPrincipalByPath($uri);
1510
+        if (isset($principalInformation['{DAV:}displayname'])) {
1511
+            $addressbookInfo[$displaynameKey] = $principalInformation['{DAV:}displayname'];
1512
+        }
1513
+    }
1514
+
1515
+    /**
1516
+     * Extract UID from vcard
1517
+     *
1518
+     * @param string $cardData the vcard raw data
1519
+     * @return string the uid
1520
+     * @throws BadRequest if no UID is available or vcard is empty
1521
+     */
1522
+    private function getUID(string $cardData): string {
1523
+        if ($cardData !== '') {
1524
+            $vCard = Reader::read($cardData);
1525
+            if ($vCard->UID) {
1526
+                $uid = $vCard->UID->getValue();
1527
+                return $uid;
1528
+            }
1529
+            // should already be handled, but just in case
1530
+            throw new BadRequest('vCards on CardDAV servers MUST have a UID property');
1531
+        }
1532
+        // should already be handled, but just in case
1533
+        throw new BadRequest('vCard can not be empty');
1534
+    }
1535 1535
 }
Please login to merge, or discard this patch.
Spacing   +63 added lines, -63 removed lines patch added patch discarded remove patch
@@ -77,7 +77,7 @@  discard block
 block discarded – undo
77 77
 			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
78 78
 
79 79
 		$result = $query->executeQuery();
80
-		$column = (int)$result->fetchOne();
80
+		$column = (int) $result->fetchOne();
81 81
 		$result->closeCursor();
82 82
 		return $column;
83 83
 	}
@@ -100,7 +100,7 @@  discard block
 block discarded – undo
100 100
 	 * @return array
101 101
 	 */
102 102
 	public function getAddressBooksForUser($principalUri) {
103
-		return $this->atomic(function () use ($principalUri) {
103
+		return $this->atomic(function() use ($principalUri) {
104 104
 			$principalUriOriginal = $principalUri;
105 105
 			$principalUri = $this->convertPrincipal($principalUri, true);
106 106
 			$select = $this->db->getQueryBuilder();
@@ -117,7 +117,7 @@  discard block
 block discarded – undo
117 117
 					'uri' => $row['uri'],
118 118
 					'principaluri' => $this->convertPrincipal($row['principaluri'], false),
119 119
 					'{DAV:}displayname' => $row['displayname'],
120
-					'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
120
+					'{'.Plugin::NS_CARDDAV.'}addressbook-description' => $row['description'],
121 121
 					'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
122 122
 					'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
123 123
 				];
@@ -148,13 +148,13 @@  discard block
 block discarded – undo
148 148
 				->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
149 149
 			$result = $select->executeQuery();
150 150
 
151
-			$readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
151
+			$readOnlyPropertyName = '{'.\OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD.'}read-only';
152 152
 			while ($row = $result->fetchAssociative()) {
153 153
 				if ($row['principaluri'] === $principalUri) {
154 154
 					continue;
155 155
 				}
156 156
 
157
-				$readOnly = (int)$row['access'] === Backend::ACCESS_READ;
157
+				$readOnly = (int) $row['access'] === Backend::ACCESS_READ;
158 158
 				if (isset($addressBooks[$row['id']])) {
159 159
 					if ($readOnly) {
160 160
 						// New share can not have more permissions then the old one.
@@ -168,18 +168,18 @@  discard block
 block discarded – undo
168 168
 				}
169 169
 
170 170
 				[, $name] = \Sabre\Uri\split($row['principaluri']);
171
-				$uri = $row['uri'] . '_shared_by_' . $name;
172
-				$displayName = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? $name ?? '') . ')';
171
+				$uri = $row['uri'].'_shared_by_'.$name;
172
+				$displayName = $row['displayname'].' ('.($this->userManager->getDisplayName($name) ?? $name ?? '').')';
173 173
 
174 174
 				$addressBooks[$row['id']] = [
175 175
 					'id' => $row['id'],
176 176
 					'uri' => $uri,
177 177
 					'principaluri' => $principalUriOriginal,
178 178
 					'{DAV:}displayname' => $displayName,
179
-					'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
179
+					'{'.Plugin::NS_CARDDAV.'}addressbook-description' => $row['description'],
180 180
 					'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
181 181
 					'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
182
-					'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'],
182
+					'{'.\OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD.'}owner-principal' => $row['principaluri'],
183 183
 					$readOnlyPropertyName => $readOnly,
184 184
 				];
185 185
 
@@ -207,7 +207,7 @@  discard block
 block discarded – undo
207 207
 				'uri' => $row['uri'],
208 208
 				'principaluri' => $this->convertPrincipal($row['principaluri'], false),
209 209
 				'{DAV:}displayname' => $row['displayname'],
210
-				'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
210
+				'{'.Plugin::NS_CARDDAV.'}addressbook-description' => $row['description'],
211 211
 				'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
212 212
 				'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
213 213
 			];
@@ -239,7 +239,7 @@  discard block
 block discarded – undo
239 239
 			'uri' => $row['uri'],
240 240
 			'principaluri' => $row['principaluri'],
241 241
 			'{DAV:}displayname' => $row['displayname'],
242
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
242
+			'{'.Plugin::NS_CARDDAV.'}addressbook-description' => $row['description'],
243 243
 			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
244 244
 			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
245 245
 		];
@@ -269,7 +269,7 @@  discard block
 block discarded – undo
269 269
 			'uri' => $row['uri'],
270 270
 			'principaluri' => $row['principaluri'],
271 271
 			'{DAV:}displayname' => $row['displayname'],
272
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
272
+			'{'.Plugin::NS_CARDDAV.'}addressbook-description' => $row['description'],
273 273
 			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
274 274
 			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
275 275
 
@@ -277,8 +277,8 @@  discard block
 block discarded – undo
277 277
 
278 278
 		// system address books are always read only
279 279
 		if ($principal === 'principals/system/system') {
280
-			$addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] = $row['principaluri'];
281
-			$addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'] = true;
280
+			$addressBook['{'.\OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD.'}owner-principal'] = $row['principaluri'];
281
+			$addressBook['{'.\OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD.'}read-only'] = true;
282 282
 		}
283 283
 
284 284
 		$this->addOwnerPrincipal($addressBook);
@@ -305,22 +305,22 @@  discard block
 block discarded – undo
305 305
 	public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
306 306
 		$supportedProperties = [
307 307
 			'{DAV:}displayname',
308
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description',
308
+			'{'.Plugin::NS_CARDDAV.'}addressbook-description',
309 309
 		];
310 310
 
311
-		$propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) {
311
+		$propPatch->handle($supportedProperties, function($mutations) use ($addressBookId) {
312 312
 			$updates = [];
313 313
 			foreach ($mutations as $property => $newValue) {
314 314
 				switch ($property) {
315 315
 					case '{DAV:}displayname':
316 316
 						$updates['displayname'] = $newValue;
317 317
 						break;
318
-					case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
318
+					case '{'.Plugin::NS_CARDDAV.'}addressbook-description':
319 319
 						$updates['description'] = $newValue;
320 320
 						break;
321 321
 				}
322 322
 			}
323
-			[$addressBookRow, $shares] = $this->atomic(function () use ($addressBookId, $updates) {
323
+			[$addressBookRow, $shares] = $this->atomic(function() use ($addressBookId, $updates) {
324 324
 				$query = $this->db->getQueryBuilder();
325 325
 				$query->update('addressbooks');
326 326
 
@@ -332,12 +332,12 @@  discard block
 block discarded – undo
332 332
 
333 333
 				$this->addChange($addressBookId, '', 2);
334 334
 
335
-				$addressBookRow = $this->getAddressBookById((int)$addressBookId);
336
-				$shares = $this->getShares((int)$addressBookId);
335
+				$addressBookRow = $this->getAddressBookById((int) $addressBookId);
336
+				$shares = $this->getShares((int) $addressBookId);
337 337
 				return [$addressBookRow, $shares];
338 338
 			}, $this->db);
339 339
 
340
-			$this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations));
340
+			$this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int) $addressBookId, $addressBookRow, $shares, $mutations));
341 341
 
342 342
 			return true;
343 343
 		});
@@ -371,11 +371,11 @@  discard block
 block discarded – undo
371 371
 				case '{DAV:}displayname':
372 372
 					$values['displayname'] = $newValue;
373 373
 					break;
374
-				case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
374
+				case '{'.Plugin::NS_CARDDAV.'}addressbook-description':
375 375
 					$values['description'] = $newValue;
376 376
 					break;
377 377
 				default:
378
-					throw new BadRequest('Unknown property: ' . $property);
378
+					throw new BadRequest('Unknown property: '.$property);
379 379
 			}
380 380
 		}
381 381
 
@@ -385,7 +385,7 @@  discard block
 block discarded – undo
385 385
 			$values['displayname'] = $url;
386 386
 		}
387 387
 
388
-		[$addressBookId, $addressBookRow] = $this->atomic(function () use ($values) {
388
+		[$addressBookId, $addressBookRow] = $this->atomic(function() use ($values) {
389 389
 			$query = $this->db->getQueryBuilder();
390 390
 			$query->insert('addressbooks')
391 391
 				->values([
@@ -417,8 +417,8 @@  discard block
 block discarded – undo
417 417
 	 * @return void
418 418
 	 */
419 419
 	public function deleteAddressBook($addressBookId) {
420
-		$this->atomic(function () use ($addressBookId): void {
421
-			$addressBookId = (int)$addressBookId;
420
+		$this->atomic(function() use ($addressBookId): void {
421
+			$addressBookId = (int) $addressBookId;
422 422
 			$addressBookData = $this->getAddressBookById($addressBookId);
423 423
 			$shares = $this->getShares($addressBookId);
424 424
 
@@ -482,7 +482,7 @@  discard block
 block discarded – undo
482 482
 
483 483
 		$result = $query->executeQuery();
484 484
 		while ($row = $result->fetchAssociative()) {
485
-			$row['etag'] = '"' . $row['etag'] . '"';
485
+			$row['etag'] = '"'.$row['etag'].'"';
486 486
 
487 487
 			$modified = false;
488 488
 			$row['carddata'] = $this->readBlob($row['carddata'], $modified);
@@ -522,7 +522,7 @@  discard block
 block discarded – undo
522 522
 		if (!$row) {
523 523
 			return false;
524 524
 		}
525
-		$row['etag'] = '"' . $row['etag'] . '"';
525
+		$row['etag'] = '"'.$row['etag'].'"';
526 526
 
527 527
 		$modified = false;
528 528
 		$row['carddata'] = $this->readBlob($row['carddata'], $modified);
@@ -564,7 +564,7 @@  discard block
 block discarded – undo
564 564
 			$result = $query->executeQuery();
565 565
 
566 566
 			while ($row = $result->fetchAssociative()) {
567
-				$row['etag'] = '"' . $row['etag'] . '"';
567
+				$row['etag'] = '"'.$row['etag'].'"';
568 568
 
569 569
 				$modified = false;
570 570
 				$row['carddata'] = $this->readBlob($row['carddata'], $modified);
@@ -608,7 +608,7 @@  discard block
 block discarded – undo
608 608
 	public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
609 609
 		$etag = md5($cardData);
610 610
 		$uid = $this->getUID($cardData);
611
-		return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
611
+		return $this->atomic(function() use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
612 612
 			if ($checkAlreadyExists) {
613 613
 				$q = $this->db->getQueryBuilder();
614 614
 				$q->select('uid')
@@ -617,7 +617,7 @@  discard block
 block discarded – undo
617 617
 					->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
618 618
 					->setMaxResults(1);
619 619
 				$result = $q->executeQuery();
620
-				$count = (bool)$result->fetchOne();
620
+				$count = (bool) $result->fetchOne();
621 621
 				$result->closeCursor();
622 622
 				if ($count) {
623 623
 					throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
@@ -648,7 +648,7 @@  discard block
 block discarded – undo
648 648
 			$objectRow = $this->getCard($addressBookId, $cardUri);
649 649
 			$this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
650 650
 
651
-			return '"' . $etag . '"';
651
+			return '"'.$etag.'"';
652 652
 		}, $this->db);
653 653
 	}
654 654
 
@@ -681,13 +681,13 @@  discard block
 block discarded – undo
681 681
 		$uid = $this->getUID($cardData);
682 682
 		$etag = md5($cardData);
683 683
 
684
-		return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $uid, $etag) {
684
+		return $this->atomic(function() use ($addressBookId, $cardUri, $cardData, $uid, $etag) {
685 685
 			$query = $this->db->getQueryBuilder();
686 686
 
687 687
 			// check for recently stored etag and stop if it is the same
688 688
 			$etagCacheKey = "$addressBookId#$cardUri";
689 689
 			if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) {
690
-				return '"' . $etag . '"';
690
+				return '"'.$etag.'"';
691 691
 			}
692 692
 
693 693
 			$query->update($this->dbCardsTable)
@@ -709,7 +709,7 @@  discard block
 block discarded – undo
709 709
 			$shares = $this->getShares($addressBookId);
710 710
 			$objectRow = $this->getCard($addressBookId, $cardUri);
711 711
 			$this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
712
-			return '"' . $etag . '"';
712
+			return '"'.$etag.'"';
713 713
 		}, $this->db);
714 714
 	}
715 715
 
@@ -717,12 +717,12 @@  discard block
 block discarded – undo
717 717
 	 * @throws Exception
718 718
 	 */
719 719
 	public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int $targetAddressBookId, string $tragetObjectUri): bool {
720
-		return $this->atomic(function () use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) {
720
+		return $this->atomic(function() use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) {
721 721
 			$card = $this->getCard($sourceAddressBookId, $sourceObjectUri);
722 722
 			if (empty($card)) {
723 723
 				return false;
724 724
 			}
725
-			$sourceObjectId = (int)$card['id'];
725
+			$sourceObjectId = (int) $card['id'];
726 726
 
727 727
 			$query = $this->db->getQueryBuilder();
728 728
 			$query->update('cards')
@@ -765,7 +765,7 @@  discard block
 block discarded – undo
765 765
 	 * @return bool
766 766
 	 */
767 767
 	public function deleteCard($addressBookId, $cardUri) {
768
-		return $this->atomic(function () use ($addressBookId, $cardUri) {
768
+		return $this->atomic(function() use ($addressBookId, $cardUri) {
769 769
 			$addressBookData = $this->getAddressBookById($addressBookId);
770 770
 			$shares = $this->getShares($addressBookId);
771 771
 			$objectRow = $this->getCard($addressBookId, $cardUri);
@@ -855,7 +855,7 @@  discard block
 block discarded – undo
855 855
 		$maxLimit = $this->config->getSystemValueInt('carddav_sync_request_truncation', 2500);
856 856
 		$limit = ($limit === null) ? $maxLimit : min($limit, $maxLimit);
857 857
 		// Current synctoken
858
-		return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) {
858
+		return $this->atomic(function() use ($addressBookId, $syncToken, $syncLevel, $limit) {
859 859
 			$qb = $this->db->getQueryBuilder();
860 860
 			$qb->select('synctoken')
861 861
 				->from('addressbooks')
@@ -899,7 +899,7 @@  discard block
 block discarded – undo
899 899
 				} else {
900 900
 					$lastID = $values[array_key_last($values)]['id'];
901 901
 					$result['added'] = array_column($values, 'uri');
902
-					$result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken ;
902
+					$result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken;
903 903
 					$result['result_truncated'] = count($result['added']) >= $limit;
904 904
 				}
905 905
 			} elseif ($syncToken) {
@@ -983,7 +983,7 @@  discard block
 block discarded – undo
983 983
 				}
984 984
 				$lastID = $values[array_key_last($values)]['id'];
985 985
 				if (count($values) >= $limit) {
986
-					$result['syncToken'] = 'init_' . $lastID . '_' . $currentToken;
986
+					$result['syncToken'] = 'init_'.$lastID.'_'.$currentToken;
987 987
 					$result['result_truncated'] = true;
988 988
 				}
989 989
 
@@ -1004,13 +1004,13 @@  discard block
 block discarded – undo
1004 1004
 	 * @return void
1005 1005
 	 */
1006 1006
 	protected function addChange(int $addressBookId, string $objectUri, int $operation): void {
1007
-		$this->atomic(function () use ($addressBookId, $objectUri, $operation): void {
1007
+		$this->atomic(function() use ($addressBookId, $objectUri, $operation): void {
1008 1008
 			$query = $this->db->getQueryBuilder();
1009 1009
 			$query->select('synctoken')
1010 1010
 				->from('addressbooks')
1011 1011
 				->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)));
1012 1012
 			$result = $query->executeQuery();
1013
-			$syncToken = (int)$result->fetchOne();
1013
+			$syncToken = (int) $result->fetchOne();
1014 1014
 			$result->closeCursor();
1015 1015
 
1016 1016
 			$query = $this->db->getQueryBuilder();
@@ -1079,7 +1079,7 @@  discard block
 block discarded – undo
1079 1079
 	 * @param list<string> $remove
1080 1080
 	 */
1081 1081
 	public function updateShares(IShareable $shareable, array $add, array $remove): void {
1082
-		$this->atomic(function () use ($shareable, $add, $remove): void {
1082
+		$this->atomic(function() use ($shareable, $add, $remove): void {
1083 1083
 			$addressBookId = $shareable->getResourceId();
1084 1084
 			$addressBookData = $this->getAddressBookById($addressBookId);
1085 1085
 			$oldShares = $this->getShares($addressBookId);
@@ -1113,7 +1113,7 @@  discard block
 block discarded – undo
1113 1113
 	 * @return array an array of contacts which are arrays of key-value-pairs
1114 1114
 	 */
1115 1115
 	public function search($addressBookId, $pattern, $searchProperties, $options = []): array {
1116
-		return $this->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) {
1116
+		return $this->atomic(function() use ($addressBookId, $pattern, $searchProperties, $options) {
1117 1117
 			return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options);
1118 1118
 		}, $this->db);
1119 1119
 	}
@@ -1131,9 +1131,9 @@  discard block
 block discarded – undo
1131 1131
 		string $pattern,
1132 1132
 		array $searchProperties,
1133 1133
 		array $options = []): array {
1134
-		return $this->atomic(function () use ($principalUri, $pattern, $searchProperties, $options) {
1135
-			$addressBookIds = array_map(static function ($row):int {
1136
-				return (int)$row['id'];
1134
+		return $this->atomic(function() use ($principalUri, $pattern, $searchProperties, $options) {
1135
+			$addressBookIds = array_map(static function($row):int {
1136
+				return (int) $row['id'];
1137 1137
 			}, $this->getAddressBooksForUser($principalUri));
1138 1138
 
1139 1139
 			return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options);
@@ -1168,7 +1168,7 @@  discard block
 block discarded – undo
1168 1168
 		$useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false;
1169 1169
 
1170 1170
 		if ($escapePattern) {
1171
-			$searchProperties = array_filter($searchProperties, function ($property) use ($pattern) {
1171
+			$searchProperties = array_filter($searchProperties, function($property) use ($pattern) {
1172 1172
 				if ($property === 'EMAIL' && str_contains($pattern, ' ')) {
1173 1173
 					// There can be no spaces in emails
1174 1174
 					return false;
@@ -1201,7 +1201,7 @@  discard block
 block discarded – undo
1201 1201
 			} elseif (!$escapePattern) {
1202 1202
 				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter($pattern)));
1203 1203
 			} else {
1204
-				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
1204
+				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%'.$this->db->escapeLikeParameter($pattern).'%')));
1205 1205
 			}
1206 1206
 		}
1207 1207
 		if (isset($options['limit'])) {
@@ -1212,7 +1212,7 @@  discard block
 block discarded – undo
1212 1212
 		}
1213 1213
 
1214 1214
 		if (isset($options['person'])) {
1215
-			$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($options['person']) . '%')));
1215
+			$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%'.$this->db->escapeLikeParameter($options['person']).'%')));
1216 1216
 		}
1217 1217
 		if (isset($options['since']) || isset($options['until'])) {
1218 1218
 			$query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid');
@@ -1240,8 +1240,8 @@  discard block
 block discarded – undo
1240 1240
 		$result = $query2->executeQuery();
1241 1241
 		$matches = $result->fetchAllAssociative();
1242 1242
 		$result->closeCursor();
1243
-		$matches = array_map(function ($match) {
1244
-			return (int)$match['cardid'];
1243
+		$matches = array_map(function($match) {
1244
+			return (int) $match['cardid'];
1245 1245
 		}, $matches);
1246 1246
 
1247 1247
 		$cardResults = [];
@@ -1258,8 +1258,8 @@  discard block
 block discarded – undo
1258 1258
 		}
1259 1259
 
1260 1260
 		$cards = array_merge(...$cardResults);
1261
-		return array_map(function ($array) {
1262
-			$array['addressbookid'] = (int)$array['addressbookid'];
1261
+		return array_map(function($array) {
1262
+			$array['addressbookid'] = (int) $array['addressbookid'];
1263 1263
 			$modified = false;
1264 1264
 			$array['carddata'] = $this->readBlob($array['carddata'], $modified);
1265 1265
 			if ($modified) {
@@ -1305,7 +1305,7 @@  discard block
 block discarded – undo
1305 1305
 		$result->closeCursor();
1306 1306
 
1307 1307
 		if (!isset($uri['uri'])) {
1308
-			throw new \InvalidArgumentException('Card does not exists: ' . $id);
1308
+			throw new \InvalidArgumentException('Card does not exists: '.$id);
1309 1309
 		}
1310 1310
 
1311 1311
 		return $uri['uri'];
@@ -1330,7 +1330,7 @@  discard block
 block discarded – undo
1330 1330
 
1331 1331
 		if (is_array($contact)) {
1332 1332
 			$modified = false;
1333
-			$contact['etag'] = '"' . $contact['etag'] . '"';
1333
+			$contact['etag'] = '"'.$contact['etag'].'"';
1334 1334
 			$contact['carddata'] = $this->readBlob($contact['carddata'], $modified);
1335 1335
 			if ($modified) {
1336 1336
 				$contact['size'] = strlen($contact['carddata']);
@@ -1365,7 +1365,7 @@  discard block
 block discarded – undo
1365 1365
 	 * @param string $vCardSerialized
1366 1366
 	 */
1367 1367
 	protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) {
1368
-		$this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void {
1368
+		$this->atomic(function() use ($addressBookId, $cardUri, $vCardSerialized): void {
1369 1369
 			$cardId = $this->getCardId($addressBookId, $cardUri);
1370 1370
 			$vCard = $this->readCard($vCardSerialized);
1371 1371
 
@@ -1440,10 +1440,10 @@  discard block
 block discarded – undo
1440 1440
 		$result->closeCursor();
1441 1441
 
1442 1442
 		if (!isset($cardIds['id'])) {
1443
-			throw new \InvalidArgumentException('Card does not exists: ' . $uri);
1443
+			throw new \InvalidArgumentException('Card does not exists: '.$uri);
1444 1444
 		}
1445 1445
 
1446
-		return (int)$cardIds['id'];
1446
+		return (int) $cardIds['id'];
1447 1447
 	}
1448 1448
 
1449 1449
 	/**
@@ -1471,7 +1471,7 @@  discard block
 block discarded – undo
1471 1471
 			->from('addressbookchanges');
1472 1472
 
1473 1473
 		$result = $query->executeQuery();
1474
-		$maxId = (int)$result->fetchOne();
1474
+		$maxId = (int) $result->fetchOne();
1475 1475
 		$result->closeCursor();
1476 1476
 		if (!$maxId || $maxId < $keep) {
1477 1477
 			return 0;
@@ -1498,8 +1498,8 @@  discard block
 block discarded – undo
1498 1498
 	}
1499 1499
 
1500 1500
 	private function addOwnerPrincipal(array &$addressbookInfo): void {
1501
-		$ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
1502
-		$displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
1501
+		$ownerPrincipalKey = '{'.\OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD.'}owner-principal';
1502
+		$displaynameKey = '{'.\OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD.'}owner-displayname';
1503 1503
 		if (isset($addressbookInfo[$ownerPrincipalKey])) {
1504 1504
 			$uri = $addressbookInfo[$ownerPrincipalKey];
1505 1505
 		} else {
Please login to merge, or discard this patch.
apps/dav/lib/Command/RemoveInvalidShares.php 1 patch
Indentation   +37 added lines, -37 removed lines patch added patch discarded remove patch
@@ -22,46 +22,46 @@
 block discarded – undo
22 22
  * have no matching principal. Happened because of a bug in the calendar app.
23 23
  */
24 24
 class RemoveInvalidShares extends Command {
25
-	public function __construct(
26
-		private IDBConnection $connection,
27
-		private Principal $principalBackend,
28
-		private RemoteUserPrincipalBackend $remoteUserPrincipalBackend,
29
-	) {
30
-		parent::__construct();
31
-	}
25
+    public function __construct(
26
+        private IDBConnection $connection,
27
+        private Principal $principalBackend,
28
+        private RemoteUserPrincipalBackend $remoteUserPrincipalBackend,
29
+    ) {
30
+        parent::__construct();
31
+    }
32 32
 
33
-	protected function configure(): void {
34
-		$this
35
-			->setName('dav:remove-invalid-shares')
36
-			->setDescription('Remove invalid dav shares');
37
-	}
33
+    protected function configure(): void {
34
+        $this
35
+            ->setName('dav:remove-invalid-shares')
36
+            ->setDescription('Remove invalid dav shares');
37
+    }
38 38
 
39
-	protected function execute(InputInterface $input, OutputInterface $output): int {
40
-		$query = $this->connection->getQueryBuilder();
41
-		$result = $query->selectDistinct('principaluri')
42
-			->from('dav_shares')
43
-			->executeQuery();
39
+    protected function execute(InputInterface $input, OutputInterface $output): int {
40
+        $query = $this->connection->getQueryBuilder();
41
+        $result = $query->selectDistinct('principaluri')
42
+            ->from('dav_shares')
43
+            ->executeQuery();
44 44
 
45
-		while ($row = $result->fetchAssociative()) {
46
-			$principaluri = $row['principaluri'];
47
-			$p = $this->principalBackend->getPrincipalByPath($principaluri)
48
-				?? $this->remoteUserPrincipalBackend->getPrincipalByPath($principaluri);
49
-			if ($p === null) {
50
-				$this->deleteSharesForPrincipal($principaluri);
51
-			}
52
-		}
45
+        while ($row = $result->fetchAssociative()) {
46
+            $principaluri = $row['principaluri'];
47
+            $p = $this->principalBackend->getPrincipalByPath($principaluri)
48
+                ?? $this->remoteUserPrincipalBackend->getPrincipalByPath($principaluri);
49
+            if ($p === null) {
50
+                $this->deleteSharesForPrincipal($principaluri);
51
+            }
52
+        }
53 53
 
54
-		$result->closeCursor();
55
-		return self::SUCCESS;
56
-	}
54
+        $result->closeCursor();
55
+        return self::SUCCESS;
56
+    }
57 57
 
58
-	/**
59
-	 * @param string $principaluri
60
-	 */
61
-	private function deleteSharesForPrincipal($principaluri): void {
62
-		$delete = $this->connection->getQueryBuilder();
63
-		$delete->delete('dav_shares')
64
-			->where($delete->expr()->eq('principaluri', $delete->createNamedParameter($principaluri)));
65
-		$delete->executeStatement();
66
-	}
58
+    /**
59
+     * @param string $principaluri
60
+     */
61
+    private function deleteSharesForPrincipal($principaluri): void {
62
+        $delete = $this->connection->getQueryBuilder();
63
+        $delete->delete('dav_shares')
64
+            ->where($delete->expr()->eq('principaluri', $delete->createNamedParameter($principaluri)));
65
+        $delete->executeStatement();
66
+    }
67 67
 }
Please login to merge, or discard this patch.