Issues (189)

lib/GrommunioDavBackend.php (12 issues)

1
<?php
2
3
/*
4
 * SPDX-License-Identifier: AGPL-3.0-only
5
 * SPDX-FileCopyrightText: Copyright 2016 - 2018 Kopano b.v.
6
 * SPDX-FileCopyrightText: Copyright 2020 - 2025 grommunio GmbH
7
 *
8
 * grommunio DAV backend class which handles grommunio related activities.
9
 */
10
11
namespace grommunio\DAV;
12
13
use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet;
14
15
class GrommunioDavBackend {
16
	private $logger;
17
	protected $session;
18
	protected $stores;
19
	protected $user;
20
	protected $customprops;
21
	protected $syncstate;
22
23
	/**
24
	 * Constructor.
25
	 */
26
	public function __construct(GLogger $glogger) {
27
		$this->logger = $glogger;
28
		$this->syncstate = new GrommunioSyncState($glogger, SYNC_DB);
29
	}
30
31
	/**
32
	 * Connect to grommunio and create session.
33
	 *
34
	 * @param string $user
35
	 * @param string $pass
36
	 *
37
	 * @return bool
38
	 */
39
	public function Logon($user, $pass) {
40
		$this->logger->trace('%s / password', $user);
41
42
		$gDavVersion = 'grommunio-dav' . @constant('GDAV_VERSION');
43
		$userAgent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown';
44
		$this->session = mapi_logon_zarafa($user, $pass, MAPI_SERVER, null, null, 1, $gDavVersion, $userAgent);
45
		if (!$this->session) {
46
			$this->logger->info("Auth: ERROR - logon failed for user %s from IP %s", $user, $_SERVER['REMOTE_ADDR']);
47
48
			return false;
49
		}
50
51
		$this->user = $user;
52
		$this->logger->debug("Auth: OK - user %s - session %s", $this->user, $this->session);
53
54
		return $this->isGdavEnabled();
55
	}
56
57
	/**
58
	 * Returns the authenticated user.
59
	 *
60
	 * @return string
61
	 */
62
	public function GetUser() {
63
		$this->logger->trace($this->user);
64
65
		return $this->user;
66
	}
67
68
	/**
69
	 * Create a folder with MAPI class.
70
	 *
71
	 * @param mixed  $principalUri
72
	 * @param string $url
73
	 * @param string $class
74
	 * @param string $displayname
75
	 *
76
	 * @return string
77
	 */
78
	public function CreateFolder($principalUri, $url, $class, $displayname) {
79
		$props = mapi_getprops($this->GetStore($principalUri), [PR_IPM_SUBTREE_ENTRYID]);
80
		$folder = mapi_msgstore_openentry($this->GetStore($principalUri), $props[PR_IPM_SUBTREE_ENTRYID]);
81
		$newfolder = mapi_folder_createfolder($folder, $url, $displayname);
82
		mapi_setprops($newfolder, [PR_CONTAINER_CLASS => $class]);
83
84
		return $url;
85
	}
86
87
	/**
88
	 * Delete a folder with MAPI class.
89
	 *
90
	 * @param mixed $id
91
	 *
92
	 * @return bool
93
	 */
94
	public function DeleteFolder($id) {
95
		$folder = $this->GetMapiFolder($id);
96
		if (!$folder) {
97
			return false;
98
		}
99
100
		$props = mapi_getprops($folder, [PR_ENTRYID, PR_PARENT_ENTRYID]);
101
		$parentfolder = mapi_msgstore_openentry($this->GetStoreById($id), $props[PR_PARENT_ENTRYID]);
102
		mapi_folder_deletefolder($parentfolder, $props[PR_ENTRYID]);
103
104
		return true;
105
	}
106
107
	/**
108
	 * Returns a list of folders for a MAPI class.
109
	 *
110
	 * @param string $principalUri
111
	 * @param mixed  $classes
112
	 *
113
	 * @return array
114
	 */
115
	public function GetFolders($principalUri, $classes) {
116
		$this->logger->trace("principal '%s', classes '%s'", $principalUri, $classes);
117
		$folders = [];
118
119
		// TODO limit the output to subfolders of the principalUri?
120
121
		$store = $this->GetStore($principalUri);
122
		$storeprops = mapi_getprops($store, [PR_IPM_WASTEBASKET_ENTRYID]);
123
		$rootfolder = mapi_msgstore_openentry($store);
124
		$hierarchy = mapi_folder_gethierarchytable($rootfolder, CONVENIENT_DEPTH | MAPI_DEFERRED_ERRORS);
125
		// TODO also filter hidden folders
126
		$restrictions = [];
127
		foreach ($classes as $class) {
128
			$restrictions[] = [RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => PR_CONTAINER_CLASS, VALUE => $class]];
129
		}
130
		mapi_table_restrict($hierarchy, [RES_OR, $restrictions]);
131
132
		// TODO how to handle hierarchies?
133
		$rows = mapi_table_queryallrows($hierarchy, [PR_DISPLAY_NAME, PR_ENTRYID, PR_SOURCE_KEY, PR_PARENT_SOURCE_KEY, PR_FOLDER_TYPE, PR_LOCAL_COMMIT_TIME_MAX, PR_CONTAINER_CLASS, PR_COMMENT, PR_PARENT_ENTRYID]);
134
135
		$rootprops = mapi_getprops($rootfolder, [PR_IPM_CONTACT_ENTRYID, PR_IPM_APPOINTMENT_ENTRYID]);
136
		foreach ($rows as $row) {
137
			if ($row[PR_FOLDER_TYPE] == FOLDER_SEARCH) {
138
				continue;
139
			}
140
			$folderId = $principalUri . ":" . bin2hex($row[PR_SOURCE_KEY]);
141
			$syncToken = $this->GetCurrentSyncToken($folderId);
142
143
			if (isset($row[PR_PARENT_ENTRYID], $storeprops[PR_IPM_WASTEBASKET_ENTRYID]) && $row[PR_PARENT_ENTRYID] == $storeprops[PR_IPM_WASTEBASKET_ENTRYID]) {
144
				continue;
145
			}
146
147
			$folder = [
148
				'id' => $folderId,
149
				'uri' => $row[PR_DISPLAY_NAME],
150
				'principaluri' => $principalUri,
151
				'{http://sabredav.org/ns}sync-token' => $syncToken,
152
				'{DAV:}displayname' => $row[PR_DISPLAY_NAME],
153
				'{urn:ietf:params:xml:ns:caldav}calendar-description' => $row[PR_COMMENT],
154
				'{http://calendarserver.org/ns/}getctag' => isset($row[PR_LOCAL_COMMIT_TIME_MAX]) ? strval($row[PR_LOCAL_COMMIT_TIME_MAX]) : '0000000000',
155
			];
156
157
			// set the supported component (task or calendar)
158
			if ($row[PR_CONTAINER_CLASS] == "IPF.Task") {
159
				$folder['{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'] = new SupportedCalendarComponentSet(['VTODO']);
160
			}
161
			if ($row[PR_CONTAINER_CLASS] == "IPF.Appointment") {
162
				$folder['{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'] = new SupportedCalendarComponentSet(['VEVENT']);
163
			}
164
165
			// ensure default contacts folder is put first, some clients
166
			// i.e. Apple Addressbook only supports one contact folder,
167
			// therefore it is desired that folder is the default one.
168
			if (in_array("IPF.Contact", $classes) && isset($rootprops[PR_IPM_CONTACT_ENTRYID]) && $row[PR_ENTRYID] == $rootprops[PR_IPM_CONTACT_ENTRYID]) {
169
				array_unshift($folders, $folder);
170
			}
171
			// ensure default calendar folder is put first,
172
			// before the tasks folder.
173
			elseif (in_array('IPF.Appointment', $classes) && isset($rootprops[PR_IPM_APPOINTMENT_ENTRYID]) && $row[PR_ENTRYID] == $rootprops[PR_IPM_APPOINTMENT_ENTRYID]) {
174
				array_unshift($folders, $folder);
175
			}
176
			else {
177
				array_push($folders, $folder);
178
			}
179
		}
180
		$this->logger->trace('found %d folders: %s', count($folders), $folders);
181
182
		return $folders;
183
	}
