Issues (187)

lib/GrommunioDavBackend.php (1 issue)

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));
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
		$this->stores[$storename] = $this->OpenMapiStore($storename);
416
		if (!$this->stores[$storename]) {
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
		return $this->stores[$storename];
423
	}
424
425
	/**
426
	 * Returns store from the id.
427
	 *
428
	 * @param mixed $id
429
	 *
430
	 * @return mixed
431
	 */
432
	public function GetStoreById($id) {
433
		$arr = explode(':', $id);
434
435
		return $this->GetStore($arr[0]);
436
	}
437
438
	/**
439
	 * Returns logon session.
440
	 *
441
	 * @return mixed
442
	 */
443
	public function GetSession() {
444
		return $this->session;
445
	}
446
447
	/**
448
	 * Returns an object ID of a mapi object.
449
	 * If set, goid will be preferred. If not the PR_SOURCE_KEY of the message (as hex) will be returned.
450
	 *
451
	 * This order is reflected as well when searching for a message with these ids in GrommunioDavBackend->GetMapiMessageForId().
452
	 *
453
	 * @param string $folderId
454
	 * @param mixed  $mapimessage
455
	 *
456
	 * @return string
457
	 */
458
	public function GetIdOfMapiMessage($folderId, $mapimessage) {
459
		$this->logger->trace("Finding ID of %s", $mapimessage);
460
		$properties = $this->GetCustomProperties($folderId);
461
462
		// It's one of these, order:
463
		// - GOID (if set)
464
		// - PROP_VCARDUID (if set)
465
		// - PR_SOURCE_KEY
466
		$props = mapi_getprops($mapimessage, [$properties['goid'], PR_SOURCE_KEY]);
467
		if (isset($props[$properties['goid']])) {
468
			$id = getUidFromGoid($props[$properties['goid']]);
469
			$this->logger->debug("Found uid %s from goid: %s", $id, bin2hex($props[$properties['goid']]));
470
			if ($id != null) {
471
				return rawurlencode($id);
472
			}
473
		}
474
		// PR_SOURCE_KEY is always available
475
		$id = bin2hex($props[PR_SOURCE_KEY]);
476
		$this->logger->debug("Found PR_SOURCE_KEY: %s", $id);
477
478
		return $id;
479
	}
480
481
	/**
482
	 * Finds and opens a MapiMessage from an objectId.
483
	 * The id can be a PROP_APPTTSREF or a PR_SOURCE_KEY (as hex).
484
	 *
485
	 * @param string $folderId
486
	 * @param string $objectUri
487
	 * @param mixed  $mapifolder optional
488
	 * @param string $extension  optional
489
	 *
490
	 * @return mixed
491
	 */
492
	public function GetMapiMessageForId($folderId, $objectUri, $mapifolder = null, $extension = null) {
493
		$this->logger->trace("Searching for '%s' in '%s' (%s) (%s)", $objectUri, $folderId, $mapifolder, $extension);
494
495
		if (!$mapifolder) {
496
			$mapifolder = $this->GetMapiFolder($folderId);
497
		}
498
499
		$id = rawurldecode($this->GetObjectIdFromObjectUri($objectUri, $extension));
500
501
		/* The ID can be several different things:
502
		 * - a UID that is saved in goid
503
		 * - a PROP_VCARDUID
504
		 * - a PR_SOURCE_KEY
505
		 *
506
		 * If it's a sourcekey, we can open the message directly.
507
		 * If the $extension is set:
508
		 *      if it's ics:
509
		 *          - search GOID with this value
510
		 *      if it's vcf:
511
		 *          - search PROP_VCARDUID value
512
		 */
513
		$entryid = false;
514
		$restriction = false;
515
516
		if (ctype_xdigit($id) && strlen($id) % 2 == 0) {
517
			$this->logger->trace("Try PR_SOURCE_KEY %s", $id);
518
			$arr = explode(':', $folderId);
519
			$entryid = mapi_msgstore_entryidfromsourcekey($this->GetStoreById($arr[0]), hex2bin($arr[1]), hex2bin($id));
520
		}
521
522
		if (!$entryid) {
523
			$this->logger->trace("Entryid not found. Try goid/vcarduid %s", $id);
524
525
			$properties = $this->GetCustomProperties($folderId);
526
			$restriction = [];
527
528
			if ($extension) {
529
				if ($extension == GrommunioCalDavBackend::FILE_EXTENSION) {
530
					$this->logger->trace("Try goid %s", $id);
531
					$goid = getGoidFromUid($id);
532
					$this->logger->trace("Try goid 0x%08X => %s", $properties["goid"], bin2hex($goid));
533
					$goid0 = getGoidFromUidZero($id);
534
					$restriction[] = [RES_OR, [
0 ignored issues
show
The constant grommunio\DAV\RES_OR was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
535
						[RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => $properties["goid"], VALUE => $goid]],
536
						[RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => $properties["goid"], VALUE => $goid0]],
537
					]];
538
				}
539
				elseif ($extension == GrommunioCardDavBackend::FILE_EXTENSION) {
540
					$this->logger->trace("Try vcarduid %s", $id);
541
					$restriction[] = [RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => $properties["vcarduid"], VALUE => $id]];
542
				}
