Passed
Push — master ( 58e4a8...07b9db )
by Blizzz
28:52 queued 14:18
created

IMipService::createInvitationToken()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 24
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 19
c 1
b 0
f 0
nc 2
nop 3
dl 0
loc 24
rs 9.6333
1
<?php
2
declare(strict_types=1);
3
/*
4
 * DAV App
5
 *
6
 * @copyright 2022 Anna Larch <[email protected]>
7
 *
8
 * @author Anna Larch <[email protected]>
9
 *
10
 * This library is free software; you can redistribute it and/or
11
 * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
12
 * License as published by the Free Software Foundation; either
13
 * version 3 of the License, or any later version.
14
 *
15
 * This library is distributed in the hope that it will be useful,
16
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18
 * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
19
 *
20
 * You should have received a copy of the GNU Affero General Public
21
 * License along with this library.  If not, see <http://www.gnu.org/licenses/>.
22
 */
23
24
namespace OCA\DAV\CalDAV\Schedule;
25
26
use OC\URLGenerator;
27
use OCP\IConfig;
28
use OCP\IDBConnection;
29
use OCP\IL10N;
30
use OCP\L10N\IFactory as L10NFactory;
31
use OCP\Mail\IEMailTemplate;
32
use OCP\Security\ISecureRandom;
33
use Sabre\VObject\Component\VCalendar;
34
use Sabre\VObject\Component\VEvent;
35
use Sabre\VObject\DateTimeParser;
36
use Sabre\VObject\ITip\Message;
37
use Sabre\VObject\Parameter;
38
use Sabre\VObject\Property;
39
use Sabre\VObject\Recur\EventIterator;
40
41
class IMipService {
42
43
	private URLGenerator $urlGenerator;
44
	private IConfig $config;
45
	private IDBConnection $db;
46
	private ISecureRandom $random;
47
	private L10NFactory $l10nFactory;
48
	private IL10N $l10n;
49
50
	/** @var string[] */
51
	private const STRING_DIFF = [
52
		'meeting_title' => 'SUMMARY',
53
		'meeting_description' => 'DESCRIPTION',
54
		'meeting_url' => 'URL',
55
		'meeting_location' => 'LOCATION'
56
	];
57
58
	public function __construct(URLGenerator $urlGenerator,
59
								IConfig $config,
60
								IDBConnection $db,
61
								ISecureRandom $random,
62
								L10NFactory $l10nFactory) {
63
		$this->urlGenerator = $urlGenerator;
64
		$this->config = $config;
65
		$this->db = $db;
66
		$this->random = $random;
67
		$this->l10nFactory = $l10nFactory;
68
		$default = $this->l10nFactory->findGenericLanguage();
69
		$this->l10n = $this->l10nFactory->get('dav', $default);
70
	}
71
72
	/**
73
	 * @param string $senderName
74
	 * @param $default
75
	 * @return string
76
	 */
77
	public function getFrom(string $senderName, $default): string {
78
		return $this->l10n->t('%1$s via %2$s', [$senderName, $default]);
79
	}
80
81
	public static function readPropertyWithDefault(VEvent $vevent, string $property, string $default) {
82
		if (isset($vevent->$property)) {
83
			$value = $vevent->$property->getValue();
84
			if (!empty($value)) {
85
				return $value;
86
			}
87
		}
88
		return $default;
89
	}
90
91
	private function generateDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
92
		$strikethrough = "<span style='text-decoration: line-through'>%s</span><br />%s";
93
		if (!isset($vevent->$property)) {
94
			return $default;
95
		}
96
		$newstring = $vevent->$property->getValue();
97
		if(isset($oldVEvent->$property)) {
98
			$oldstring = $oldVEvent->$property->getValue();
99
			return sprintf($strikethrough, $oldstring, $newstring);
100
		}
101
		return $newstring;
102
	}
103
104
	/**
105
	 * @param VEvent $vEvent
106
	 * @param VEvent|null $oldVEvent
107
	 * @return array
108
	 */
