Issues (171)

lib/GrommunioDavBackend.php (10 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);
0 ignored issues
show
The function mapi_folder_createfolder 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

81
		$newfolder = /** @scrutinizer ignore-call */ mapi_folder_createfolder($folder, $url, $displayname);
Loading history...
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]);
0 ignored issues
show
The function mapi_folder_deletefolder 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

102
		/** @scrutinizer ignore-call */ 
103
  mapi_folder_deletefolder($parentfolder, $props[PR_ENTRYID]);
Loading history...
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
141
			if (isset($row[PR_PARENT_ENTRYID], $storeprops[PR_IPM_WASTEBASKET_ENTRYID]) && $row[PR_PARENT_ENTRYID] == $storeprops[PR_IPM_WASTEBASKET_ENTRYID]) {
142
				continue;
143
			}
144
145
			$folder = [
146
				'id' => $principalUri . ":" . bin2hex($row[PR_SOURCE_KEY]),
147
				'uri' => $row[PR_DISPLAY_NAME],
148
				'principaluri' => $principalUri,
149
				'{http://sabredav.org/ns}sync-token' => '0000000000',
150
				'{DAV:}displayname' => $row[PR_DISPLAY_NAME],
151
				'{urn:ietf:params:xml:ns:caldav}calendar-description' => $row[PR_COMMENT],
152
				'{http://calendarserver.org/ns/}getctag' => isset($row[PR_LOCAL_COMMIT_TIME_MAX]) ? strval($row[PR_LOCAL_COMMIT_TIME_MAX]) : '0000000000',
153
			];
154
155
			// set the supported component (task or calendar)
156
			if ($row[PR_CONTAINER_CLASS] == "IPF.Task") {
157
				$folder['{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'] = new SupportedCalendarComponentSet(['VTODO']);
158
			}
159
			if ($row[PR_CONTAINER_CLASS] == "IPF.Appointment") {
160
				$folder['{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'] = new SupportedCalendarComponentSet(['VEVENT']);
161
			}
162
163
			// ensure default contacts folder is put first, some clients
164
			// i.e. Apple Addressbook only supports one contact folder,
165
			// therefore it is desired that folder is the default one.
166
			if (in_array("IPF.Contact", $classes) && isset($rootprops[PR_IPM_CONTACT_ENTRYID]) && $row[PR_ENTRYID] == $rootprops[PR_IPM_CONTACT_ENTRYID]) {
167
				array_unshift($folders, $folder);
168
			}
169
			// ensure default calendar folder is put first,
170
			// before the tasks folder.
171
			elseif (in_array('IPF.Appointment', $classes) && isset($rootprops[PR_IPM_APPOINTMENT_ENTRYID]) && $row[PR_ENTRYID] == $rootprops[PR_IPM_APPOINTMENT_ENTRYID]) {
172
				array_unshift($folders, $folder);
173
			}
174
			else {
175
				array_push($folders, $folder);
176
			}
177
		}
178
		$this->logger->trace('found %d folders: %s', count($folders), $folders);
179
180
		return $folders;
181
	}
182
183
	/**
184
	 * Returns a MAPI restriction for a defined set of filters.
185
	 *
186
	 * @param array  $filters
187
	 * @param string $storeId (optional) mapi compatible storeid - required when using start+end filter
188
	 *
189
	 * @return null|array
190
	 */