184
185
	/**
186
	 * Returns a MAPI restriction for a defined set of filters.
187
	 *
188
	 * @param array  $filters
189
	 * @param string $storeId (optional) mapi compatible storeid - required when using start+end filter
190
	 *
191
	 * @return null|array
192
	 */
193
	private function getRestrictionForFilters($filters, $storeId = null) {
194
		$hasTimerange = isset($filters['start'], $filters['end'], $storeId);
195
		$hasTypes = isset($filters['types']) && is_array($filters['types']) && !empty($filters['types']);
196
197
		// Fast path: only message-class filtering, no time-range.
198
		if (!$hasTimerange && $hasTypes) {
199
			$this->logger->trace("getRestrictionForFilters - types only: %s", $filters['types']);
200
			$arr = [];
201
			foreach ($filters['types'] as $filter) {
202
				$arr[] = [RES_PROPERTY,
203
					[RELOP => RELOP_EQ,
204
						ULPROPTAG => PR_MESSAGE_CLASS,
205
						VALUE => $filter,
206
					],
207
				];
208
			}
209
			$restriction = [RES_OR, $arr];
210
			$this->logger->trace("getRestrictionForFilters - built: %s", simplifyRestriction($restriction));
0 ignored issues
show
The function simplifyRestriction was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

210
			$this->logger->trace("getRestrictionForFilters - built: %s", /** @scrutinizer ignore-call */ simplifyRestriction($restriction));
Loading history...
211
212
			return $restriction;
213
		}
214
215
		// Time-range only (no explicit types) – interpret as events time-range.
216
		if ($hasTimerange && !$hasTypes) {
217
			$this->logger->trace("getRestrictionForFilters - timerange only start:%d end:%d", $filters['start'], $filters['end']);
218
			$restriction = $this->GetCalendarRestriction($storeId, $filters['start'], $filters['end']);
219
			$this->logger->trace("getRestrictionForFilters - built: %s", simplifyRestriction($restriction));
220
221
			return $restriction;
222
		}
223
224
		// Both types and time-range. Apply date restriction to appointments only,
225
		// and include other types (e.g., tasks) without date constraints.
226
		if ($hasTimerange && $hasTypes) {
227
			$this->logger->trace("getRestrictionForFilters - timerange + types start:%d end:%d types:%s", $filters['start'], $filters['end'], $filters['types']);
228
			$orParts = [];
229
230
			$dateRestriction = $this->GetCalendarRestriction($storeId, $filters['start'], $filters['end']);
231
			foreach ($filters['types'] as $type) {
232
				if ($type === 'IPM.Appointment') {
233
					$orParts[] = [RES_AND, [
234
						$dateRestriction,
235
						[RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => PR_MESSAGE_CLASS, VALUE => 'IPM.Appointment']],
236
					]];
237
				}
238
				else {
239
					// Other types (e.g., tasks) – no date constraint.
240
					$orParts[] = [RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => PR_MESSAGE_CLASS, VALUE => $type]];
241
				}
242
			}