109
	public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array {
110
		$defaultVal = '';
111
		$data = [];
112
		$data['meeting_when'] = $this->generateWhenString($vEvent);
113
114
		foreach(self::STRING_DIFF as $key => $property) {
115
			$data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
116
		}
117
118
		$data['meeting_url_html'] = self::readPropertyWithDefault($vEvent, 'URL', $defaultVal);
119
120
		if(!empty($oldVEvent)) {
121
			$oldMeetingWhen = $this->generateWhenString($oldVEvent);
122
			$data['meeting_title_html']	= $this->generateDiffString($vEvent, $oldVEvent, 'SUMMARY', $data['meeting_title']);
123
			$data['meeting_description_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'DESCRIPTION', $data['meeting_description']);
124
			$data['meeting_location_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'LOCATION', $data['meeting_location']);
125
126
			$oldUrl = self::readPropertyWithDefault($oldVEvent, 'URL', $defaultVal);
127
			$data['meeting_url_html'] = !empty($oldUrl) ? sprintf('<a href="%1$s">%1$s</a>', $oldUrl) : $data['meeting_url'];
128
129
			$data['meeting_when_html'] =
130
				($oldMeetingWhen !== $data['meeting_when'] && $oldMeetingWhen !== null)
131
					? sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", $oldMeetingWhen, $data['meeting_when'])
132
					: $data['meeting_when'];
133
		}
134
		return $data;
135
	}
136
137
	/**
138
	 * @param IL10N $this->l10n
139
	 * @param VEvent $vevent
140
	 * @return false|int|string
141
	 */
142
	public function generateWhenString(VEvent $vevent) {
143
		/** @var Property\ICalendar\DateTime $dtstart */
144
		$dtstart = $vevent->DTSTART;
145
		if (isset($vevent->DTEND)) {
146
			/** @var Property\ICalendar\DateTime $dtend */
147
			$dtend = $vevent->DTEND;
148
		} elseif (isset($vevent->DURATION)) {
149
			$isFloating = $dtstart->isFloating();
150
			$dtend = clone $dtstart;
151
			$endDateTime = $dtend->getDateTime();
152
			$endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue()));
0 ignored issues
show
Bug introduced by
The method getValue() does not exist on null. ( Ignorable by Annotation )

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

152
			$endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->/** @scrutinizer ignore-call */ getValue()));

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
153
			$dtend->setDateTime($endDateTime, $isFloating);
154
		} elseif (!$dtstart->hasTime()) {
155
			$isFloating = $dtstart->isFloating();
156
			$dtend = clone $dtstart;
157
			$endDateTime = $dtend->getDateTime();
158
			$endDateTime = $endDateTime->modify('+1 day');
159
			$dtend->setDateTime($endDateTime, $isFloating);
160
		} else {
161
			$dtend = clone $dtstart;
162
		}
163
164
		/** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */
165
		/** @var \DateTimeImmutable $dtstartDt */
166
		$dtstartDt = $dtstart->getDateTime();
167
168
		/** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */
169
		/** @var \DateTimeImmutable $dtendDt */
170
		$dtendDt = $dtend->getDateTime();
171
172
		$diff = $dtstartDt->diff($dtendDt);
173
174
		$dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM));
175
		$dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM));
176
177
		if ($dtstart instanceof Property\ICalendar\Date) {
178
			// One day event
179
			if ($diff->days === 1) {
180
				return $this->l10n->l('date', $dtstartDt, ['width' => 'medium']);
181
			}
182
183
			// DTEND is exclusive, so if the ics data says 2020-01-01 to 2020-01-05,
184
			// the email should show 2020-01-01 to 2020-01-04.
185
			$dtendDt->modify('-1 day');
186
187
			//event that spans over multiple days
188
			$localeStart = $this->l10n->l('date', $dtstartDt, ['width' => 'medium']);
189
			$localeEnd = $this->l10n->l('date', $dtendDt, ['width' => 'medium']);
190
191
			return $localeStart . ' - ' . $localeEnd;
0 ignored issues
show
Bug introduced by
Are you sure $localeEnd of type false|integer|string can be used in concatenation? ( Ignorable by Annotation )

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

191
			return $localeStart . ' - ' . /** @scrutinizer ignore-type */ $localeEnd;
Loading history...
Bug introduced by
Are you sure $localeStart of type false|integer|string can be used in concatenation? ( Ignorable by Annotation )

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

191
			return /** @scrutinizer ignore-type */ $localeStart . ' - ' . $localeEnd;
Loading history...
192
		}