191
	private function getRestrictionForFilters($filters, $storeId = null) {
192
		$restrictions = [];
193
		if (isset($filters['start'], $filters['end'], $storeId)) {
194
			$this->logger->trace("getRestrictionForFilters - got start: %d and end: %d", $filters['start'], $filters['end']);
195
			$subrestriction = $this->GetCalendarRestriction($storeId, $filters['start'], $filters['end']);
196
			$restrictions[] = $subrestriction;
197
		}
198
		if (isset($filters['types'])) {
199
			$this->logger->trace("getRestrictionForFilters - got types: %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
			$restrictions[] = [RES_OR, $arr];
210
		}
211
		if (!empty($restrictions)) {
212
			$restriction = [RES_AND, $restrictions];
213
			$this->logger->trace("getRestrictionForFilters - got restriction: %s", simplifyRestriction($restriction));
214
215
			return $restriction;
216
		}
217
218
		return null;
219
	}
220
221
	/**
222
	 * Returns a list of objects for a folder given by the id.
223
	 *
224
	 * @param string $id
225
	 * @param string $fileExtension
226
	 * @param array  $filters
227
	 *
228
	 * @return array
229
	 */
230
	public function GetObjects($id, $fileExtension, $filters = []) {
231
		$folder = $this->GetMapiFolder($id);
232
		$properties = $this->GetCustomProperties($id);
233
		$table = mapi_folder_getcontentstable($folder, MAPI_DEFERRED_ERRORS);
234
		$restriction = $this->getRestrictionForFilters($filters, $this->GetStoreById($id));
235
		if ($restriction) {
236
			mapi_table_restrict($table, $restriction);
237
		}
238
239
		$rows = mapi_table_queryallrows($table, [PR_SOURCE_KEY, PR_LAST_MODIFICATION_TIME, PR_MESSAGE_SIZE, $properties['goid']]);
240
241
		$results = [];
242
		foreach ($rows as $row) {
243
			$realId = "";
244
			if (isset($row[$properties['goid']])) {
245
				$realId = getUidFromGoid($row[$properties['goid']]);
246
			}
247
			if (!$realId) {
248
				$realId = bin2hex($row[PR_SOURCE_KEY]);
249
			}
250
			$realId = rawurlencode($realId);
251
252
			$result = [
253
				'id' => $realId,
254
				'uri' => $realId . $fileExtension,
255
				'etag' => '"' . $row[PR_LAST_MODIFICATION_TIME] . '"',
256
				'lastmodified' => $row[PR_LAST_MODIFICATION_TIME],
257
				'size' => $row[PR_MESSAGE_SIZE], // only approximation
258
			];
259
260
			if ($fileExtension == GrommunioCalDavBackend::FILE_EXTENSION) {
261
				$result['calendarid'] = $id;
262
			}
263
			elseif ($fileExtension == GrommunioCardDavBackend::FILE_EXTENSION) {
264
				$result['addressbookid'] = $id;
265
			}
266
			$results[] = $result;
267
		}
268
269
		return $results;
270
	}
271
272
	/**
273
	 * Create the object and set appttsref.
274
	 *
275
	 * @param mixed  $folderId
276
	 * @param mixed  $folder
277
	 * @param string $objectId
278
	 *
279
	 * @return mixed
280
	 */
281
	public function CreateObject($folderId, $folder, $objectId) {
282
		$mapimessage = mapi_folder_createmessage($folder);
283
		// we save the objectId in PROP_APPTTSREF so we find it by this id
284
		$properties = $this->GetCustomProperties($folderId);
285
		// FIXME: uid for contacts
286
		$goid = getGoidFromUid($objectId);
287
		mapi_setprops($mapimessage, [$properties['goid'] => $goid]);
288
289
		return $mapimessage;
290
	}
291
292
	/**
293
	 * Returns a mapi folder resource for a folderid (PR_SOURCE_KEY).
294
	 *
295
	 * @param string $folderid
296
	 *
297
	 * @return mixed
298
	 */
299
	public function GetMapiFolder($folderid) {
300
		$this->logger->trace('Id: %s', $folderid);
301
		$arr = explode(':', $folderid);
302
		$entryid = mapi_msgstore_entryidfromsourcekey($this->GetStore($arr[0]), hex2bin($arr[1]));
303
304
		return mapi_msgstore_openentry($this->GetStore($arr[0]), $entryid);
305
	}
306
307
	/**
308
	 * Returns MAPI addressbook.
309
	 *
310
	 * @return mixed
311
	 */
312
	public function GetAddressBook() {
313
		// TODO should be a singleton
314
		return mapi_openaddressbook($this->session);
315
	}
316
317
	/**
318
	 * Opens MAPI store for the user.
319
	 *
320
	 * @param string $username
321
	 *
322
	 * @return mixed
323
	 */
324
	public function OpenMapiStore($username = null) {
325
		$msgstorestable = mapi_getmsgstorestable($this->session);
326
		$msgstores = mapi_table_queryallrows($msgstorestable, [PR_DEFAULT_STORE, PR_ENTRYID, PR_MDB_PROVIDER]);
327
328
		$defaultstore = null;
329
		$publicstore = null;
330
		foreach ($msgstores as $row) {
331
			if (isset($row[PR_DEFAULT_STORE]) && $row[PR_DEFAULT_STORE]) {
332
				$defaultstore = $row[PR_ENTRYID];
333
			}
334
			if (isset($row[PR_MDB_PROVIDER]) && $row[PR_MDB_PROVIDER] == ZARAFA_STORE_PUBLIC_GUID) {
335
				$publicstore = $row[PR_ENTRYID];
336
			}
337
		}
338
339
		/* user's own store or public store */
340
		if ($username == $this->GetUser() && $defaultstore != null) {
341
			return mapi_openmsgstore($this->session, $defaultstore);
342
		}
343
		if ($username == 'public' && $publicstore != null) {
344
			return mapi_openmsgstore($this->session, $publicstore);
345
		}
346
347
		/* otherwise other user's store */
348
		$store = mapi_openmsgstore($this->session, $defaultstore);
349
		if (!$store) {
350
			return false;
351
		}
352
		$otherstore = mapi_msgstore_createentryid($store, $username);
353
354
		return mapi_openmsgstore($this->session, $otherstore);
355
	}
356
357
	/**
358
	 * Returns store for the user.
359
	 *
360
	 * @param string $storename
361
	 *
362
	 * @return mixed
363
	 */
364
	public function GetStore($storename) {
365
		if ($storename == null) {
366
			$storename = $this->GetUser();
367
		}
368
		else {
369
			$storename = str_replace('principals/', '', $storename);
370
		}
371
		$this->logger->trace("storename %s", $storename);
372
373
		/* We already got the store */
374
		if (isset($this->stores[$storename]) && $this->stores[$storename] != null) {
375
			return $this->stores[$storename];
376
		}
377
378
		$this->stores[$storename] = $this->OpenMapiStore($storename);
379
		if (!$this->stores[$storename]) {
380
			$this->logger->info("Auth: ERROR - unable to open store for %s (0x%08X)", $storename, mapi_last_hresult());
381
382
			return false;
383
		}
384
385
		return $this->stores[$storename];
386
	}
387
388
	/**
389
	 * Returns store from the id.
390
	 *
391
	 * @param mixed $id
392
	 *
393
	 * @return mixed
394
	 */
395
	public function GetStoreById($id) {
396
		$arr = explode(':', $id);
397
398
		return $this->GetStore($arr[0]);
399
	}
400
401
	/**
402
	 * Returns logon session.
403
	 *
404
	 * @return mixed
405
	 */
406
	public function GetSession() {
407
		return $this->session;
408
	}
409
410
	/**
411
	 * Returns an object ID of a mapi object.
412
	 * If set, goid will be preferred. If not the PR_SOURCE_KEY of the message (as hex) will be returned.
413
	 *
414
	 * This order is reflected as well when searching for a message with these ids in GrommunioDavBackend->GetMapiMessageForId().
415
	 *
416
	 * @param string $folderId
417
	 * @param mixed  $mapimessage
418
	 *
419
	 * @return string
420
	 */
421
	public function GetIdOfMapiMessage($folderId, $mapimessage) {
422
		$this->logger->trace("Finding ID of %s", $mapimessage);
423
		$properties = $this->GetCustomProperties($folderId);
424
425
		// It's one of these, order:
426
		// - GOID (if set)
427
		// - PROP_VCARDUID (if set)
428
		// - PR_SOURCE_KEY
429
		$props = mapi_getprops($mapimessage, [$properties['goid'], PR_SOURCE_KEY]);
430
		if (isset($props[$properties['goid']])) {
431
			$id = getUidFromGoid($props[$properties['goid']]);
432
			$this->logger->debug("Found uid %s from goid: %s", $id, bin2hex($props[$properties['goid']]));
433
			if ($id != null) {
434
				return rawurlencode($id);
435
			}
436
		}
437
		// PR_SOURCE_KEY is always available
438
		$id = bin2hex($props[PR_SOURCE_KEY]);
439
		$this->logger->debug("Found PR_SOURCE_KEY: %s", $id);
440
441
		return $id;
442
	}
443
444
	/**
445
	 * Finds and opens a MapiMessage from an objectId.
446
	 * The id can be a PROP_APPTTSREF or a PR_SOURCE_KEY (as hex).
447
	 *
448
	 * @param string $folderId
449
	 * @param string $objectUri
450
	 * @param mixed  $mapifolder optional
451
	 * @param string $extension  optional
452
	 *
453
	 * @return mixed
454
	 */
455
	public function GetMapiMessageForId($folderId, $objectUri, $mapifolder = null, $extension = null) {
456
		$this->logger->trace("Searching for '%s' in '%s' (%s) (%s)", $objectUri, $folderId, $mapifolder, $extension);
457
458
		if (!$mapifolder) {
459
			$mapifolder = $this->GetMapiFolder($folderId);
460
		}
461
462
		$id = rawurldecode($this->GetObjectIdFromObjectUri($objectUri, $extension));
463
464
		/* The ID can be several different things:
465
		 * - a UID that is saved in goid
466
		 * - a PROP_VCARDUID
467
		 * - a PR_SOURCE_KEY
468
		 *
469
		 * If it's a sourcekey, we can open the message directly.
470
		 * If the $extension is set:
471
		 *      if it's ics:
472
		 *          - search GOID with this value
473
		 *      if it's vcf:
474
		 *          - search PROP_VCARDUID value
475
		 */
476
		$entryid = false;
477
		$restriction = false;
478
479
		if (ctype_xdigit($id) && strlen($id) % 2 == 0) {
480
			$this->logger->trace("Try PR_SOURCE_KEY %s", $id);
481
			$arr = explode(':', $folderId);
482
			$entryid = mapi_msgstore_entryidfromsourcekey($this->GetStoreById($arr[0]), hex2bin($arr[1]), hex2bin($id));
483
		}
484
485
		if (!$entryid) {
486
			$this->logger->trace("Entryid not found. Try goid/vcarduid %s", $id);
487
488
			$properties = $this->GetCustomProperties($folderId);
489
			$restriction = [];
490
491
			if ($extension) {
492
				if ($extension == GrommunioCalDavBackend::FILE_EXTENSION) {
493
					$this->logger->trace("Try goid %s", $id);
494
					$goid = getGoidFromUid($id);
495
					$this->logger->trace("Try goid 0x%08X => %s", $properties["goid"], bin2hex($goid));
496
					$goid0 = getGoidFromUidZero($id);
497
					$restriction[] = [RES_OR, [
498
						[RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => $properties["goid"], VALUE => $goid]],
499
						[RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => $properties["goid"], VALUE => $goid0]],
500
					]];
501
				}
502
				elseif ($extension == GrommunioCardDavBackend::FILE_EXTENSION) {
503
					$this->logger->trace("Try vcarduid %s", $id);
504
					$restriction[] = [RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => $properties["vcarduid"], VALUE => $id]];
505
				}
506
			}