243
244
			// If no parts were added, return null.
245
			if (empty($orParts)) {
246
				return null;
247
			}
248
249
			$restriction = [RES_OR, $orParts];
250
			$this->logger->trace("getRestrictionForFilters - built: %s", simplifyRestriction($restriction));
251
252
			return $restriction;
253
		}
254
255
		return null;
256
	}
257
258
	/**
259
	 * Returns a list of objects for a folder given by the id.
260
	 *
261
	 * @param string $id
262
	 * @param string $fileExtension
263
	 * @param array  $filters
264
	 *
265
	 * @return array
266
	 */
267
	public function GetObjects($id, $fileExtension, $filters = []) {
268
		$folder = $this->GetMapiFolder($id);
269
		$properties = $this->GetCustomProperties($id);
270
		$table = mapi_folder_getcontentstable($folder, MAPI_DEFERRED_ERRORS);
271
		$restriction = $this->getRestrictionForFilters($filters, $this->GetStoreById($id));
272
		if ($restriction) {
273
			mapi_table_restrict($table, $restriction);
274
		}
275
276
		$rows = mapi_table_queryallrows($table, [PR_SOURCE_KEY, PR_LAST_MODIFICATION_TIME, PR_MESSAGE_SIZE, $properties['goid']]);
277
278
		$results = [];
279
		foreach ($rows as $row) {
280
			$realId = "";
281
			if (isset($row[$properties['goid']])) {
282
				$realId = getUidFromGoid($row[$properties['goid']]);
283
			}
284
			if (!$realId) {
285
				$realId = bin2hex($row[PR_SOURCE_KEY]);
286
			}
287
			$realId = rawurlencode($realId);
288
289
			$result = [
290
				'id' => $realId,
291
				'uri' => $realId . $fileExtension,
292
				'etag' => '"' . $row[PR_LAST_MODIFICATION_TIME] . '"',
293
				'lastmodified' => $row[PR_LAST_MODIFICATION_TIME],
294
				'size' => $row[PR_MESSAGE_SIZE], // only approximation
295
			];
296
297
			if ($fileExtension == GrommunioCalDavBackend::FILE_EXTENSION) {
298
				$result['calendarid'] = $id;
299
			}
300
			elseif ($fileExtension == GrommunioCardDavBackend::FILE_EXTENSION) {
301
				$result['addressbookid'] = $id;
302
			}
303
			$results[] = $result;
304
		}
305
306
		return $results;
307
	}
308
309
	/**
310
	 * Create the object and set appttsref.
311
	 *
312
	 * @param mixed  $folderId
313
	 * @param mixed  $folder
314
	 * @param string $objectId
315
	 *
316
	 * @return mixed
317
	 */
