Issues (189)

lib/GrommunioDavBackend.php (5 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]);
0 ignored issues
show
The constant grommunio\DAV\PR_IPM_WASTEBASKET_ENTRYID was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
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]);
0 ignored issues
show
The constant grommunio\DAV\PR_COMMENT was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
The constant grommunio\DAV\PR_PARENT_ENTRYID was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
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));
0 ignored issues
show
It seems like $this->GetStoreById($id) can also be of type false; however, parameter $storeId of grommunio\DAV\GrommunioD...RestrictionForFilters() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

271
		$restriction = $this->getRestrictionForFilters($filters, /** @scrutinizer ignore-type */ $this->GetStoreById($id));
Loading history...
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) {
0 ignored issues
show
The constant grommunio\DAV\ZARAFA_STORE_PUBLIC_GUID was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
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]);
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);
544
					$restriction[] = [RES_OR, [
545
						[RES_PROPERTY, [RELOP => RELOP_EQ, ULPROPTAG => $properties["goid"], VALUE => $goid]],
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,
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);
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]);
894
		if ($storeProps[PR_EC_ENABLED_FEATURES_L] & UP_DAV) {
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