507
		}
508
509
		// find the message if we have a restriction
510
		if ($restriction) {
511
			$table = mapi_folder_getcontentstable($mapifolder, MAPI_DEFERRED_ERRORS);
512
			mapi_table_restrict($table, [RES_OR, $restriction]);
513
			// Get requested properties, plus whatever we need
514
			$proplist = [PR_ENTRYID];
515
			$rows = mapi_table_queryallrows($table, $proplist);
516
			if (count($rows) > 1) {
517
				$this->logger->warn("Found %d entries for id '%s' searching for message, returnin first in the list", count($rows), $id);
518
			}
519
			if (isset($rows[0], $rows[0][PR_ENTRYID])) {
520
				$entryid = $rows[0][PR_ENTRYID];
521
			}
522
		}
523
		if (!$entryid) {
524
			$this->logger->debug("Try to get entryid from appttsref");
525
			$arr = explode(':', $folderId);
526
			$sk = $this->syncstate->getSourcekey($arr[1], $id);
527
			if ($sk !== null) {
528
				$this->logger->debug("Found sourcekey from appttsref %s", $sk);
529
				$entryid = mapi_msgstore_entryidfromsourcekey($this->GetStoreById($arr[0]), hex2bin($arr[1]), hex2bin($sk));
530
			}
531
		}