318
	public function CreateObject($folderId, $folder, $objectId) {
319
		$mapimessage = mapi_folder_createmessage($folder);
320
		// we save the objectId in PROP_APPTTSREF so we find it by this id
321
		$properties = $this->GetCustomProperties($folderId);
322
		// FIXME: uid for contacts
323
		$goid = getGoidFromUid($objectId);
324
		mapi_setprops($mapimessage, [$properties['goid'] => $goid]);
325
326
		return $mapimessage;
327
	}
328
329
	/**
330
	 * Returns a mapi folder resource for a folderid (PR_SOURCE_KEY).
331
	 *
332
	 * @param string $folderid
333
	 *
334
	 * @return mixed
335
	 */
336
	public function GetMapiFolder($folderid) {
337
		$this->logger->trace('Id: %s', $folderid);
338
		$arr = explode(':', $folderid);
339
		$entryid = mapi_msgstore_entryidfromsourcekey($this->GetStore($arr[0]), hex2bin($arr[1]));
340
341
		return mapi_msgstore_openentry($this->GetStore($arr[0]), $entryid);
342
	}
343
344
	/**
345
	 * Returns MAPI addressbook.
346
	 *
347
	 * @return mixed
348
	 */
349
	public function GetAddressBook() {
350
		// TODO should be a singleton
351
		return mapi_openaddressbook($this->session);
352
	}
353
354
	/**
355
	 * Opens MAPI store for the user.
356
	 *
357
	 * @param string $username
358
	 *
359
	 * @return mixed
360
	 */
361
	public function OpenMapiStore($username = null) {
362
		$msgstorestable = mapi_getmsgstorestable($this->session);
363
		$msgstores = mapi_table_queryallrows($msgstorestable, [PR_DEFAULT_STORE, PR_ENTRYID, PR_MDB_PROVIDER]);
364
365
		$defaultstore = null;
366
		$publicstore = null;
367
		foreach ($msgstores as $row) {
368
			if (isset($row[PR_DEFAULT_STORE]) && $row[PR_DEFAULT_STORE]) {
369
				$defaultstore = $row[PR_ENTRYID];
370
			}
371
			if (isset($row[PR_MDB_PROVIDER]) && $row[PR_MDB_PROVIDER] == ZARAFA_STORE_PUBLIC_GUID) {
372
				$publicstore = $row[PR_ENTRYID];
373
			}
374
		}
375
376
		/* user's own store or public store */
377
		if ($username == $this->GetUser() && $defaultstore != null) {
378
			return mapi_openmsgstore($this->session, $defaultstore);
379
		}
380
		if ($username == 'public' && $publicstore != null) {
381
			return mapi_openmsgstore($this->session, $publicstore);
382
		}
383
384
		/* otherwise other user's store */
385
		$store = mapi_openmsgstore($this->session, $defaultstore);
386
		if (!$store) {
387
			return false;
388
		}
389
		$otherstore = mapi_msgstore_createentryid($store, $username);
390
391
		return mapi_openmsgstore($this->session, $otherstore);
392
	}
393
394
	/**
395
	 * Returns store for the user.
396
	 *
397
	 * @param string $storename
398
	 *
399
	 * @return mixed
400
	 */
401
	public function GetStore($storename) {
402
		if ($storename == null) {
403
			$storename = $this->GetUser();
404
		}
405
		else {
406
			$storename = str_replace('principals/', '', $storename);
407
		}
408
		$this->logger->trace("storename %s", $storename);
409
410
		/* We already got the store */
411
		if (isset($this->stores[$storename]) && $this->stores[$storename] != null) {
412
			return $this->stores[$storename];
413
		}
414
415
		$store = $this->OpenMapiStore($storename);
416
		if (!$store) {
417
			$this->logger->info("Auth: ERROR - unable to open store for %s (0x%08X)", $storename, mapi_last_hresult());
418
419
			return false;
420
		}
421
422
		// g-dav#61: always use SMTP address (issue with altnames)
423
		$storeProps = mapi_getprops($store, [PR_MAILBOX_OWNER_ENTRYID]);
424
		$addressbook = $this->getAddressbook();
425
		$mailuser = mapi_ab_openentry($addressbook, $storeProps[PR_MAILBOX_OWNER_ENTRYID]);
426
		$smtpProps = mapi_getprops($mailuser, [PR_SMTP_ADDRESS]);
0 ignored issues
show
The constant grommunio\DAV\PR_SMTP_ADDRESS was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
427
		if (isset($smtpProps[PR_SMTP_ADDRESS])) {
428
			$storename = $this->user = $smtpProps[PR_SMTP_ADDRESS];
429
		}
430
		$this->stores[$storename] = $store;
431
432
		return $this->stores[$storename];
433
	}
434
435
	/**
436
	 * Returns store from the id.
437
	 *
438
	 * @param mixed $id
439
	 *
440
	 * @return mixed
441
	 */