193
194
		/** @var Property\ICalendar\DateTime $dtstart */
195
		/** @var Property\ICalendar\DateTime $dtend */
196
		$isFloating = $dtstart->isFloating();
197
		$startTimezone = $endTimezone = null;
198
		if (!$isFloating) {
199
			$prop = $dtstart->offsetGet('TZID');
200
			if ($prop instanceof Parameter) {
201
				$startTimezone = $prop->getValue();
202
			}
203
204
			$prop = $dtend->offsetGet('TZID');
205
			if ($prop instanceof Parameter) {
206
				$endTimezone = $prop->getValue();
207
			}
208
		}
209
210
		$localeStart = $this->l10n->l('weekdayName', $dtstartDt, ['width' => 'abbreviated']) . ', ' .
0 ignored issues
show
Bug introduced by
Are you sure $this->l10n->l('weekdayN...dth' => 'abbreviated')) of type false|integer|string can be used in concatenation? ( Ignorable by Annotation )

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

210
		$localeStart = /** @scrutinizer ignore-type */ $this->l10n->l('weekdayName', $dtstartDt, ['width' => 'abbreviated']) . ', ' .
Loading history...
211
			$this->l10n->l('datetime', $dtstartDt, ['width' => 'medium|short']);
0 ignored issues
show
Bug introduced by
Are you sure $this->l10n->l('datetime...th' => 'medium|short')) of type false|integer|string can be used in concatenation? ( Ignorable by Annotation )

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

211
			/** @scrutinizer ignore-type */ $this->l10n->l('datetime', $dtstartDt, ['width' => 'medium|short']);
Loading history...
212
213
		// always show full date with timezone if timezones are different
214
		if ($startTimezone !== $endTimezone) {
215
			$localeEnd = $this->l10n->l('datetime', $dtendDt, ['width' => 'medium|short']);
216
217
			return $localeStart . ' (' . $startTimezone . ') - ' .
218
				$localeEnd . ' (' . $endTimezone . ')';
219
		}
220
221
		// show only end time if date is the same
222
		if ($dtstartDt->format('Y-m-d') === $dtendDt->format('Y-m-d')) {
223
			$localeEnd = $this->l10n->l('time', $dtendDt, ['width' => 'short']);
224
		} else {
225
			$localeEnd = $this->l10n->l('weekdayName', $dtendDt, ['width' => 'abbreviated']) . ', ' .
0 ignored issues
show
Bug introduced by
Are you sure $this->l10n->l('weekdayN...dth' => 'abbreviated')) of type false|integer|string can be used in concatenation? ( Ignorable by Annotation )

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

225
			$localeEnd = /** @scrutinizer ignore-type */ $this->l10n->l('weekdayName', $dtendDt, ['width' => 'abbreviated']) . ', ' .
Loading history...
226
				$this->l10n->l('datetime', $dtendDt, ['width' => 'medium|short']);
0 ignored issues
show
Bug introduced by
Are you sure $this->l10n->l('datetime...th' => 'medium|short')) of type false|integer|string can be used in concatenation? ( Ignorable by Annotation )

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

226
				/** @scrutinizer ignore-type */ $this->l10n->l('datetime', $dtendDt, ['width' => 'medium|short']);
Loading history...
227
		}
228
229
		return $localeStart . ' - ' . $localeEnd . ' (' . $startTimezone . ')';
230
	}
231
232
	/**
233
	 * @param VEvent $vEvent
234
	 * @return array
235
	 */