543
			}
544
		}
545
546
		// find the message if we have a restriction
547
		if ($restriction) {
548
			$table = mapi_folder_getcontentstable($mapifolder, MAPI_DEFERRED_ERRORS);
549
			mapi_table_restrict($table, [RES_OR, $restriction]);
550
			// Get requested properties, plus whatever we need
551
			$proplist = [PR_ENTRYID];
552
			$rows = mapi_table_queryallrows($table, $proplist);
553
			if (count($rows) > 1) {
554
				$this->logger->warn("Found %d entries for id '%s' searching for message, returnin first in the list", count($rows), $id);
555
			}
556
			if (isset($rows[0], $rows[0][PR_ENTRYID])) {
557
				$entryid = $rows[0][PR_ENTRYID];
558
			}
559
		}
560
		if (!$entryid) {
561
			$this->logger->debug("Try to get entryid from appttsref");
562
			$arr = explode(':', $folderId);
563
			$sk = $this->syncstate->getSourcekey($arr[1], $id);
564
			if ($sk !== null) {
565
				$this->logger->debug("Found sourcekey from appttsref %s", $sk);
566
				$entryid = mapi_msgstore_entryidfromsourcekey($this->GetStoreById($arr[0]), hex2bin($arr[1]), hex2bin($sk));
567
			}
568
		}
569
		if ($entryid) {
570
			$mapimessage = mapi_msgstore_openentry($this->GetStoreById($folderId), $entryid);
571
			if (!$mapimessage) {
572
				$this->logger->warn("Error, unable to open entry id: %s 0x%X", bin2hex($entryid), mapi_last_hresult());
573
574
				return null;
575
			}
576
577
			return $mapimessage;
578
		}
579
		$this->logger->debug("Nothing found for %s", $id);
580
581
		return null;
582
	}
583
584
	/**
585
	 * Returns the objectId from an objectUri. It strips the file extension
586
	 * if it matches the passed one.
587
	 *
588
	 * @param string $objectUri
589
	 * @param string $extension
590
	 *
591
	 * @return string
592
	 */
593
	public function GetObjectIdFromObjectUri($objectUri, $extension) {
594
		if (!$extension) {
595
			return $objectUri;
596
		}
597
		$extLength = strlen($extension);
598
		if (substr($objectUri, -$extLength) === $extension) {
599
			return substr($objectUri, 0, -$extLength);
600
		}
601
602
		return $objectUri;
603
	}
604
605
	/**
606
	 * Checks if the PHP-MAPI extension is available and in a requested version.
607
	 *
608
	 * @param string $version the version to be checked ("6.30.10-18495", parts or build number)
609
	 *
610
	 * @return bool installed version is superior to the checked string
611
	 */
612
	protected function checkMapiExtVersion($version = "") {
613
		if (!extension_loaded("mapi")) {
614
			return false;
615
		}
616
		// compare build number if requested
617
		if (preg_match('/^\d+$/', $version) && strlen($version) > 3) {
618
			$vs = preg_split('/-/', phpversion("mapi"));
619
620
			return $version <= $vs[1];
621
		}
622
		if (version_compare(phpversion("mapi"), $version) == -1) {
623
			return false;
624
		}
625
626
		return true;
627
	}
628
629
	/**
630
	 * Get named (custom) properties. Currently only PROP_APPTTSREF.
631
	 *
632
	 * @param string $id the folder id
633
	 *
634
	 * @return mixed
635
	 */
636
	protected function GetCustomProperties($id) {
637
		if (!isset($this->customprops[$id])) {
638
			$this->logger->trace("Fetching properties id:%s", $id);
639
			$store = $this->GetStoreById($id);
640
			$properties = getPropIdsFromStrings($store, [
641
				"goid" => "PT_BINARY:PSETID_Meeting:" . PidLidGlobalObjectId,
642
				"vcarduid" => MapiProps::PROP_VCARDUID,
643
			]);
644
			$this->customprops[$id] = $properties;
645
		}
646
647
		return $this->customprops[$id];
648
	}