442
	public function GetStoreById($id) {
443
		$arr = explode(':', $id);
444
445
		return $this->GetStore($arr[0]);
446
	}
447
448
	/**
449
	 * Returns logon session.
450
	 *
451
	 * @return mixed
452
	 */
453
	public function GetSession() {
454
		return $this->session;
455
	}
456
457
	/**
458
	 * Returns an object ID of a mapi object.
459
	 * If set, goid will be preferred. If not the PR_SOURCE_KEY of the message (as hex) will be returned.
460
	 *
461
	 * This order is reflected as well when searching for a message with these ids in GrommunioDavBackend->GetMapiMessageForId().
462
	 *
463
	 * @param string $folderId
464
	 * @param mixed  $mapimessage
465
	 *
466
	 * @return string
467
	 */
468
	public function GetIdOfMapiMessage($folderId, $mapimessage) {
469
		$this->logger->trace("Finding ID of %s", $mapimessage);
470
		$properties = $this->GetCustomProperties($folderId);
471
472
		// It's one of these, order:
473
		// - GOID (if set)
474
		// - PROP_VCARDUID (if set)
475
		// - PR_SOURCE_KEY
476
		$props = mapi_getprops($mapimessage, [$properties['goid'], PR_SOURCE_KEY]);
477
		if (isset($props[$properties['goid']])) {
478
			$id = getUidFromGoid($props[$properties['goid']]);
479
			$this->logger->debug("Found uid %s from goid: %s", $id, bin2hex($props[$properties['goid']]));
480
			if ($id != null) {
481
				return rawurlencode($id);
482
			}
483
		}
484
		// PR_SOURCE_KEY is always available
485
		$id = bin2hex($props[PR_SOURCE_KEY]);
486
		$this->logger->debug("Found PR_SOURCE_KEY: %s", $id);
487
488
		return $id;
489
	}
490
491
	/**
492
	 * Finds and opens a MapiMessage from an objectId.
493
	 * The id can be a PROP_APPTTSREF or a PR_SOURCE_KEY (as hex).
494
	 *
495
	 * @param string $folderId
496
	 * @param string $objectUri
497
	 * @param mixed  $mapifolder optional
498
	 * @param string $extension  optional
499
	 *
500
	 * @return mixed
501
	 */
502
	public function GetMapiMessageForId($folderId, $objectUri, $mapifolder = null, $extension = null) {
503
		$this->logger->trace("Searching for '%s' in '%s' (%s) (%s)", $objectUri, $folderId, $mapifolder, $extension);
504
505
		if (!$mapifolder) {
506
			$mapifolder = $this->GetMapiFolder($folderId);
507
		}
508
509
		$id = rawurldecode($this->GetObjectIdFromObjectUri($objectUri, $extension));
510
511
		/* The ID can be several different things:
512
		 * - a UID that is saved in goid
513
		 * - a PROP_VCARDUID
514
		 * - a PR_SOURCE_KEY
515
		 *
516
		 * If it's a sourcekey, we can open the message directly.
517
		 * If the $extension is set:
518
		 *      if it's ics:
519
		 *          - search GOID with this value
520
		 *      if it's vcf:
521
		 *          - search PROP_VCARDUID value
522
		 */
523
		$entryid = false;
524
		$restriction = false;
525
526
		if (ctype_xdigit($id) && strlen($id) % 2 == 0) {
527
			$this->logger->trace("Try PR_SOURCE_KEY %s", $id);
528
			$arr = explode(':', $folderId);
529
			$entryid = mapi_msgstore_entryidfromsourcekey($this->GetStoreById($arr[0]), hex2bin($arr[1]), hex2bin($id));
530
		}
531
532
		if (!$entryid) {
533
			$this->logger->trace("Entryid not found. Try goid/vcarduid %s", $id);
534
535
			$properties = $this->GetCustomProperties($folderId);
536
			$restriction = [];
537
538
			if ($extension) {
539
				if ($extension == GrommunioCalDavBackend::FILE_EXTENSION) {
540
					$this->logger->trace("Try goid %s", $id);
541
					$goid = getGoidFromUid($id);
542
					$this->logger->trace("Try goid 0x%08X => %s", $properties["goid"], bin2hex($goid));
543
					$goid0 = getGoidFromUidZero($id);
0 ignored issues
show
The function getGoidFromUidZero was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

543
					$goid0 = /** @scrutinizer ignore-call */ getGoidFromUidZero($id);
Loading history...
544
					$restriction[] = [RES_OR, [
545
						[RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => $properties["goid"], VALUE => $goid]],
0 ignored issues
show
The constant grommunio\DAV\ULPROPTAG was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
The constant grommunio\DAV\RES_PROPERTY was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
The constant grommunio\DAV\RELOP was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
The constant grommunio\DAV\RELOP_EQ was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
The constant grommunio\DAV\VALUE was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
546
						[RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => $properties["goid"], VALUE => $goid0]],
547
					]];
548
				}
549
				elseif ($extension == GrommunioCardDavBackend::FILE_EXTENSION) {
550
					$this->logger->trace("Try vcarduid %s", $id);
551
					$restriction[] = [RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => $properties["vcarduid"], VALUE => $id]];
552
				}