236
	public function buildCancelledBodyData(VEvent $vEvent): array {
237
		$defaultVal = '';
238
		$strikethrough = "<span style='text-decoration: line-through'>%s</span>";
239
240
		$newMeetingWhen = $this->generateWhenString($vEvent);
241
		$newSummary = isset($vEvent->SUMMARY) && (string)$vEvent->SUMMARY !== '' ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event');;
242
		$newDescription = isset($vEvent->DESCRIPTION) && (string)$vEvent->DESCRIPTION !== '' ? (string)$vEvent->DESCRIPTION : $defaultVal;
243
		$newUrl = isset($vEvent->URL) && (string)$vEvent->URL !== '' ? sprintf('<a href="%1$s">%1$s</a>', $vEvent->URL) : $defaultVal;
244
		$newLocation = isset($vEvent->LOCATION) && (string)$vEvent->LOCATION !== '' ? (string)$vEvent->LOCATION : $defaultVal;
245
246
		$data = [];
247
		$data['meeting_when_html'] = $newMeetingWhen === '' ?: sprintf($strikethrough, $newMeetingWhen);
248
		$data['meeting_when'] = $newMeetingWhen;
249
		$data['meeting_title_html'] = sprintf($strikethrough, $newSummary);
250
		$data['meeting_title'] = $newSummary !== '' ? $newSummary: $this->l10n->t('Untitled event');
251
		$data['meeting_description_html'] = $newDescription !== '' ? sprintf($strikethrough, $newDescription) : '';
252
		$data['meeting_description'] = $newDescription;
253
		$data['meeting_url_html'] = $newUrl !== '' ? sprintf($strikethrough, $newUrl) : '';
254
		$data['meeting_url'] = isset($vEvent->URL) ? (string)$vEvent->URL : '';
255
		$data['meeting_location_html'] = $newLocation !== '' ? sprintf($strikethrough, $newLocation) : '';
256
		$data['meeting_location'] = $newLocation;
257
		return $data;
258
	}
259
260
	/**
261
	 * Check if event took place in the past
262
	 *
263
	 * @param VCalendar $vObject
264
	 * @return int
265
	 */
266
	public function getLastOccurrence(VCalendar $vObject) {
267
		/** @var VEvent $component */
268
		$component = $vObject->VEVENT;
269
270
		if (isset($component->RRULE)) {
271
			$it = new EventIterator($vObject, (string)$component->UID);
272
			$maxDate = new \DateTime(IMipPlugin::MAX_DATE);
273
			if ($it->isInfinite()) {
274
				return $maxDate->getTimestamp();
275
			}
276
277
			$end = $it->getDtEnd();
278
			while ($it->valid() && $end < $maxDate) {
279
				$end = $it->getDtEnd();
280
				$it->next();
281
			}
282
			return $end->getTimestamp();
283
		}
284
285
		/** @var Property\ICalendar\DateTime $dtStart */
286
		$dtStart = $component->DTSTART;
287
288
		if (isset($component->DTEND)) {
289
			/** @var Property\ICalendar\DateTime $dtEnd */
290
			$dtEnd = $component->DTEND;
291
			return $dtEnd->getDateTime()->getTimeStamp();
292
		}
293
294
		if(isset($component->DURATION)) {
295
			/** @var \DateTime $endDate */
296
			$endDate = clone $dtStart->getDateTime();
297
			// $component->DTEND->getDateTime() returns DateTimeImmutable
298
			$endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue()));
0 ignored issues
show
Bug introduced by
The method getValue() does not exist on null. ( Ignorable by Annotation )

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

298
			$endDate = $endDate->add(DateTimeParser::parse($component->DURATION->/** @scrutinizer ignore-call */ getValue()));

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
299
			return $endDate->getTimestamp();
300
		}
301
302
		if(!$dtStart->hasTime()) {
303
			/** @var \DateTime $endDate */
304
			// $component->DTSTART->getDateTime() returns DateTimeImmutable
305
			$endDate = clone $dtStart->getDateTime();
306
			$endDate = $endDate->modify('+1 day');
307
			return $endDate->getTimestamp();
308
		}
309
310
		// No computation of end time possible - return start date
311
		return $dtStart->getDateTime()->getTimeStamp();
312
	}