532
		if ($entryid) {
533
			$mapimessage = mapi_msgstore_openentry($this->GetStoreById($folderId), $entryid);
534
			if (!$mapimessage) {
535
				$this->logger->warn("Error, unable to open entry id: %s 0x%X", bin2hex($entryid), mapi_last_hresult());
536
537
				return null;
538
			}
539
540
			return $mapimessage;
541
		}
542
		$this->logger->debug("Nothing found for %s", $id);
543
544
		return null;
545
	}
546
547
	/**
548
	 * Returns the objectId from an objectUri. It strips the file extension
549
	 * if it matches the passed one.
550
	 *
551
	 * @param string $objectUri
552
	 * @param string $extension
553
	 *
554
	 * @return string
555
	 */
556
	public function GetObjectIdFromObjectUri($objectUri, $extension) {
557
		if (!$extension) {
558
			return $objectUri;
559
		}
560
		$extLength = strlen($extension);
561
		if (substr($objectUri, -$extLength) === $extension) {
562
			return substr($objectUri, 0, -$extLength);
563
		}
564
565
		return $objectUri;
566
	}
567
568
	/**
569
	 * Checks if the PHP-MAPI extension is available and in a requested version.
570
	 *
571
	 * @param string $version the version to be checked ("6.30.10-18495", parts or build number)
572
	 *
573
	 * @return bool installed version is superior to the checked string
574
	 */