553
			}
554
		}
555
556
		// find the message if we have a restriction
557
		if ($restriction) {
558
			$table = mapi_folder_getcontentstable($mapifolder, MAPI_DEFERRED_ERRORS);
559
			mapi_table_restrict($table, [RES_OR, $restriction]);
560
			// Get requested properties, plus whatever we need
561
			$proplist = [PR_ENTRYID];
562
			$rows = mapi_table_queryallrows($table, $proplist);
563
			if (count($rows) > 1) {
564
				$this->logger->warn("Found %d entries for id '%s' searching for message, returnin first in the list", count($rows), $id);
565
			}
566
			if (isset($rows[0], $rows[0][PR_ENTRYID])) {
567
				$entryid = $rows[0][PR_ENTRYID];
568
			}
569
		}
570
		if (!$entryid) {
571
			$this->logger->debug("Try to get entryid from appttsref");
572
			$arr = explode(':', $folderId);
573
			$sk = $this->syncstate->getSourcekey($arr[1], $id);
574
			if ($sk !== null) {
575
				$this->logger->debug("Found sourcekey from appttsref %s", $sk);
576
				$entryid = mapi_msgstore_entryidfromsourcekey($this->GetStoreById($arr[0]), hex2bin($arr[1]), hex2bin($sk));
577
			}
578
		}
579
		if ($entryid) {
580
			$mapimessage = mapi_msgstore_openentry($this->GetStoreById($folderId), $entryid);
581
			if (!$mapimessage) {
582
				$this->logger->warn("Error, unable to open entry id: %s 0x%X", bin2hex($entryid), mapi_last_hresult());
583
584
				return null;
585
			}
586
587
			return $mapimessage;
588
		}
589
		$this->logger->debug("Nothing found for %s", $id);
590
591
		return null;
592
	}
593
594
	/**
595
	 * Returns the objectId from an objectUri. It strips the file extension
596
	 * if it matches the passed one.
597
	 *
598
	 * @param string $objectUri
599
	 * @param string $extension
600
	 *
601
	 * @return string
602
	 */
603
	public function GetObjectIdFromObjectUri($objectUri, $extension) {
604
		if (!$extension) {
605
			return $objectUri;
606
		}
607
		$extLength = strlen($extension);
608
		if (substr($objectUri, -$extLength) === $extension) {
609
			return substr($objectUri, 0, -$extLength);
610
		}
611
612
		return $objectUri;
613
	}
614
615
	/**
616
	 * Checks if the PHP-MAPI extension is available and in a requested version.
617
	 *
618
	 * @param string $version the version to be checked ("6.30.10-18495", parts or build number)
619
	 *
620
	 * @return bool installed version is superior to the checked string
621
	 */
622
	protected function checkMapiExtVersion($version = "") {
623
		if (!extension_loaded("mapi")) {
624
			return false;
625
		}
626
		// compare build number if requested
627
		if (preg_match('/^\d+$/', $version) && strlen($version) > 3) {
628
			$vs = preg_split('/-/', phpversion("mapi"));
629
630
			return $version <= $vs[1];
631
		}
632
		if (version_compare(phpversion("mapi"), $version) == -1) {
633
			return false;
634
		}
635
636
		return true;
637
	}
638
639
	/**
640
	 * Get named (custom) properties. Currently only PROP_APPTTSREF.
641
	 *
642
	 * @param string $id the folder id
643
	 *
644
	 * @return mixed
645
	 */
646
	protected function GetCustomProperties($id) {
647
		if (!isset($this->customprops[$id])) {
648
			$this->logger->trace("Fetching properties id:%s", $id);
649
			$store = $this->GetStoreById($id);
650
			$properties = getPropIdsFromStrings($store, [
651
				"goid" => "PT_BINARY:PSETID_Meeting:" . PidLidGlobalObjectId,
0 ignored issues
show
The constant grommunio\DAV\PidLidGlobalObjectId was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
652
				"vcarduid" => MapiProps::PROP_VCARDUID,
653
			]);
654
			$this->customprops[$id] = $properties;
655
		}
656
657
		return $this->customprops[$id];
658
	}