313
314
	/**
315
	 * @param Property|null $attendee
316
	 */
317
	public function setL10n(?Property $attendee = null) {
318
		if($attendee === null) {
319
			return;
320
		}
321
322
		$lang = $attendee->offsetGet('LANGUAGE');
323
		if ($lang instanceof Parameter) {
324
			$lang = $lang->getValue();
325
			$this->l10n = $this->l10nFactory->get('dav', $lang);
326
		}
327
	}
328
329
	/**
330
	 * @param Property|null $attendee
331
	 * @return bool
332
	 */
333
	public function getAttendeeRsvpOrReqForParticipant(?Property $attendee = null) {
334
		if($attendee === null) {
335
			return false;
336
		}
337
338
		$rsvp = $attendee->offsetGet('RSVP');
339
		if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) {
340
			return true;
341
		}
342
		$role = $attendee->offsetGet('ROLE');
343
		// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16
344
		// Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set
345
		if ($role === null
346
			|| (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0))
347
			|| (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0))
348
		) {
349
			return true;
350
		}
351
352
		// RFC 5545 3.2.17: default RSVP is false
353
		return false;
354
	}
355
356
	/**
357
	 * @param IEMailTemplate $template
358
	 * @param string $method
359
	 * @param string $sender
360
	 * @param string $summary
361
	 * @param string|null $partstat
362
	 */
363
	public function addSubjectAndHeading(IEMailTemplate $template,
364
		string $method, string $sender, string $summary): void {
365
		if ($method === IMipPlugin::METHOD_CANCEL) {
366
			// TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}"
367
			$template->setSubject($this->l10n->t('Cancelled: %1$s', [$summary]));
368
			$template->addHeading($this->l10n->t('"%1$s" has been canceled', [$summary]));
369
		} elseif ($method === IMipPlugin::METHOD_REPLY) {
370
			// TRANSLATORS Subject for email, when an invitation is replied to. Ex: "Re: {{Event Name}}"
371
			$template->setSubject($this->l10n->t('Re: %1$s', [$summary]));
372
			$template->addHeading($this->l10n->t('%1$s has responded your invitation', [$sender]));
373
		} else {
374
			// TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}"
375
			$template->setSubject($this->l10n->t('Invitation: %1$s', [$summary]));
376
			$template->addHeading($this->l10n->t('%1$s would like to invite you to "%2$s"', [$sender, $summary]));
377
		}
378
	}
379
380
	/**
381
	 * @param string $path
382
	 * @return string
383
	 */
384
	public function getAbsoluteImagePath($path): string {
385
		return $this->urlGenerator->getAbsoluteURL(
386
			$this->urlGenerator->imagePath('core', $path)
387
		);
388
	}
389
390
	/**
391
	 * addAttendees: add organizer and attendee names/emails to iMip mail.
392
	 *
393
	 * Enable with DAV setting: invitation_list_attendees (default: no)
394
	 *
395
	 * The default is 'no', which matches old behavior, and is privacy preserving.
396
	 *
397
	 * To enable including attendees in invitation emails:
398
	 *   % php occ config:app:set dav invitation_list_attendees --value yes
399
	 *
400
	 * @param IEMailTemplate $template
401
	 * @param IL10N $this->l10n
402
	 * @param VEvent $vevent
403
	 * @author brad2014 on github.com
404
	 */
405
	public function addAttendees(IEMailTemplate $template, VEvent $vevent) {
406
		if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') {
407
			return;
408
		}
409
410
		if (isset($vevent->ORGANIZER)) {
411
			/** @var Property | Property\ICalendar\CalAddress $organizer */
412
			$organizer = $vevent->ORGANIZER;
413
			$organizerEmail = substr($organizer->getNormalizedValue(), 7);
0 ignored issues
show
Bug introduced by
The method getNormalizedValue() does not exist on Sabre\VObject\Property. It seems like you code against a sub-type of Sabre\VObject\Property such as Sabre\VObject\Property\ICalendar\CalAddress. ( Ignorable by Annotation )

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

413
			$organizerEmail = substr($organizer->/** @scrutinizer ignore-call */ getNormalizedValue(), 7);
Loading history...
414
			/** @var string|null $organizerName */
415
			$organizerName = isset($organizer->CN) ? $organizer->CN->getValue() : null;
416
			$organizerHTML = sprintf('<a href="%s">%s</a>',
417
				htmlspecialchars($organizer->getNormalizedValue()),
418
				htmlspecialchars($organizerName ?: $organizerEmail));
419
			$organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail);