649
650
	/**
651
	 * Create a MAPI restriction to use in the calendar which will
652
	 * return future calendar items (until $end), plus those since $start.
653
	 * Origins: Z-Push.
654
	 *
655
	 * @param mixed $store the MAPI store
656
	 * @param int   $start Timestamp since when to include messages
657
	 * @param int   $end   Ending timestamp
658
	 *
659
	 * @return array
660
	 */
661
	// TODO getting named properties
662
	public function GetCalendarRestriction($store, $start, $end) {
663
		$props = MapiProps::GetAppointmentProperties();
664
		$props = getPropIdsFromStrings($store, $props);
665
666
		return [RES_OR,
667
			[
668
				// OR
669
				// item.end > window.start && item.start < window.end
670
				[RES_AND,
671
					[
672
						[RES_PROPERTY,
673
							[RELOP => RELOP_LE,
674
								ULPROPTAG => $props["starttime"],
675
								VALUE => $end,
676
							],
677
						],
678
						[RES_PROPERTY,
679
							[RELOP => RELOP_GE,
680
								ULPROPTAG => $props["endtime"],
681
								VALUE => $start,
682
							],
683
						],
684
					],
685
				],
686
				// OR
687
				[RES_OR,
688
					[
689
						// OR
690
						// (EXIST(recurrence_enddate_property) && item[isRecurring] == true && recurrence_enddate_property >= start)
691
						[RES_AND,
692
							[
693
								[RES_EXIST,
694
									[ULPROPTAG => $props["recurrenceend"],
695
									],
696
								],
697
								[RES_PROPERTY,
698
									[RELOP => RELOP_EQ,
699
										ULPROPTAG => $props["isrecurring"],
700
										VALUE => true,
701
									],
702
								],
703
								[RES_PROPERTY,
704
									[RELOP => RELOP_GE,
705
										ULPROPTAG => $props["recurrenceend"],
706
										VALUE => $start,
707
									],
708
								],
709
							],
710
						],
711
						// OR
712
						// (!EXIST(recurrence_enddate_property) && item[isRecurring] == true && item[start] <= end)
713
						[RES_AND,
714
							[
715
								[RES_NOT,
716
									[
717
										[RES_EXIST,
718
											[ULPROPTAG => $props["recurrenceend"],
719
											],
720
										],
721
									],
722
								],
723
								[RES_PROPERTY,
724
									[RELOP => RELOP_LE,
725
										ULPROPTAG => $props["starttime"],
726
										VALUE => $end,
727
									],
728
								],
729
								[RES_PROPERTY,
730
									[RELOP => RELOP_EQ,
731
										ULPROPTAG => $props["isrecurring"],
732
										VALUE => true,
733
									],
734
								],
735
							],
736
						],
737
					],
738
				], // EXISTS OR
739
			],
740
		];        // global OR
741
	}
742
743
	/**
744
	 * Performs ICS based sync used from getChangesForAddressBook
745
	 * / getChangesForCalendar.
746
	 *
747
	 * @param string $folderId
748
	 * @param string $syncToken
749
	 * @param string $fileExtension
750
	 * @param int    $limit
751
	 * @param array  $filters
752
	 *
753
	 * @return null|array
754
	 */