659
660
	/**
661
	 * Create a MAPI restriction to use in the calendar which will
662
	 * return future calendar items (until $end), plus those since $start.
663
	 * Origins: Z-Push.
664
	 *
665
	 * @param mixed $store the MAPI store
666
	 * @param int   $start Timestamp since when to include messages
667
	 * @param int   $end   Ending timestamp
668
	 *
669
	 * @return array
670
	 */
671
	// TODO getting named properties
672
	public function GetCalendarRestriction($store, $start, $end) {
673
		$props = MapiProps::GetAppointmentProperties();
674
		$props = getPropIdsFromStrings($store, $props);
675
676
		return [RES_OR,
677
			[
678
				// OR
679
				// item.end > window.start && item.start < window.end
680
				[RES_AND,
681
					[
682
						[RES_PROPERTY,
683
							[RELOP => RELOP_LE,
684
								ULPROPTAG => $props["starttime"],
685
								VALUE => $end,
686
							],
687
						],
688
						[RES_PROPERTY,
689
							[RELOP => RELOP_GE,
690
								ULPROPTAG => $props["endtime"],
691
								VALUE => $start,
692
							],
693
						],
694
					],
695
				],
696
				// OR
697
				[RES_OR,
698
					[
699
						// OR
700
						// (EXIST(recurrence_enddate_property) && item[isRecurring] == true && recurrence_enddate_property >= start)
701
						[RES_AND,
702
							[
703
								[RES_EXIST,
704
									[ULPROPTAG => $props["recurrenceend"],
705
									],
706
								],
707
								[RES_PROPERTY,
708
									[RELOP => RELOP_EQ,
709
										ULPROPTAG => $props["isrecurring"],
710
										VALUE => true,
711
									],
712
								],
713
								[RES_PROPERTY,
714
									[RELOP => RELOP_GE,
715
										ULPROPTAG => $props["recurrenceend"],
716
										VALUE => $start,
717
									],
718
								],
719
							],
720
						],
721
						// OR
722
						// (!EXIST(recurrence_enddate_property) && item[isRecurring] == true && item[start] <= end)
723
						[RES_AND,
724
							[
725
								[RES_NOT,
726
									[
727
										[RES_EXIST,
728
											[ULPROPTAG => $props["recurrenceend"],
729
											],
730
										],
731
									],
732
								],
733
								[RES_PROPERTY,
734
									[RELOP => RELOP_LE,
735
										ULPROPTAG => $props["starttime"],
736
										VALUE => $end,
737
									],
738
								],
739
								[RES_PROPERTY,
740
									[RELOP => RELOP_EQ,
741
										ULPROPTAG => $props["isrecurring"],
742
										VALUE => true,
743
									],
744
								],
745
							],
746
						],
747
					],
748
				], // EXISTS OR
749
			],
750
		];        // global OR
751
	}
752
753
	/**
754
	 * Performs ICS based sync used from getChangesForAddressBook
755
	 * / getChangesForCalendar.
756
	 *
757
	 * @param string $folderId
758
	 * @param string $syncToken
759
	 * @param string $fileExtension
760
	 * @param int    $limit
761
	 * @param array  $filters
762
	 *
763
	 * @return null|array
764
	 */