420
			if(isset($organizer['PARTSTAT']) ) {
421
				/** @var Parameter $partstat */
422
				$partstat = $organizer['PARTSTAT'];
423
				if(strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) {
424
					$organizerHTML .= ' ✔︎';
425
					$organizerText .= ' ✔︎';
426
				}
427
			}
428
			$template->addBodyListItem($organizerHTML, $this->l10n->t('Organizer:'),
429
				$this->getAbsoluteImagePath('caldav/organizer.png'),
430
				$organizerText, '', IMipPlugin::IMIP_INDENT);
431
		}
432
433
		$attendees = $vevent->select('ATTENDEE');
434
		if (count($attendees) === 0) {
435
			return;
436
		}
437
438
		$attendeesHTML = [];
439
		$attendeesText = [];
440
		foreach ($attendees as $attendee) {
441
			$attendeeEmail = substr($attendee->getNormalizedValue(), 7);
442
			$attendeeName = isset($attendee['CN']) ? $attendee['CN']->getValue() : null;
443
			$attendeeHTML = sprintf('<a href="%s">%s</a>',
444
				htmlspecialchars($attendee->getNormalizedValue()),
445
				htmlspecialchars($attendeeName ?: $attendeeEmail));
446
			$attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail);
447
			if (isset($attendee['PARTSTAT'])) {
448
				/** @var Parameter $partstat */
449
				$partstat = $attendee['PARTSTAT'];
450
				if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) {
451
					$attendeeHTML .= ' ✔︎';
452
					$attendeeText .= ' ✔︎';
453
				}
454
			}
455
			$attendeesHTML[] = $attendeeHTML;
456
			$attendeesText[] = $attendeeText;
457
		}
458
459
		$template->addBodyListItem(implode('<br/>', $attendeesHTML), $this->l10n->t('Attendees:'),
460
			$this->getAbsoluteImagePath('caldav/attendees.png'),
461
			implode("\n", $attendeesText), '', IMipPlugin::IMIP_INDENT);
462
	}
463
464
	/**
465
	 * @param IEMailTemplate $template
466
	 * @param VEVENT $vevent
467
	 * @param $data
468
	 */
469
	public function addBulletList(IEMailTemplate $template, VEvent $vevent, $data) {
470
		$template->addBodyListItem(
471
			$data['meeting_title'], $this->l10n->t('Title:'),
472
			$this->getAbsoluteImagePath('caldav/title.png'), $data['meeting_title'], '', IMipPlugin::IMIP_INDENT);
473
		if ($data['meeting_when'] !== '') {
474
			$template->addBodyListItem($data['meeting_when_html'] ?? $data['meeting_when'], $this->l10n->t('Time:'),
475
				$this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_when'], '', IMipPlugin::IMIP_INDENT);
476
		}
477
		if ($data['meeting_location'] !== '') {
478
			$template->addBodyListItem($data['meeting_location_html'] ?? $data['meeting_location'], $this->l10n->t('Location:'),
479
				$this->getAbsoluteImagePath('caldav/location.png'), $data['meeting_location'], '', IMipPlugin::IMIP_INDENT);
480
		}
481
		if ($data['meeting_url'] !== '') {
482
			$template->addBodyListItem($data['meeting_url_html'] ?? $data['meeting_url'], $this->l10n->t('Link:'),
483
				$this->getAbsoluteImagePath('caldav/link.png'), $data['meeting_url'], '', IMipPlugin::IMIP_INDENT);
484
		}
485
486
		$this->addAttendees($template, $vevent);
487
488
		/* Put description last, like an email body, since it can be arbitrarily long */
489
		if ($data['meeting_description']) {
490
			$template->addBodyListItem($data['meeting_description_html'] ?? $data['meeting_description'], $this->l10n->t('Description:'),
491
				$this->getAbsoluteImagePath('caldav/description.png'), $data['meeting_description'], '', IMipPlugin::IMIP_INDENT);
492
		}
493
	}
494
495
	/**
496
	 * @param Message $iTipMessage
497
	 * @return null|Property
498
	 */
499
	public function getCurrentAttendee(Message $iTipMessage): ?Property {
500
		/** @var VEvent $vevent */
501
		$vevent = $iTipMessage->message->VEVENT;
502
		$attendees = $vevent->select('ATTENDEE');
503
		foreach ($attendees as $attendee) {
504
			/** @var Property $attendee */
505
			if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) {
506
				return $attendee;
507
			}
508
		}
509
		return null;
510
	}
