Issues (171)

lib/GrommunioCalDavBackend.php (2 issues)

1
<?php
2
/*
3
 * SPDX-License-Identifier: AGPL-3.0-only
4
 * SPDX-FileCopyrightText: Copyright 2016 - 2018 Kopano b.v.
5
 * SPDX-FileCopyrightText: Copyright 2020 - 2024 grommunio GmbH
6
 *
7
 * grommunio CalDAV backend class which handles calendar related activities.
8
 */
9
10
namespace grommunio\DAV;
11
12
use Sabre\CalDAV\Backend\AbstractBackend;
13
use Sabre\CalDAV\Backend\SchedulingSupport;
14
use Sabre\CalDAV\Backend\SyncSupport;
15
16
class GrommunioCalDavBackend extends AbstractBackend implements SchedulingSupport, SyncSupport {
17
	/*
18
	 * TODO IMPLEMENT
19
	 *
20
	 * SubscriptionSupport,
21
	 * SharingSupport,
22
	 *
23
	 */
24
25
	private $logger;
26
	protected $gDavBackend;
27
28
	public const FILE_EXTENSION = '.ics';
29
	// TODO: implement Task support - Issue: #10
30
	public const MESSAGE_CLASSES = ['IPM.Appointment' /* , 'IPM.Note' */];
31
	public const CONTAINER_CLASS = 'IPF.Appointment';
32
	public const CONTAINER_CLASSES = ['IPF.Appointment', 'IPF.Task'];
33
34
	/**
35
	 * Constructor.
36
	 */
37
	public function __construct(GrommunioDavBackend $gDavBackend, GLogger $glogger) {
38
		$this->gDavBackend = $gDavBackend;
39
		$this->logger = $glogger;
40
	}
41
42
	/**
43
	 * Returns a list of calendars for a principal.
44
	 *
45
	 * Every project is an array with the following keys:
46
	 *  * id, a unique id that will be used by other functions to modify the
47
	 *    calendar. This can be the same as the uri or a database key.
48
	 *  * uri. This is just the 'base uri' or 'filename' of the calendar.
49
	 *  * principaluri. The owner of the calendar. Almost always the same as
50
	 *    principalUri passed to this method.
51
	 *
52
	 * Furthermore it can contain webdav properties in clark notation. A very
53
	 * common one is '{DAV:}displayname'.
54
	 *
55
	 * Many clients also require:
56
	 * {urn:ietf:params:xml:ns:caldav}supported-calendar-component-set
57
	 * For this property, you can just return an instance of
58
	 * Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet.
59
	 *
60
	 * If you return {http://sabredav.org/ns}read-only and set the value to 1,
61
	 * ACL will automatically be put in read-only mode.
62
	 *
63
	 * @param string $principalUri
64
	 *
65
	 * @return array
66
	 */
67
	public function getCalendarsForUser($principalUri) {
68
		$this->logger->trace("principalUri: %s", $principalUri);
69
70
		return $this->gDavBackend->GetFolders($principalUri, static::CONTAINER_CLASSES);
71
	}
72
73
	/**
74
	 * Creates a new calendar for a principal.
75
	 *
76
	 * If the creation was a success, an id must be returned that can be used
77
	 * to reference this calendar in other methods, such as updateCalendar.
78
	 *
79
	 * @param string $principalUri
80
	 * @param string $calendarUri
81
	 *
82
	 * @return string
83
	 */
84
	public function createCalendar($principalUri, $calendarUri, array $properties) {
85
		$this->logger->trace("principalUri: %s - calendarUri: %s - properties: %s", $principalUri, $calendarUri, $properties);
86
87
		// TODO Add displayname
88
		return $this->gDavBackend->CreateFolder($principalUri, $calendarUri, static::CONTAINER_CLASS, "");
89
	}
90
91
	/**
92
	 * Delete a calendar and all its objects.
93
	 *
94
	 * @param string $calendarId
95
	 */
96
	public function deleteCalendar($calendarId) {
97
		$this->logger->trace("calendarId: %s", $calendarId);
98
		$success = $this->gDavBackend->DeleteFolder($calendarId);
0 ignored issues
show
The assignment to $success is dead and can be removed.
Loading history...
99
		// TODO evaluate $success
100
	}
101
102
	/**
103
	 * Returns all calendar objects within a calendar.
104
	 *
105
	 * Every item contains an array with the following keys:
106
	 *   * calendardata - The iCalendar-compatible calendar data
107
	 *   * uri - a unique key which will be used to construct the uri. This can
108
	 *     be any arbitrary string, but making sure it ends with '.ics' is a
109
	 *     good idea. This is only the basename, or filename, not the full
110
	 *     path.
111
	 *   * lastmodified - a timestamp of the last modification time
112
	 *   * etag - An arbitrary string, surrounded by double-quotes. (e.g.:
113
	 *   '  "abcdef"')
114
	 *   * size - The size of the calendar objects, in bytes.
115
	 *   * component - optional, a string containing the type of object, such
116
	 *     as 'vevent' or 'vtodo'. If specified, this will be used to populate
117
	 *     the Content-Type header.
118
	 *
119
	 * Note that the etag is optional, but it's highly encouraged to return for
120
	 * speed reasons.
121
	 *
122
	 * The calendardata is also optional. If it's not returned
123
	 * 'getCalendarObject' will be called later, which *is* expected to return
124
	 * calendardata.
125
	 *
126
	 * If neither etag or size are specified, the calendardata will be
127
	 * used/fetched to determine these numbers. If both are specified the
128
	 * amount of times this is needed is reduced by a great degree.
129
	 *
130
	 * @param string $calendarId
131
	 *
132
	 * @return array
133
	 */
134
	public function getCalendarObjects($calendarId) {
135
		$result = $this->gDavBackend->GetObjects($calendarId, static::FILE_EXTENSION, ['types' => static::MESSAGE_CLASSES]);
136
		$this->logger->trace("calendarId: %s found %d objects", $calendarId, count($result));
137
138
		return $result;
139
	}
140
141
	/**
142
	 * Performs a calendar-query on the contents of this calendar.
143
	 *
144
	 * The calendar-query is defined in RFC4791 : CalDAV. Using the
145
	 * calendar-query it is possible for a client to request a specific set of
146
	 * object, based on contents of iCalendar properties, date-ranges and
147
	 * iCalendar component types (VTODO, VEVENT).
148
	 *
149
	 * This method should just return a list of (relative) urls that match this
150
	 * query.
151
	 *
152
	 * The list of filters are specified as an array. The exact array is
153
	 * documented by \Sabre\CalDAV\CalendarQueryParser.
154
	 *
155
	 * Note that it is extremely likely that getCalendarObject for every path
156
	 * returned from this method will be called almost immediately after. You
157
	 * may want to anticipate this to speed up these requests.
158
	 *
159
	 * This method provides a default implementation, which parses *all* the
160
	 * iCalendar objects in the specified calendar.
161
	 *
162
	 * This default may well be good enough for personal use, and calendars
163
	 * that aren't very large. But if you anticipate high usage, big calendars
164
	 * or high loads, you are strongly advised to optimize certain paths.
165
	 *
166
	 * The best way to do so is override this method and to optimize
167
	 * specifically for 'common filters'.
168
	 *
169
	 * Requests that are extremely common are:
170
	 *   * requests for just VEVENTS
171
	 *   * requests for just VTODO
172
	 *   * requests with a time-range-filter on either VEVENT or VTODO.
173
	 *
174
	 * ..and combinations of these requests. It may not be worth it to try to
175
	 * handle every possible situation and just rely on the (relatively
176
	 * easy to use) CalendarQueryValidator to handle the rest.
177
	 *
178
	 * Note that especially time-range-filters may be difficult to parse. A
179
	 * time-range filter specified on a VEVENT must for instance also handle
180
	 * recurrence rules correctly.
181
	 * A good example of how to interpret all these filters can also simply
182
	 * be found in \Sabre\CalDAV\CalendarQueryFilter. This class is as correct
183
	 * as possible, so it gives you a good idea on what type of stuff you need
184
	 * to think of.
185
	 *
186
	 * @param mixed $calendarId
187
	 *
188
	 * @return array
189
	 */
190
	public function calendarQuery($calendarId, array $filters) {
191
		$start = $end = null;
192
		$types = [];
193
		foreach ($filters['comp-filters'] as $filter) {
194
195
			if ($filter['name'] == 'VEVENT') {
196
				$types[] = 'IPM.Appointment';
197
			}
198
			elseif ($filter['name'] == 'VTODO') {
199
				$types[] = 'IPM.Task';
200
			}
201
202
			/* will this work on tasks? */
203
			if (is_array($filter['time-range']) && isset($filter['time-range']['start'], $filter['time-range']['end'])) {
204
				$start = $filter['time-range']['start']->getTimestamp();
205
				$end = $filter['time-range']['end']->getTimestamp();
206
			}
207
		}
208
209
		$objfilters = [];
210
		if ($start != null && $end != null) {
211
			$objfilters["start"] = $start;
212
			$objfilters["end"] = $end;
213
		}
214
		if (!empty($types)) {
215
			$objfilters["types"] = $types;
216
		}
217
218
		$objects = $this->gDavBackend->GetObjects($calendarId, static::FILE_EXTENSION, $objfilters);
219
		$result = [];
220
		foreach ($objects as $object) {
221
			$result[] = $object['uri'];
222
		}
223
224
		return $result;
225
	}
226
227
	/**
228
	 * Returns information from a single calendar object, based on its object uri.
229
	 *
230
	 * The object uri is only the basename, or filename and not a full path.
231
	 *
232
	 * The returned array must have the same keys as getCalendarObjects. The
233
	 * 'calendardata' object is required here though, while it's not required
234
	 * for getCalendarObjects.
235
	 *
236
	 * This method must return null if the object did not exist.
237
	 *
238
	 * @param string   $calendarId
239
	 * @param string   $objectUri
240
	 * @param resource $mapifolder optional mapifolder resource, used if available
241
	 *
242
	 * @return null|array
243
	 */
244
	public function getCalendarObject($calendarId, $objectUri, $mapifolder = null) {
245
		$this->logger->trace("calendarId: %s - objectUri: %s - mapifolder: %s", $calendarId, $objectUri, $mapifolder);
246
247
		if (!$mapifolder) {
248
			$mapifolder = $this->gDavBackend->GetMapiFolder($calendarId);
249
		}
250
251
		$mapimessage = $this->gDavBackend->GetMapiMessageForId($calendarId, $objectUri, $mapifolder, static::FILE_EXTENSION);
252
		if (!$mapimessage) {
253
			$this->logger->info("Object NOT FOUND");
254
255
			return null;
256
		}
257
258
		$realId = $this->gDavBackend->GetIdOfMapiMessage($calendarId, $mapimessage);
259
260
		// this should be cached or moved to gDavBackend
261
		$session = $this->gDavBackend->GetSession();
262
		$ab = $this->gDavBackend->GetAddressBook();
263
264
		$ics = mapi_mapitoical($session, $ab, $mapimessage, []);
265
		if (!$ics && mapi_last_hresult()) {
266
			$this->logger->error("Error generating ical, error code: 0x%08X", mapi_last_hresult());
267
			$ics = null;
268
		}
269
		elseif (!$ics) {
270
			$this->logger->error("Error generating ical, unknown error");
271
			$ics = null;
272
		}
273
274
		$props = mapi_getprops($mapimessage, [PR_LAST_MODIFICATION_TIME]);
275
276
		$r = [
277
			'id' => $realId,
278
			'uri' => $realId . static::FILE_EXTENSION,
279
			'etag' => '"' . $props[PR_LAST_MODIFICATION_TIME] . '"',
280
			'lastmodified' => $props[PR_LAST_MODIFICATION_TIME],
281
			'calendarid' => $calendarId,
282
			'size' => strlen($ics),
283
			'calendardata' => $ics,
284
		];
285
		$this->logger->trace("returned data id: %s - size: %d - etag: %s", $r['id'], $r['size'], $r['etag']);
286
287
		return $r;
288
	}
289
290
	/**
291
	 * Creates a new calendar object.
292
	 *
293
	 * The object uri is only the basename, or filename and not a full path.
294
	 *
295
	 * It is possible return an etag from this function, which will be used in
296
	 * the response to this PUT request. Note that the ETag must be surrounded
297
	 * by double-quotes.
298
	 *
299
	 * However, you should only really return this ETag if you don't mangle the
300
	 * calendar-data. If the result of a subsequent GET to this object is not
301
	 * the exact same as this request body, you should omit the ETag.
302
	 *
303
	 * @param mixed  $calendarId
304
	 * @param string $objectUri
305
	 * @param string $calendarData
306
	 *
307
	 * @return null|string
308
	 */
309
	public function createCalendarObject($calendarId, $objectUri, $calendarData) {
310
		$this->logger->trace("calendarId: %s - objectUri: %s", $calendarId, $objectUri);
311
		$objectId = $this->gDavBackend->GetObjectIdFromObjectUri($objectUri, static::FILE_EXTENSION);
312
		$folder = $this->gDavBackend->GetMapiFolder($calendarId);
313
		$mapimessage = $this->gDavBackend->CreateObject($calendarId, $folder, $objectId);
314
		$retval = $this->setData($calendarId, $mapimessage, $calendarData);
315
		if (!$retval) {
316
			return null;
317
		}
318
319
		return '"' . $retval . '"';
320
	}
321
322
	/**
323
	 * Updates an existing calendarobject, based on its uri.
324
	 *
325
	 * The object uri is only the basename, or filename and not a full path.
326
	 *
327
	 * It is possible return an etag from this function, which will be used in
328
	 * the response to this PUT request. Note that the ETag must be surrounded
329
	 * by double-quotes.
330
	 *
331
	 * However, you should only really return this ETag if you don't mangle the
332
	 * calendar-data. If the result of a subsequent GET to this object is not
333
	 * the exact same as this request body, you should omit the ETag.
334
	 *
335
	 * @param mixed  $calendarId
336
	 * @param string $objectUri
337
	 * @param string $calendarData
338
	 *
339
	 * @return null|string
340
	 */
341
	public function updateCalendarObject($calendarId, $objectUri, $calendarData) {
342
		$this->logger->trace("calendarId: %s - objectUri: %s", $calendarId, $objectUri);
343
344
		$folder = $this->gDavBackend->GetMapiFolder($calendarId);
0 ignored issues
show
The assignment to $folder is dead and can be removed.
Loading history...
345
		$mapimessage = $this->gDavBackend->GetMapiMessageForId($calendarId, $objectUri, null, static::FILE_EXTENSION);
346
		$retval = $this->setData($calendarId, $mapimessage, $calendarData);
347
		if (!$retval) {
348
			return null;
349
		}
350
351
		return '"' . $retval . '"';
352
	}
353
354
	/**
355
	 * Sets data for a calendar item.
356
	 *
357
	 * @param mixed  $calendarId
358
	 * @param mixed  $mapimessage
359
	 * @param string $ics
360
	 *
361
	 * @return null|string
362
	 */
363
	private function setData($calendarId, $mapimessage, $ics) {
364
		// this should be cached or moved to gDavBackend
365
		$store = $this->gDavBackend->GetStoreById($calendarId);
366
		$session = $this->gDavBackend->GetSession();
367
		$ab = $this->gDavBackend->GetAddressBook();
368
369
		// Evolution sends daylight/standard information in the ical data
370
		// and some values are not supported by Outlook/Exchange.
371
		// Strip that data and leave only the last occurrences of
372
		// daylight/standard information.
373
		// @see GRAM-52
374
375
		$xLicLocation = stripos($ics, 'X-LIC-LOCATION:');
376
		if (($xLicLocation !== false) &&
377
				(
378
					substr_count($ics, 'BEGIN:DAYLIGHT', $xLicLocation) > 0 ||
379
					substr_count($ics, 'BEGIN:STANDARD', $xLicLocation) > 0
380
				)) {
381
			$firstDaytime = stripos($ics, 'BEGIN:DAYLIGHT', $xLicLocation);
382
			$firstStandard = stripos($ics, 'BEGIN:STANDARD', $xLicLocation);
383
384
			$lastDaytime = strripos($ics, 'BEGIN:DAYLIGHT', $xLicLocation);
385
			$lastStandard = strripos($ics, 'BEGIN:STANDARD', $xLicLocation);
386
387
			// the first part of ics until the first piece of standard/daytime information
388
			$cutStart = $firstDaytime < $firstStandard ? $firstDaytime : $firstStandard;
389
390
			if ($lastDaytime > $lastStandard) {
391
				// the part of the ics with the last piece of standard/daytime information
392
				$cutEnd = $lastDaytime;
393
394
				// the positions of the last piece of standard information
395
				$cut1 = $lastStandard;
396
				$cut2 = strripos($ics, 'END:STANDARD', $lastStandard) + 14; // strlen('END:STANDARD')
397
			}
398
			else {
399
				// the part of the ics with the last piece of standard/daytime information
400
				$cutEnd = $lastStandard;
401
402
				// the positions of the last piece of daylight information
403
				$cut1 = $lastDaytime;
404
				$cut2 = strripos($ics, 'END:DAYLIGHT', $lastDaytime) + 14; // strlen('END:DAYLIGHT')
405
			}
406
407
			$ics = substr($ics, 0, $cutStart) . substr($ics, $cut1, $cut2 - $cut1) . substr($ics, $cutEnd);
408
			$this->logger->trace("newics: %s", $ics);
409
		}
410
411
		$ok = mapi_icaltomapi($session, $store, $ab, $mapimessage, $ics, false);
412
		if (!$ok && mapi_last_hresult()) {
413
			$this->logger->error("Error updating mapi object, error code: 0x%08X", mapi_last_hresult());
414
415
			return null;
416
		}
417
		if (!$ok) {
418
			$this->logger->error("Error updating mapi object, unknown error");
419
420
			return null;
421
		}
422
423
		$propList = MapiProps::GetAppointmentProperties();
424
		$defaultProps = MapiProps::GetDefaultAppoinmentProperties();
425
		$propsToSet = $this->gDavBackend->GetPropsToSet($calendarId, $mapimessage, $propList, $defaultProps);
426
		if (!empty($propsToSet)) {
427
			mapi_setprops($mapimessage, $propsToSet);
428
		}
429
430
		mapi_savechanges($mapimessage);
431
		$props = mapi_getprops($mapimessage, [PR_LAST_MODIFICATION_TIME]);
432
433
		return $props[PR_LAST_MODIFICATION_TIME];
434
	}
435
436
	/**
437
	 * Deletes an existing calendar object.
438
	 *
439
	 * The object uri is only the basename, or filename and not a full path.
440
	 *
441
	 * @param string $calendarId
442
	 * @param string $objectUri
443
	 */
444
	public function deleteCalendarObject($calendarId, $objectUri) {
445
		$this->logger->trace("calendarId: %s - objectUri: %s", $calendarId, $objectUri);
446
447
		$mapifolder = $this->gDavBackend->GetMapiFolder($calendarId);
448
449
		// to delete we need the PR_ENTRYID of the message
450
		// TODO move this part to GrommunioDavBackend
451
		$mapimessage = $this->gDavBackend->GetMapiMessageForId($calendarId, $objectUri, $mapifolder, static::FILE_EXTENSION);
452
		$props = mapi_getprops($mapimessage, [PR_ENTRYID]);
453
		mapi_folder_deletemessages($mapifolder, [$props[PR_ENTRYID]]);
454
	}
455
456
	/**
457
	 * Return a single scheduling object.
458
	 *
459
	 * TODO: Add implementation.
460
	 *
461
	 * @param string $principalUri
462
	 * @param string $objectUri
463
	 *
464
	 * @return array
465
	 */
466
	public function getSchedulingObject($principalUri, $objectUri) {
467
		$this->logger->trace("principalUri: %s - objectUri: %s", $principalUri, $objectUri);
468
469
		return [];
470
	}
471
472
	/**
473
	 * Returns scheduling objects for the principal URI.
474
	 *
475
	 * TODO: Add implementation.
476
	 *
477
	 * @param string $principalUri
478
	 *
479
	 * @return array
480
	 */
481
	public function getSchedulingObjects($principalUri) {
482
		$this->logger->trace("principalUri: %s", $principalUri);
483
484
		return [];
485
	}
486
487
	/**
488
	 * Delete scheduling object.
489
	 *
490
	 * TODO: Add implementation.
491
	 *
492
	 * @param string $principalUri
493
	 * @param string $objectUri
494
	 */
495
	public function deleteSchedulingObject($principalUri, $objectUri) {
496
		$this->logger->trace("principalUri: %s - objectUri: %s", $principalUri, $objectUri);
497
	}
498
499
	/**
500
	 * Create a new scheduling object.
501
	 *
502
	 * TODO: Add implementation.
503
	 *
504
	 * @param string $principalUri
505
	 * @param string $objectUri
506
	 * @param string $objectData
507
	 */
508
	public function createSchedulingObject($principalUri, $objectUri, $objectData) {
509
		$this->logger->trace("principalUri: %s - objectUri: %s - objectData: %s", $principalUri, $objectUri, $objectData);
510
	}
511
512
	/**
513
	 * Return CTAG for scheduling inbox.
514
	 *
515
	 * TODO: Add implementation.
516
	 *
517
	 * @param string $principalUri
518
	 *
519
	 * @return string
520
	 */
521
	public function getSchedulingInboxCtag($principalUri) {
522
		$this->logger->trace("principalUri: %s", $principalUri);
523
524
		return "empty";
525
	}
526
527
	/**
528
	 * The getChanges method returns all the changes that have happened, since
529
	 * the specified syncToken in the specified calendar.
530
	 *
531
	 * This function should return an array, such as the following:
532
	 *
533
	 * [
534
	 *   'syncToken' => 'The current synctoken',
535
	 *   'added'   => [
536
	 *      'new.txt',
537
	 *   ],
538
	 *   'modified'   => [
539
	 *      'modified.txt',
540
	 *   ],
541
	 *   'deleted' => [
542
	 *      'foo.php.bak',
543
	 *      'old.txt'
544
	 *   ]
545
	 * );
546
	 *
547
	 * The returned syncToken property should reflect the *current* syncToken
548
	 * of the calendar, as reported in the {http://sabredav.org/ns}sync-token
549
	 * property This is * needed here too, to ensure the operation is atomic.
550
	 *
551
	 * If the $syncToken argument is specified as null, this is an initial
552
	 * sync, and all members should be reported.
553
	 *
554
	 * The modified property is an array of nodenames that have changed since
555
	 * the last token.
556
	 *
557
	 * The deleted property is an array with nodenames, that have been deleted
558
	 * from collection.
559
	 *
560
	 * The $syncLevel argument is basically the 'depth' of the report. If it's
561
	 * 1, you only have to report changes that happened only directly in
562
	 * immediate descendants. If it's 2, it should also include changes from
563
	 * the nodes below the child collections. (grandchildren)
564
	 *
565
	 * The $limit argument allows a client to specify how many results should
566
	 * be returned at most. If the limit is not specified, it should be treated
567
	 * as infinite.
568
	 *
569
	 * If the limit (infinite or not) is higher than you're willing to return,
570
	 * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception.
571
	 *
572
	 * If the syncToken is expired (due to data cleanup) or unknown, you must
573
	 * return null.
574
	 *
575
	 * The limit is 'suggestive'. You are free to ignore it.
576
	 *
577
	 * @param string $calendarId
578
	 * @param string $syncToken
579
	 * @param int    $syncLevel
580
	 * @param int    $limit
581
	 *
582
	 * @return array
583
	 */
584
	public function getChangesForCalendar($calendarId, $syncToken, $syncLevel, $limit = null) {
585
		$this->logger->trace("calendarId: %s - syncToken: %s - syncLevel: %d - limit: %d", $calendarId, $syncToken, $syncLevel, $limit);
586
587
		return $this->gDavBackend->Sync($calendarId, $syncToken, static::FILE_EXTENSION, $limit, ['types' => static::MESSAGE_CLASSES]);
588
	}
589
}
590