575
	protected function checkMapiExtVersion($version = "") {
576
		if (!extension_loaded("mapi")) {
577
			return false;
578
		}
579
		// compare build number if requested
580
		if (preg_match('/^\d+$/', $version) && strlen($version) > 3) {
581
			$vs = preg_split('/-/', phpversion("mapi"));
582
583
			return $version <= $vs[1];
584
		}
585
		if (version_compare(phpversion("mapi"), $version) == -1) {
586
			return false;
587
		}
588
589
		return true;
590
	}
591
592
	/**
593
	 * Get named (custom) properties. Currently only PROP_APPTTSREF.
594
	 *
595
	 * @param string $id the folder id
596
	 *
597
	 * @return mixed
598
	 */
599
	protected function GetCustomProperties($id) {
600
		if (!isset($this->customprops[$id])) {
601
			$this->logger->trace("Fetching properties id:%s", $id);
602
			$store = $this->GetStoreById($id);
603
			$properties = getPropIdsFromStrings($store, [
604
				"goid" => "PT_BINARY:PSETID_Meeting:" . PidLidGlobalObjectId,
605
				"vcarduid" => MapiProps::PROP_VCARDUID,
606
			]);
607
			$this->customprops[$id] = $properties;
608
		}
609
610
		return $this->customprops[$id];
611
	}
612
613
	/**
614
	 * Create a MAPI restriction to use in the calendar which will
615
	 * return future calendar items (until $end), plus those since $start.
616
	 * Origins: Z-Push.
617
	 *
618
	 * @param mixed $store the MAPI store
619
	 * @param int   $start Timestamp since when to include messages
620
	 * @param int   $end   Ending timestamp
621
	 *
622
	 * @return array
623
	 */
624
	// TODO getting named properties
625
	public function GetCalendarRestriction($store, $start, $end) {
626
		$props = MapiProps::GetAppointmentProperties();
627
		$props = getPropIdsFromStrings($store, $props);
628
629
		return [RES_OR,
630
			[
631
				// OR
632
				// item.end > window.start && item.start < window.end
633
				[RES_AND,
634
					[
635
						[RES_PROPERTY,
636
							[RELOP => RELOP_LE,
637
								ULPROPTAG => $props["starttime"],
638
								VALUE => $end,
639
							],
640
						],
641
						[RES_PROPERTY,
642
							[RELOP => RELOP_GE,
643
								ULPROPTAG => $props["endtime"],
644
								VALUE => $start,
645
							],
646
						],
647
					],
648
				],
649
				// OR
650
				[RES_OR,
651
					[
652
						// OR
653
						// (EXIST(recurrence_enddate_property) && item[isRecurring] == true && recurrence_enddate_property >= start)
654
						[RES_AND,
655
							[
656
								[RES_EXIST,
657
									[ULPROPTAG => $props["recurrenceend"],
658
									],
659
								],
660
								[RES_PROPERTY,
661
									[RELOP => RELOP_EQ,
662
										ULPROPTAG => $props["isrecurring"],
663
										VALUE => true,
664
									],
665
								],
666
								[RES_PROPERTY,
667
									[RELOP => RELOP_GE,
668
										ULPROPTAG => $props["recurrenceend"],
669
										VALUE => $start,
670
									],
671
								],
672
							],
673
						],
674
						// OR
675
						// (!EXIST(recurrence_enddate_property) && item[isRecurring] == true && item[start] <= end)
676
						[RES_AND,
677
							[
678
								[RES_NOT,
679
									[
680
										[RES_EXIST,
681
											[ULPROPTAG => $props["recurrenceend"],
682
											],
683
										],
684
									],
685
								],
686
								[RES_PROPERTY,
687
									[RELOP => RELOP_LE,
688
										ULPROPTAG => $props["starttime"],
689
										VALUE => $end,
690
									],
691
								],
692
								[RES_PROPERTY,
693
									[RELOP => RELOP_EQ,
694
										ULPROPTAG => $props["isrecurring"],
695
										VALUE => true,
696
									],
697
								],
698
							],
699
						],
700
					],
701
				], // EXISTS OR
702
			],
703
		];        // global OR
704
	}