511
512
	/**
513
	 * @param Message $iTipMessage
514
	 * @param VEvent $vevent
515
	 * @param int $lastOccurrence
516
	 * @return string
517
	 */
518
	public function createInvitationToken(Message $iTipMessage, VEvent $vevent, int $lastOccurrence): string {
519
		$token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC);
520
521
		$attendee = $iTipMessage->recipient;
522
		$organizer = $iTipMessage->sender;
523
		$sequence = $iTipMessage->sequence;
524
		$recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ?
525
			$vevent->{'RECURRENCE-ID'}->serialize() : null;
526
		$uid = $vevent->{'UID'};
527
528
		$query = $this->db->getQueryBuilder();
529
		$query->insert('calendar_invitations')
530
			->values([
531
				'token' => $query->createNamedParameter($token),
532
				'attendee' => $query->createNamedParameter($attendee),
533
				'organizer' => $query->createNamedParameter($organizer),
534
				'sequence' => $query->createNamedParameter($sequence),
535
				'recurrenceid' => $query->createNamedParameter($recurrenceId),
536
				'expiration' => $query->createNamedParameter($lastOccurrence),
537
				'uid' => $query->createNamedParameter($uid)
538
			])
539
			->execute();
540
541
		return $token;
542
	}
543
544
	/**
545
	 * Create a valid VCalendar object out of the details of
546
	 * a VEvent and its associated iTip Message
547
	 *
548
	 * We do this to filter out all unchanged VEvents
549
	 * This is especially important in iTip Messages with recurrences
550
	 * and recurrence exceptions
551
	 *
552
	 * @param Message $iTipMessage
553
	 * @param VEvent $vEvent
554
	 * @return VCalendar
555
	 */
556
	public function generateVCalendar(Message $iTipMessage, VEvent $vEvent): VCalendar {
557
		$vCalendar = new VCalendar();
558
		$vCalendar->add('METHOD', $iTipMessage->method);
559
		foreach ($iTipMessage->message->getComponents() as $component) {
560
			if ($component instanceof VEvent) {
561
				continue;
562
			}
563
			$vCalendar->add(clone $component);
564
		}
565
		$vCalendar->add($vEvent);
566
		return $vCalendar;
567
	}
568
569
	/**
570
	 * @param IEMailTemplate $template
571
	 * @param $token
572
	 */
573
	public function addResponseButtons(IEMailTemplate $template, $token) {
574
		$template->addBodyButtonGroup(
575
			$this->l10n->t('Accept'),
576
			$this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [
577
				'token' => $token,
578
			]),
579
			$this->l10n->t('Decline'),
580
			$this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [
581
				'token' => $token,
582
			])
583
		);
584
	}
585
586
	public function addMoreOptionsButton(IEMailTemplate $template, $token) {
587
		$moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [
588
			'token' => $token,
589
		]);
590
		$html = vsprintf('<small><a href="%s">%s</a></small>', [
591
			$moreOptionsURL, $this->l10n->t('More options …')
592
		]);
593
		$text = $this->l10n->t('More options at %s', [$moreOptionsURL]);
594
595
		$template->addBodyText($html, $text);
596
	}
597
}
598