755
	public function Sync($folderId, $syncToken, $fileExtension, $limit = null, $filters = []) {
756
		$arr = explode(':', $folderId);
757
		$phpwrapper = new PHPWrapper($this->GetStoreById($folderId), $this->logger, $this->GetCustomProperties($folderId), $fileExtension, $this->syncstate, $arr[1]);
758
		$mapiimporter = mapi_wrap_importcontentschanges($phpwrapper);
759
760
		$mapifolder = $this->GetMapiFolder($folderId);
761
		$exporter = mapi_openproperty($mapifolder, PR_CONTENTS_SYNCHRONIZER, IID_IExchangeExportChanges, 0, 0);
762
		if (!$exporter) {
763
			$this->logger->error("Unable to get exporter");
764
765
			return null;
766
		}
767
768
		$stream = mapi_stream_create();
769
		if ($syncToken == null || $syncToken == '0000000000') {
770
			mapi_stream_write($stream, hex2bin("0000000000000000"));
771
		}
772
		else {
773
			$value = $this->syncstate->getState($arr[1], $syncToken);
774
			if ($value === null) {
775
				$this->logger->error("Unable to get value from token: %s - folderId: %s", $syncToken, $folderId);
776
777
				return null;
778
			}
779
			mapi_stream_write($stream, hex2bin($value));
780
		}
781
782
		// force restriction of "types" to export only appointments or contacts
783
		$restriction = $this->getRestrictionForFilters($filters);
784
785
		// The last parameter in mapi_exportchanges_config is buffer size for mapi_exportchanges_synchronize - how many
786
		// changes will be processed in its call. Setting it to MAX_SYNC_ITEMS won't export more items than is set in
787
		// the config. If there are more changes than MAX_SYNC_ITEMS the client will eventually catch up and sync
788
		// the rest on the subsequent sync request(s).
789
		$bufferSize = ($limit !== null && $limit > 0) ? $limit : MAX_SYNC_ITEMS;
790
		mapi_exportchanges_config($exporter, $stream, SYNC_NORMAL | SYNC_UNICODE, $mapiimporter, $restriction, false, false, $bufferSize);
791
		$changesCount = mapi_exportchanges_getchangecount($exporter);
792
		$this->logger->debug("Exporter found %d changes, buffer size for mapi_exportchanges_synchronize %d", $changesCount, $bufferSize);
793
		while (is_array(mapi_exportchanges_synchronize($exporter))) {
794
			if ($changesCount > $bufferSize) {
795
				$this->logger->info("There were too many changes to be exported in this request. Total changes %d, exported %d.", $changesCount, $phpwrapper->Total());
796
797
				break;
798
			}
799
		}
800
		$exportedChanges = $phpwrapper->Total();
801
		$this->logger->debug("Exported %d changes, pending %d", $exportedChanges, $changesCount - $exportedChanges);
802
803
		mapi_exportchanges_updatestate($exporter, $stream);
804
		mapi_stream_seek($stream, 0, STREAM_SEEK_SET);
805
		$state = "";
806
		while (true) {
807
			$data = mapi_stream_read($stream, 4096);
808
			if (strlen($data) > 0) {
809
				$state .= $data;
810
			}
811
			else {
812
				break;
813
			}
814
		}
815
816
		$newtoken = ($phpwrapper->Total() > 0) ? uniqid() : $syncToken;
817
818
		$this->syncstate->setState($arr[1], $newtoken, bin2hex($state));
819
820
		$result = [
821
			"syncToken" => $newtoken,
822
			"added" => $phpwrapper->GetAdded(),
823
			"modified" => $phpwrapper->GetModified(),
824
			"deleted" => $phpwrapper->GetDeleted(),
825
		];
826
827
		$this->logger->trace("Returning %s", $result);
828
829
		return $result;
830
	}
831
832
	/**
833
	 * Returns an array of necessary properties to set with default values.
834
	 *
835
	 * @see MapiProps::GetDefault...Properties()
836
	 *
837
	 * @param mixed $id           storeid
838
	 * @param mixed $mapimessage  mapi message to check
839
	 * @param array $propList     array of mapped properties
840
	 * @param array $defaultProps array of necessary properties with default values
841
	 *
842
	 * @return array
843
	 */
844
	public function GetPropsToSet($id, $mapimessage, $propList, $defaultProps) {
845
		$propsToSet = [];
846
		$store = $this->GetStoreById($id);
847
		$propList = getPropIdsFromStrings($store, $propList);
848
		$props = mapi_getprops($mapimessage);
849
850
		foreach ($defaultProps as $prop => $value) {
851
			if (!isset($props[$propList[$prop]])) {
852
				$propsToSet[$propList[$prop]] = $value;
853
			}
854
		}
855
856
		return $propsToSet;
857
	}
858
859
	/**
860
	 * Returns the current sync-token for the folder if one was issued.
861
	 *
862
	 * @param string $folderId composite id in form principal:sourcekey
863
	 *
864
	 * @return string
865
	*/
866
	public function GetCurrentSyncToken($folderId) {
867
		$arr = explode(':', $folderId, 2);
868
		if (count($arr) < 2 || $arr[1] === '') {
869
			return '0000000000';
870
		}
871
872
		$token = $this->syncstate->getCurrentToken($arr[1]);
873
874
		return (!is_string($token) || $token === '') ? '0000000000' : $token;
875
	}
876
877
	/**
878
	 * Checks whether the user is enabled for grommunio-dav.
879
	 *
880
	 * @return bool
881
	 */
882
	private function isGdavEnabled() {
883
		$storeProps = mapi_getprops($this->GetStore($this->GetUser()), [PR_EC_ENABLED_FEATURES_L]);
884
		if ($storeProps[PR_EC_ENABLED_FEATURES_L] & UP_DAV) {
885
			$this->logger->debug("user %s is enabled for grommunio-dav", $this->user);
886
887
			return true;
888
		}
889
		$this->logger->debug("user %s is disabled for grommunio-dav", $this->user);
890
891
		return false;
892
	}
893
}
894