705
706
	/**
707
	 * Performs ICS based sync used from getChangesForAddressBook
708
	 * / getChangesForCalendar.
709
	 *
710
	 * @param string $folderId
711
	 * @param string $syncToken
712
	 * @param string $fileExtension
713
	 * @param int    $limit
714
	 * @param array  $filters
715
	 *
716
	 * @return null|array
717
	 */
718
	public function Sync($folderId, $syncToken, $fileExtension, $limit = null, $filters = []) {
719
		$arr = explode(':', $folderId);
720
		$phpwrapper = new PHPWrapper($this->GetStoreById($folderId), $this->logger, $this->GetCustomProperties($folderId), $fileExtension, $this->syncstate, $arr[1]);
721
		$mapiimporter = mapi_wrap_importcontentschanges($phpwrapper);
0 ignored issues
show
The function mapi_wrap_importcontentschanges 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

721
		$mapiimporter = /** @scrutinizer ignore-call */ mapi_wrap_importcontentschanges($phpwrapper);
Loading history...
722
723
		$mapifolder = $this->GetMapiFolder($folderId);
724
		$exporter = mapi_openproperty($mapifolder, PR_CONTENTS_SYNCHRONIZER, IID_IExchangeExportChanges, 0, 0);
725
		if (!$exporter) {
726
			$this->logger->error("Unable to get exporter");
727
728
			return null;
729
		}
730
731
		$stream = mapi_stream_create();
0 ignored issues
show
The function mapi_stream_create 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

731
		$stream = /** @scrutinizer ignore-call */ mapi_stream_create();
Loading history...
732
		if ($syncToken == null) {
733
			mapi_stream_write($stream, hex2bin("0000000000000000"));
734
		}
735
		else {
736
			$value = $this->syncstate->getState($arr[1], $syncToken);
737
			if ($value === null) {
738
				$this->logger->error("Unable to get value from token: %s - folderId: %s", $syncToken, $folderId);
739
740
				return null;
741
			}
742
			mapi_stream_write($stream, hex2bin($value));
743
		}
744
745
		// force restriction of "types" to export only appointments or contacts
746
		$restriction = $this->getRestrictionForFilters($filters);
747
748
		// The last parameter in mapi_exportchanges_config is buffer size for mapi_exportchanges_synchronize - how many
749
		// changes will be processed in its call. Setting it to MAX_SYNC_ITEMS won't export more items than is set in
750
		// the config. If there are more changes than MAX_SYNC_ITEMS the client will eventually catch up and sync
751
		// the rest on the subsequent sync request(s).
752
		$bufferSize = ($limit !== null && $limit > 0) ? $limit : MAX_SYNC_ITEMS;
753
		mapi_exportchanges_config($exporter, $stream, SYNC_NORMAL | SYNC_UNICODE, $mapiimporter, $restriction, false, false, $bufferSize);
0 ignored issues
show
The function mapi_exportchanges_config 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

753
		/** @scrutinizer ignore-call */ 
754
  mapi_exportchanges_config($exporter, $stream, SYNC_NORMAL | SYNC_UNICODE, $mapiimporter, $restriction, false, false, $bufferSize);
Loading history...
754
		$changesCount = mapi_exportchanges_getchangecount($exporter);
0 ignored issues
show
The function mapi_exportchanges_getchangecount 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

754
		$changesCount = /** @scrutinizer ignore-call */ mapi_exportchanges_getchangecount($exporter);
Loading history...
755
		$this->logger->debug("Exporter found %d changes, buffer size for mapi_exportchanges_synchronize %d", $changesCount, $bufferSize);
756
		while (is_array(mapi_exportchanges_synchronize($exporter))) {
0 ignored issues
show
The function mapi_exportchanges_synchronize 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

756
		while (is_array(/** @scrutinizer ignore-call */ mapi_exportchanges_synchronize($exporter))) {
Loading history...
757
			if ($changesCount > $bufferSize) {
758
				$this->logger->info("There were too many changes to be exported in this request. Total changes %d, exported %d.", $changesCount, $phpwrapper->Total());
759
760
				break;
761
			}
762
		}
763
		$exportedChanges = $phpwrapper->Total();
764
		$this->logger->debug("Exported %d changes, pending %d", $exportedChanges, $changesCount - $exportedChanges);
765
766
		mapi_exportchanges_updatestate($exporter, $stream);
0 ignored issues
show
The function mapi_exportchanges_updatestate 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

766
		/** @scrutinizer ignore-call */ 
767
  mapi_exportchanges_updatestate($exporter, $stream);
Loading history...
767
		mapi_stream_seek($stream, 0, STREAM_SEEK_SET);
0 ignored issues
show
The function mapi_stream_seek 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

767
		/** @scrutinizer ignore-call */ 
768
  mapi_stream_seek($stream, 0, STREAM_SEEK_SET);
Loading history...
768
		$state = "";
769
		while (true) {
770
			$data = mapi_stream_read($stream, 4096);
0 ignored issues
show
The function mapi_stream_read 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

770
			$data = /** @scrutinizer ignore-call */ mapi_stream_read($stream, 4096);
Loading history...
771
			if (strlen($data) > 0) {
772
				$state .= $data;
773
			}
774
			else {
775
				break;
776
			}
777
		}
778
779
		$newtoken = ($phpwrapper->Total() > 0) ? uniqid() : $syncToken;
780
781
		$this->syncstate->setState($arr[1], $newtoken, bin2hex($state));
782
783
		$result = [
784
			"syncToken" => $newtoken,
785
			"added" => $phpwrapper->GetAdded(),
786
			"modified" => $phpwrapper->GetModified(),
787
			"deleted" => $phpwrapper->GetDeleted(),
788
		];
789
790
		$this->logger->trace("Returning %s", $result);
791
792
		return $result;
793
	}
794
795
	/**
796
	 * Returns an array of necessary properties to set with default values.
797
	 *
798
	 * @see MapiProps::GetDefault...Properties()
799
	 *
800
	 * @param mixed $id           storeid
801
	 * @param mixed $mapimessage  mapi message to check
802
	 * @param array $propList     array of mapped properties
803
	 * @param array $defaultProps array of necessary properties with default values
804
	 *
805
	 * @return array
806
	 */
807
	public function GetPropsToSet($id, $mapimessage, $propList, $defaultProps) {
808
		$propsToSet = [];
809
		$store = $this->GetStoreById($id);
810
		$propList = getPropIdsFromStrings($store, $propList);
811
		$props = mapi_getprops($mapimessage);
812
813
		foreach ($defaultProps as $prop => $value) {
814
			if (!isset($props[$propList[$prop]])) {
815
				$propsToSet[$propList[$prop]] = $value;
816
			}
817
		}
818
819
		return $propsToSet;
820
	}
821
822
	/**
823
	 * Checks whether the user is enabled for grommunio-dav.
824
	 *
825
	 * @return bool
826
	 */
827
	private function isGdavEnabled() {
828
		$storeProps = mapi_getprops($this->GetStore($this->GetUser()), [PR_EC_ENABLED_FEATURES_L]);
829
		if ($storeProps[PR_EC_ENABLED_FEATURES_L] & UP_DAV) {
830
			$this->logger->debug("user %s is enabled for grommunio-dav", $this->user);
831
832
			return true;
833
		}
834
		$this->logger->debug("user %s is disabled for grommunio-dav", $this->user);
835
836
		return false;
837
	}
838
}
839