765
	public function Sync($folderId, $syncToken, $fileExtension, $limit = null, $filters = []) {
766
		$arr = explode(':', $folderId);
767
		$phpwrapper = new PHPWrapper($this->GetStoreById($folderId), $this->logger, $this->GetCustomProperties($folderId), $fileExtension, $this->syncstate, $arr[1]);
768
		$mapiimporter = mapi_wrap_importcontentschanges($phpwrapper);
769
770
		$mapifolder = $this->GetMapiFolder($folderId);
771
		$exporter = mapi_openproperty($mapifolder, PR_CONTENTS_SYNCHRONIZER, IID_IExchangeExportChanges, 0, 0);
772
		if (!$exporter) {
773
			$this->logger->error("Unable to get exporter");
774
775
			return null;
776
		}
777
778
		$stream = mapi_stream_create();
779
		if ($syncToken == null || $syncToken == '0000000000') {
780
			mapi_stream_write($stream, hex2bin("0000000000000000"));
781
		}
782
		else {
783
			$value = $this->syncstate->getState($arr[1], $syncToken);
784
			if ($value === null) {
785
				$this->logger->error("Unable to get value from token: %s - folderId: %s", $syncToken, $folderId);
786
787
				return null;
788
			}
789
			mapi_stream_write($stream, hex2bin($value));
790
		}
791
792
		// force restriction of "types" to export only appointments or contacts
793
		$restriction = $this->getRestrictionForFilters($filters);
794
795
		// The last parameter in mapi_exportchanges_config is buffer size for mapi_exportchanges_synchronize - how many
796
		// changes will be processed in its call. Setting it to MAX_SYNC_ITEMS won't export more items than is set in
797
		// the config. If there are more changes than MAX_SYNC_ITEMS the client will eventually catch up and sync
798
		// the rest on the subsequent sync request(s).
799
		$bufferSize = ($limit !== null && $limit > 0) ? $limit : MAX_SYNC_ITEMS;
800
		mapi_exportchanges_config($exporter, $stream, SYNC_NORMAL | SYNC_UNICODE, $mapiimporter, $restriction, false, false, $bufferSize);
801
		$changesCount = mapi_exportchanges_getchangecount($exporter);
802
		$this->logger->debug("Exporter found %d changes, buffer size for mapi_exportchanges_synchronize %d", $changesCount, $bufferSize);
803
		while (is_array(mapi_exportchanges_synchronize($exporter))) {
804
			if ($changesCount > $bufferSize) {
805
				$this->logger->info("There were too many changes to be exported in this request. Total changes %d, exported %d.", $changesCount, $phpwrapper->Total());
806
807
				break;
808
			}
809
		}
810
		$exportedChanges = $phpwrapper->Total();
811
		$this->logger->debug("Exported %d changes, pending %d", $exportedChanges, $changesCount - $exportedChanges);
812
813
		mapi_exportchanges_updatestate($exporter, $stream);
814
		mapi_stream_seek($stream, 0, STREAM_SEEK_SET);
815
		$state = "";
816
		while (true) {
817
			$data = mapi_stream_read($stream, 4096);
818
			if (strlen($data) > 0) {
819
				$state .= $data;
820
			}
821
			else {
822
				break;
823
			}
824
		}
825
826
		$newtoken = ($phpwrapper->Total() > 0) ? uniqid() : $syncToken;
827
828
		$this->syncstate->setState($arr[1], $newtoken, bin2hex($state));
829
830
		$result = [
831
			"syncToken" => $newtoken,
832
			"added" => $phpwrapper->GetAdded(),
833
			"modified" => $phpwrapper->GetModified(),
834
			"deleted" => $phpwrapper->GetDeleted(),
835
		];
836
837
		$this->logger->trace("Returning %s", $result);
838
839
		return $result;
840
	}
841
842
	/**
843
	 * Returns an array of necessary properties to set with default values.
844
	 *
845
	 * @see MapiProps::GetDefault...Properties()
846
	 *
847
	 * @param mixed $id           storeid
848
	 * @param mixed $mapimessage  mapi message to check
849
	 * @param array $propList     array of mapped properties
850
	 * @param array $defaultProps array of necessary properties with default values
851
	 *
852
	 * @return array
853
	 */
854
	public function GetPropsToSet($id, $mapimessage, $propList, $defaultProps) {
855
		$propsToSet = [];
856
		$store = $this->GetStoreById($id);
857
		$propList = getPropIdsFromStrings($store, $propList);
0 ignored issues
show
The function getPropIdsFromStrings was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

857
		$propList = /** @scrutinizer ignore-call */ getPropIdsFromStrings($store, $propList);
Loading history...
858
		$props = mapi_getprops($mapimessage);
859
860
		foreach ($defaultProps as $prop => $value) {
861
			if (!isset($props[$propList[$prop]])) {
862
				$propsToSet[$propList[$prop]] = $value;
863
			}
864
		}
865
866
		return $propsToSet;
867
	}
868
869
	/**
870
	 * Returns the current sync-token for the folder if one was issued.
871
	 *
872
	 * @param string $folderId composite id in form principal:sourcekey
873
	 *
874
	 * @return string
875
	*/
876
	public function GetCurrentSyncToken($folderId) {
877
		$arr = explode(':', $folderId, 2);
878
		if (count($arr) < 2 || $arr[1] === '') {
879
			return '0000000000';
880
		}
881
882
		$token = $this->syncstate->getCurrentToken($arr[1]);
883
884
		return (!is_string($token) || $token === '') ? '0000000000' : $token;
885
	}
886
887
	/**
888
	 * Checks whether the user is enabled for grommunio-dav.
889
	 *
890
	 * @return bool
891
	 */
892
	private function isGdavEnabled() {
893
		$storeProps = mapi_getprops($this->GetStore($this->GetUser()), [PR_EC_ENABLED_FEATURES_L]);
0 ignored issues
show
The constant grommunio\DAV\PR_EC_ENABLED_FEATURES_L was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
894
		if ($storeProps[PR_EC_ENABLED_FEATURES_L] & UP_DAV) {
0 ignored issues
show
The constant grommunio\DAV\UP_DAV was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
895
			$this->logger->debug("user %s is enabled for grommunio-dav", $this->user);
896
897
			return true;
898
		}
899
		$this->logger->debug("user %s is disabled for grommunio-dav", $this->user);
900
901
		return false;
902
	}
903
}
904