@@ -51,413 +51,413 @@ |
||
51 | 51 | * @package OCA\DAV\CalDAV\Reminder\NotificationProvider |
52 | 52 | */ |
53 | 53 | class EmailProvider extends AbstractProvider { |
54 | - /** @var string */ |
|
55 | - public const NOTIFICATION_TYPE = 'EMAIL'; |
|
56 | - |
|
57 | - private IMailer $mailer; |
|
58 | - |
|
59 | - public function __construct(IConfig $config, |
|
60 | - IMailer $mailer, |
|
61 | - LoggerInterface $logger, |
|
62 | - L10NFactory $l10nFactory, |
|
63 | - IURLGenerator $urlGenerator) { |
|
64 | - parent::__construct($logger, $l10nFactory, $urlGenerator, $config); |
|
65 | - $this->mailer = $mailer; |
|
66 | - } |
|
67 | - |
|
68 | - /** |
|
69 | - * Send out notification via email |
|
70 | - * |
|
71 | - * @param VEvent $vevent |
|
72 | - * @param string $calendarDisplayName |
|
73 | - * @param string[] $principalEmailAddresses |
|
74 | - * @param array $users |
|
75 | - * @throws \Exception |
|
76 | - */ |
|
77 | - public function send(VEvent $vevent, |
|
78 | - string $calendarDisplayName, |
|
79 | - array $principalEmailAddresses, |
|
80 | - array $users = []):void { |
|
81 | - $fallbackLanguage = $this->getFallbackLanguage(); |
|
82 | - |
|
83 | - $organizerEmailAddress = null; |
|
84 | - if (isset($vevent->ORGANIZER)) { |
|
85 | - $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER); |
|
86 | - } |
|
87 | - |
|
88 | - $emailAddressesOfSharees = $this->getEMailAddressesOfAllUsersWithWriteAccessToCalendar($users); |
|
89 | - $emailAddressesOfAttendees = []; |
|
90 | - if (count($principalEmailAddresses) === 0 |
|
91 | - || ($organizerEmailAddress && in_array($organizerEmailAddress, $principalEmailAddresses, true)) |
|
92 | - ) { |
|
93 | - $emailAddressesOfAttendees = $this->getAllEMailAddressesFromEvent($vevent); |
|
94 | - } |
|
95 | - |
|
96 | - // Quote from php.net: |
|
97 | - // If the input arrays have the same string keys, then the later value for that key will overwrite the previous one. |
|
98 | - // => if there are duplicate email addresses, it will always take the system value |
|
99 | - $emailAddresses = array_merge( |
|
100 | - $emailAddressesOfAttendees, |
|
101 | - $emailAddressesOfSharees |
|
102 | - ); |
|
103 | - |
|
104 | - $sortedByLanguage = $this->sortEMailAddressesByLanguage($emailAddresses, $fallbackLanguage); |
|
105 | - $organizer = $this->getOrganizerEMailAndNameFromEvent($vevent); |
|
106 | - |
|
107 | - foreach ($sortedByLanguage as $lang => $emailAddresses) { |
|
108 | - if (!$this->hasL10NForLang($lang)) { |
|
109 | - $lang = $fallbackLanguage; |
|
110 | - } |
|
111 | - $l10n = $this->getL10NForLang($lang); |
|
112 | - $fromEMail = \OCP\Util::getDefaultEmailAddress('reminders-noreply'); |
|
113 | - |
|
114 | - $template = $this->mailer->createEMailTemplate('dav.calendarReminder'); |
|
115 | - $template->addHeader(); |
|
116 | - $this->addSubjectAndHeading($template, $l10n, $vevent); |
|
117 | - $this->addBulletList($template, $l10n, $calendarDisplayName, $vevent); |
|
118 | - $template->addFooter(); |
|
119 | - |
|
120 | - foreach ($emailAddresses as $emailAddress) { |
|
121 | - if (!$this->mailer->validateMailAddress($emailAddress)) { |
|
122 | - $this->logger->error('Email address {address} for reminder notification is incorrect', ['app' => 'dav', 'address' => $emailAddress]); |
|
123 | - continue; |
|
124 | - } |
|
125 | - |
|
126 | - $message = $this->mailer->createMessage(); |
|
127 | - $message->setFrom([$fromEMail]); |
|
128 | - if ($organizer) { |
|
129 | - $message->setReplyTo($organizer); |
|
130 | - } |
|
131 | - $message->setTo([$emailAddress]); |
|
132 | - $message->useTemplate($template); |
|
133 | - |
|
134 | - try { |
|
135 | - $failed = $this->mailer->send($message); |
|
136 | - if ($failed) { |
|
137 | - $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]); |
|
138 | - } |
|
139 | - } catch (\Exception $ex) { |
|
140 | - $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]); |
|
141 | - } |
|
142 | - } |
|
143 | - } |
|
144 | - } |
|
145 | - |
|
146 | - /** |
|
147 | - * @param IEMailTemplate $template |
|
148 | - * @param IL10N $l10n |
|
149 | - * @param VEvent $vevent |
|
150 | - */ |
|
151 | - private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, VEvent $vevent):void { |
|
152 | - $template->setSubject('Notification: ' . $this->getTitleFromVEvent($vevent, $l10n)); |
|
153 | - $template->addHeading($this->getTitleFromVEvent($vevent, $l10n)); |
|
154 | - } |
|
155 | - |
|
156 | - /** |
|
157 | - * @param IEMailTemplate $template |
|
158 | - * @param IL10N $l10n |
|
159 | - * @param string $calendarDisplayName |
|
160 | - * @param array $eventData |
|
161 | - */ |
|
162 | - private function addBulletList(IEMailTemplate $template, |
|
163 | - IL10N $l10n, |
|
164 | - string $calendarDisplayName, |
|
165 | - VEvent $vevent):void { |
|
166 | - $template->addBodyListItem($calendarDisplayName, $l10n->t('Calendar:'), |
|
167 | - $this->getAbsoluteImagePath('actions/info.png')); |
|
168 | - |
|
169 | - $template->addBodyListItem($this->generateDateString($l10n, $vevent), $l10n->t('Date:'), |
|
170 | - $this->getAbsoluteImagePath('places/calendar.png')); |
|
171 | - |
|
172 | - if (isset($vevent->LOCATION)) { |
|
173 | - $template->addBodyListItem((string) $vevent->LOCATION, $l10n->t('Where:'), |
|
174 | - $this->getAbsoluteImagePath('actions/address.png')); |
|
175 | - } |
|
176 | - if (isset($vevent->DESCRIPTION)) { |
|
177 | - $template->addBodyListItem((string) $vevent->DESCRIPTION, $l10n->t('Description:'), |
|
178 | - $this->getAbsoluteImagePath('actions/more.png')); |
|
179 | - } |
|
180 | - } |
|
181 | - |
|
182 | - private function getAbsoluteImagePath(string $path):string { |
|
183 | - return $this->urlGenerator->getAbsoluteURL( |
|
184 | - $this->urlGenerator->imagePath('core', $path) |
|
185 | - ); |
|
186 | - } |
|
187 | - |
|
188 | - /** |
|
189 | - * @param VEvent $vevent |
|
190 | - * @return array|null |
|
191 | - */ |
|
192 | - private function getOrganizerEMailAndNameFromEvent(VEvent $vevent):?array { |
|
193 | - if (!$vevent->ORGANIZER) { |
|
194 | - return null; |
|
195 | - } |
|
196 | - |
|
197 | - $organizer = $vevent->ORGANIZER; |
|
198 | - if (strcasecmp($organizer->getValue(), 'mailto:') !== 0) { |
|
199 | - return null; |
|
200 | - } |
|
201 | - |
|
202 | - $organizerEMail = substr($organizer->getValue(), 7); |
|
203 | - |
|
204 | - if (!$this->mailer->validateMailAddress($organizerEMail)) { |
|
205 | - return null; |
|
206 | - } |
|
207 | - |
|
208 | - $name = $organizer->offsetGet('CN'); |
|
209 | - if ($name instanceof Parameter) { |
|
210 | - return [$organizerEMail => $name]; |
|
211 | - } |
|
212 | - |
|
213 | - return [$organizerEMail]; |
|
214 | - } |
|
215 | - |
|
216 | - /** |
|
217 | - * @param array<string, array{LANG?: string}> $emails |
|
218 | - * @return array<string, string[]> |
|
219 | - */ |
|
220 | - private function sortEMailAddressesByLanguage(array $emails, |
|
221 | - string $defaultLanguage):array { |
|
222 | - $sortedByLanguage = []; |
|
223 | - |
|
224 | - foreach ($emails as $emailAddress => $parameters) { |
|
225 | - if (isset($parameters['LANG'])) { |
|
226 | - $lang = $parameters['LANG']; |
|
227 | - } else { |
|
228 | - $lang = $defaultLanguage; |
|
229 | - } |
|
230 | - |
|
231 | - if (!isset($sortedByLanguage[$lang])) { |
|
232 | - $sortedByLanguage[$lang] = []; |
|
233 | - } |
|
234 | - |
|
235 | - $sortedByLanguage[$lang][] = $emailAddress; |
|
236 | - } |
|
237 | - |
|
238 | - return $sortedByLanguage; |
|
239 | - } |
|
240 | - |
|
241 | - /** |
|
242 | - * @param VEvent $vevent |
|
243 | - * @return array<string, array{LANG?: string}> |
|
244 | - */ |
|
245 | - private function getAllEMailAddressesFromEvent(VEvent $vevent):array { |
|
246 | - $emailAddresses = []; |
|
247 | - |
|
248 | - if (isset($vevent->ATTENDEE)) { |
|
249 | - foreach ($vevent->ATTENDEE as $attendee) { |
|
250 | - if (!($attendee instanceof VObject\Property)) { |
|
251 | - continue; |
|
252 | - } |
|
253 | - |
|
254 | - $cuType = $this->getCUTypeOfAttendee($attendee); |
|
255 | - if (\in_array($cuType, ['RESOURCE', 'ROOM', 'UNKNOWN'])) { |
|
256 | - // Don't send emails to things |
|
257 | - continue; |
|
258 | - } |
|
259 | - |
|
260 | - $partstat = $this->getPartstatOfAttendee($attendee); |
|
261 | - if ($partstat === 'DECLINED') { |
|
262 | - // Don't send out emails to people who declined |
|
263 | - continue; |
|
264 | - } |
|
265 | - if ($partstat === 'DELEGATED') { |
|
266 | - $delegates = $attendee->offsetGet('DELEGATED-TO'); |
|
267 | - if (!($delegates instanceof VObject\Parameter)) { |
|
268 | - continue; |
|
269 | - } |
|
270 | - |
|
271 | - $emailAddressesOfDelegates = $delegates->getParts(); |
|
272 | - foreach ($emailAddressesOfDelegates as $addressesOfDelegate) { |
|
273 | - if (strcasecmp($addressesOfDelegate, 'mailto:') === 0) { |
|
274 | - $delegateEmail = substr($addressesOfDelegate, 7); |
|
275 | - if ($this->mailer->validateMailAddress($delegateEmail)) { |
|
276 | - $emailAddresses[$delegateEmail] = []; |
|
277 | - } |
|
278 | - } |
|
279 | - } |
|
280 | - |
|
281 | - continue; |
|
282 | - } |
|
283 | - |
|
284 | - $emailAddressOfAttendee = $this->getEMailAddressOfAttendee($attendee); |
|
285 | - if ($emailAddressOfAttendee !== null) { |
|
286 | - $properties = []; |
|
287 | - |
|
288 | - $langProp = $attendee->offsetGet('LANG'); |
|
289 | - if ($langProp instanceof VObject\Parameter && $langProp->getValue() !== null) { |
|
290 | - $properties['LANG'] = $langProp->getValue(); |
|
291 | - } |
|
292 | - |
|
293 | - $emailAddresses[$emailAddressOfAttendee] = $properties; |
|
294 | - } |
|
295 | - } |
|
296 | - } |
|
297 | - |
|
298 | - if (isset($vevent->ORGANIZER) && $this->hasAttendeeMailURI($vevent->ORGANIZER)) { |
|
299 | - $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER); |
|
300 | - if ($organizerEmailAddress !== null) { |
|
301 | - $emailAddresses[$organizerEmailAddress] = []; |
|
302 | - } |
|
303 | - } |
|
304 | - |
|
305 | - return $emailAddresses; |
|
306 | - } |
|
307 | - |
|
308 | - private function getCUTypeOfAttendee(VObject\Property $attendee):string { |
|
309 | - $cuType = $attendee->offsetGet('CUTYPE'); |
|
310 | - if ($cuType instanceof VObject\Parameter) { |
|
311 | - return strtoupper($cuType->getValue()); |
|
312 | - } |
|
313 | - |
|
314 | - return 'INDIVIDUAL'; |
|
315 | - } |
|
316 | - |
|
317 | - private function getPartstatOfAttendee(VObject\Property $attendee):string { |
|
318 | - $partstat = $attendee->offsetGet('PARTSTAT'); |
|
319 | - if ($partstat instanceof VObject\Parameter) { |
|
320 | - return strtoupper($partstat->getValue()); |
|
321 | - } |
|
322 | - |
|
323 | - return 'NEEDS-ACTION'; |
|
324 | - } |
|
325 | - |
|
326 | - private function hasAttendeeMailURI(VObject\Property $attendee): bool { |
|
327 | - return stripos($attendee->getValue(), 'mailto:') === 0; |
|
328 | - } |
|
329 | - |
|
330 | - private function getEMailAddressOfAttendee(VObject\Property $attendee): ?string { |
|
331 | - if (!$this->hasAttendeeMailURI($attendee)) { |
|
332 | - return null; |
|
333 | - } |
|
334 | - $attendeeEMail = substr($attendee->getValue(), 7); |
|
335 | - if (!$this->mailer->validateMailAddress($attendeeEMail)) { |
|
336 | - return null; |
|
337 | - } |
|
338 | - |
|
339 | - return $attendeeEMail; |
|
340 | - } |
|
341 | - |
|
342 | - /** |
|
343 | - * @param IUser[] $users |
|
344 | - * @return array<string, array{LANG?: string}> |
|
345 | - */ |
|
346 | - private function getEMailAddressesOfAllUsersWithWriteAccessToCalendar(array $users):array { |
|
347 | - $emailAddresses = []; |
|
348 | - |
|
349 | - foreach ($users as $user) { |
|
350 | - $emailAddress = $user->getEMailAddress(); |
|
351 | - if ($emailAddress) { |
|
352 | - $lang = $this->l10nFactory->getUserLanguage($user); |
|
353 | - if ($lang) { |
|
354 | - $emailAddresses[$emailAddress] = [ |
|
355 | - 'LANG' => $lang, |
|
356 | - ]; |
|
357 | - } else { |
|
358 | - $emailAddresses[$emailAddress] = []; |
|
359 | - } |
|
360 | - } |
|
361 | - } |
|
362 | - |
|
363 | - return $emailAddresses; |
|
364 | - } |
|
365 | - |
|
366 | - /** |
|
367 | - * @throws \Exception |
|
368 | - */ |
|
369 | - private function generateDateString(IL10N $l10n, VEvent $vevent): string { |
|
370 | - $isAllDay = $vevent->DTSTART instanceof Property\ICalendar\Date; |
|
371 | - |
|
372 | - /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */ |
|
373 | - /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */ |
|
374 | - /** @var \DateTimeImmutable $dtstartDt */ |
|
375 | - $dtstartDt = $vevent->DTSTART->getDateTime(); |
|
376 | - /** @var \DateTimeImmutable $dtendDt */ |
|
377 | - $dtendDt = $this->getDTEndFromEvent($vevent)->getDateTime(); |
|
378 | - |
|
379 | - $diff = $dtstartDt->diff($dtendDt); |
|
380 | - |
|
381 | - $dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM)); |
|
382 | - $dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM)); |
|
383 | - |
|
384 | - if ($isAllDay) { |
|
385 | - // One day event |
|
386 | - if ($diff->days === 1) { |
|
387 | - return $this->getDateString($l10n, $dtstartDt); |
|
388 | - } |
|
389 | - |
|
390 | - return implode(' - ', [ |
|
391 | - $this->getDateString($l10n, $dtstartDt), |
|
392 | - $this->getDateString($l10n, $dtendDt), |
|
393 | - ]); |
|
394 | - } |
|
395 | - |
|
396 | - $startTimezone = $endTimezone = null; |
|
397 | - if (!$vevent->DTSTART->isFloating()) { |
|
398 | - $startTimezone = $vevent->DTSTART->getDateTime()->getTimezone()->getName(); |
|
399 | - $endTimezone = $this->getDTEndFromEvent($vevent)->getDateTime()->getTimezone()->getName(); |
|
400 | - } |
|
401 | - |
|
402 | - $localeStart = implode(', ', [ |
|
403 | - $this->getWeekDayName($l10n, $dtstartDt), |
|
404 | - $this->getDateTimeString($l10n, $dtstartDt) |
|
405 | - ]); |
|
406 | - |
|
407 | - // always show full date with timezone if timezones are different |
|
408 | - if ($startTimezone !== $endTimezone) { |
|
409 | - $localeEnd = implode(', ', [ |
|
410 | - $this->getWeekDayName($l10n, $dtendDt), |
|
411 | - $this->getDateTimeString($l10n, $dtendDt) |
|
412 | - ]); |
|
413 | - |
|
414 | - return $localeStart |
|
415 | - . ' (' . $startTimezone . ') ' |
|
416 | - . ' - ' |
|
417 | - . $localeEnd |
|
418 | - . ' (' . $endTimezone . ')'; |
|
419 | - } |
|
420 | - |
|
421 | - // Show only the time if the day is the same |
|
422 | - $localeEnd = $this->isDayEqual($dtstartDt, $dtendDt) |
|
423 | - ? $this->getTimeString($l10n, $dtendDt) |
|
424 | - : implode(', ', [ |
|
425 | - $this->getWeekDayName($l10n, $dtendDt), |
|
426 | - $this->getDateTimeString($l10n, $dtendDt) |
|
427 | - ]); |
|
428 | - |
|
429 | - return $localeStart |
|
430 | - . ' - ' |
|
431 | - . $localeEnd |
|
432 | - . ' (' . $startTimezone . ')'; |
|
433 | - } |
|
434 | - |
|
435 | - private function isDayEqual(DateTime $dtStart, |
|
436 | - DateTime $dtEnd):bool { |
|
437 | - return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); |
|
438 | - } |
|
439 | - |
|
440 | - private function getWeekDayName(IL10N $l10n, DateTime $dt):string { |
|
441 | - return (string)$l10n->l('weekdayName', $dt, ['width' => 'abbreviated']); |
|
442 | - } |
|
443 | - |
|
444 | - private function getDateString(IL10N $l10n, DateTime $dt):string { |
|
445 | - return (string)$l10n->l('date', $dt, ['width' => 'medium']); |
|
446 | - } |
|
447 | - |
|
448 | - private function getDateTimeString(IL10N $l10n, DateTime $dt):string { |
|
449 | - return (string)$l10n->l('datetime', $dt, ['width' => 'medium|short']); |
|
450 | - } |
|
451 | - |
|
452 | - private function getTimeString(IL10N $l10n, DateTime $dt):string { |
|
453 | - return (string)$l10n->l('time', $dt, ['width' => 'short']); |
|
454 | - } |
|
455 | - |
|
456 | - private function getTitleFromVEvent(VEvent $vevent, IL10N $l10n):string { |
|
457 | - if (isset($vevent->SUMMARY)) { |
|
458 | - return (string)$vevent->SUMMARY; |
|
459 | - } |
|
460 | - |
|
461 | - return $l10n->t('Untitled event'); |
|
462 | - } |
|
54 | + /** @var string */ |
|
55 | + public const NOTIFICATION_TYPE = 'EMAIL'; |
|
56 | + |
|
57 | + private IMailer $mailer; |
|
58 | + |
|
59 | + public function __construct(IConfig $config, |
|
60 | + IMailer $mailer, |
|
61 | + LoggerInterface $logger, |
|
62 | + L10NFactory $l10nFactory, |
|
63 | + IURLGenerator $urlGenerator) { |
|
64 | + parent::__construct($logger, $l10nFactory, $urlGenerator, $config); |
|
65 | + $this->mailer = $mailer; |
|
66 | + } |
|
67 | + |
|
68 | + /** |
|
69 | + * Send out notification via email |
|
70 | + * |
|
71 | + * @param VEvent $vevent |
|
72 | + * @param string $calendarDisplayName |
|
73 | + * @param string[] $principalEmailAddresses |
|
74 | + * @param array $users |
|
75 | + * @throws \Exception |
|
76 | + */ |
|
77 | + public function send(VEvent $vevent, |
|
78 | + string $calendarDisplayName, |
|
79 | + array $principalEmailAddresses, |
|
80 | + array $users = []):void { |
|
81 | + $fallbackLanguage = $this->getFallbackLanguage(); |
|
82 | + |
|
83 | + $organizerEmailAddress = null; |
|
84 | + if (isset($vevent->ORGANIZER)) { |
|
85 | + $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER); |
|
86 | + } |
|
87 | + |
|
88 | + $emailAddressesOfSharees = $this->getEMailAddressesOfAllUsersWithWriteAccessToCalendar($users); |
|
89 | + $emailAddressesOfAttendees = []; |
|
90 | + if (count($principalEmailAddresses) === 0 |
|
91 | + || ($organizerEmailAddress && in_array($organizerEmailAddress, $principalEmailAddresses, true)) |
|
92 | + ) { |
|
93 | + $emailAddressesOfAttendees = $this->getAllEMailAddressesFromEvent($vevent); |
|
94 | + } |
|
95 | + |
|
96 | + // Quote from php.net: |
|
97 | + // If the input arrays have the same string keys, then the later value for that key will overwrite the previous one. |
|
98 | + // => if there are duplicate email addresses, it will always take the system value |
|
99 | + $emailAddresses = array_merge( |
|
100 | + $emailAddressesOfAttendees, |
|
101 | + $emailAddressesOfSharees |
|
102 | + ); |
|
103 | + |
|
104 | + $sortedByLanguage = $this->sortEMailAddressesByLanguage($emailAddresses, $fallbackLanguage); |
|
105 | + $organizer = $this->getOrganizerEMailAndNameFromEvent($vevent); |
|
106 | + |
|
107 | + foreach ($sortedByLanguage as $lang => $emailAddresses) { |
|
108 | + if (!$this->hasL10NForLang($lang)) { |
|
109 | + $lang = $fallbackLanguage; |
|
110 | + } |
|
111 | + $l10n = $this->getL10NForLang($lang); |
|
112 | + $fromEMail = \OCP\Util::getDefaultEmailAddress('reminders-noreply'); |
|
113 | + |
|
114 | + $template = $this->mailer->createEMailTemplate('dav.calendarReminder'); |
|
115 | + $template->addHeader(); |
|
116 | + $this->addSubjectAndHeading($template, $l10n, $vevent); |
|
117 | + $this->addBulletList($template, $l10n, $calendarDisplayName, $vevent); |
|
118 | + $template->addFooter(); |
|
119 | + |
|
120 | + foreach ($emailAddresses as $emailAddress) { |
|
121 | + if (!$this->mailer->validateMailAddress($emailAddress)) { |
|
122 | + $this->logger->error('Email address {address} for reminder notification is incorrect', ['app' => 'dav', 'address' => $emailAddress]); |
|
123 | + continue; |
|
124 | + } |
|
125 | + |
|
126 | + $message = $this->mailer->createMessage(); |
|
127 | + $message->setFrom([$fromEMail]); |
|
128 | + if ($organizer) { |
|
129 | + $message->setReplyTo($organizer); |
|
130 | + } |
|
131 | + $message->setTo([$emailAddress]); |
|
132 | + $message->useTemplate($template); |
|
133 | + |
|
134 | + try { |
|
135 | + $failed = $this->mailer->send($message); |
|
136 | + if ($failed) { |
|
137 | + $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]); |
|
138 | + } |
|
139 | + } catch (\Exception $ex) { |
|
140 | + $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]); |
|
141 | + } |
|
142 | + } |
|
143 | + } |
|
144 | + } |
|
145 | + |
|
146 | + /** |
|
147 | + * @param IEMailTemplate $template |
|
148 | + * @param IL10N $l10n |
|
149 | + * @param VEvent $vevent |
|
150 | + */ |
|
151 | + private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, VEvent $vevent):void { |
|
152 | + $template->setSubject('Notification: ' . $this->getTitleFromVEvent($vevent, $l10n)); |
|
153 | + $template->addHeading($this->getTitleFromVEvent($vevent, $l10n)); |
|
154 | + } |
|
155 | + |
|
156 | + /** |
|
157 | + * @param IEMailTemplate $template |
|
158 | + * @param IL10N $l10n |
|
159 | + * @param string $calendarDisplayName |
|
160 | + * @param array $eventData |
|
161 | + */ |
|
162 | + private function addBulletList(IEMailTemplate $template, |
|
163 | + IL10N $l10n, |
|
164 | + string $calendarDisplayName, |
|
165 | + VEvent $vevent):void { |
|
166 | + $template->addBodyListItem($calendarDisplayName, $l10n->t('Calendar:'), |
|
167 | + $this->getAbsoluteImagePath('actions/info.png')); |
|
168 | + |
|
169 | + $template->addBodyListItem($this->generateDateString($l10n, $vevent), $l10n->t('Date:'), |
|
170 | + $this->getAbsoluteImagePath('places/calendar.png')); |
|
171 | + |
|
172 | + if (isset($vevent->LOCATION)) { |
|
173 | + $template->addBodyListItem((string) $vevent->LOCATION, $l10n->t('Where:'), |
|
174 | + $this->getAbsoluteImagePath('actions/address.png')); |
|
175 | + } |
|
176 | + if (isset($vevent->DESCRIPTION)) { |
|
177 | + $template->addBodyListItem((string) $vevent->DESCRIPTION, $l10n->t('Description:'), |
|
178 | + $this->getAbsoluteImagePath('actions/more.png')); |
|
179 | + } |
|
180 | + } |
|
181 | + |
|
182 | + private function getAbsoluteImagePath(string $path):string { |
|
183 | + return $this->urlGenerator->getAbsoluteURL( |
|
184 | + $this->urlGenerator->imagePath('core', $path) |
|
185 | + ); |
|
186 | + } |
|
187 | + |
|
188 | + /** |
|
189 | + * @param VEvent $vevent |
|
190 | + * @return array|null |
|
191 | + */ |
|
192 | + private function getOrganizerEMailAndNameFromEvent(VEvent $vevent):?array { |
|
193 | + if (!$vevent->ORGANIZER) { |
|
194 | + return null; |
|
195 | + } |
|
196 | + |
|
197 | + $organizer = $vevent->ORGANIZER; |
|
198 | + if (strcasecmp($organizer->getValue(), 'mailto:') !== 0) { |
|
199 | + return null; |
|
200 | + } |
|
201 | + |
|
202 | + $organizerEMail = substr($organizer->getValue(), 7); |
|
203 | + |
|
204 | + if (!$this->mailer->validateMailAddress($organizerEMail)) { |
|
205 | + return null; |
|
206 | + } |
|
207 | + |
|
208 | + $name = $organizer->offsetGet('CN'); |
|
209 | + if ($name instanceof Parameter) { |
|
210 | + return [$organizerEMail => $name]; |
|
211 | + } |
|
212 | + |
|
213 | + return [$organizerEMail]; |
|
214 | + } |
|
215 | + |
|
216 | + /** |
|
217 | + * @param array<string, array{LANG?: string}> $emails |
|
218 | + * @return array<string, string[]> |
|
219 | + */ |
|
220 | + private function sortEMailAddressesByLanguage(array $emails, |
|
221 | + string $defaultLanguage):array { |
|
222 | + $sortedByLanguage = []; |
|
223 | + |
|
224 | + foreach ($emails as $emailAddress => $parameters) { |
|
225 | + if (isset($parameters['LANG'])) { |
|
226 | + $lang = $parameters['LANG']; |
|
227 | + } else { |
|
228 | + $lang = $defaultLanguage; |
|
229 | + } |
|
230 | + |
|
231 | + if (!isset($sortedByLanguage[$lang])) { |
|
232 | + $sortedByLanguage[$lang] = []; |
|
233 | + } |
|
234 | + |
|
235 | + $sortedByLanguage[$lang][] = $emailAddress; |
|
236 | + } |
|
237 | + |
|
238 | + return $sortedByLanguage; |
|
239 | + } |
|
240 | + |
|
241 | + /** |
|
242 | + * @param VEvent $vevent |
|
243 | + * @return array<string, array{LANG?: string}> |
|
244 | + */ |
|
245 | + private function getAllEMailAddressesFromEvent(VEvent $vevent):array { |
|
246 | + $emailAddresses = []; |
|
247 | + |
|
248 | + if (isset($vevent->ATTENDEE)) { |
|
249 | + foreach ($vevent->ATTENDEE as $attendee) { |
|
250 | + if (!($attendee instanceof VObject\Property)) { |
|
251 | + continue; |
|
252 | + } |
|
253 | + |
|
254 | + $cuType = $this->getCUTypeOfAttendee($attendee); |
|
255 | + if (\in_array($cuType, ['RESOURCE', 'ROOM', 'UNKNOWN'])) { |
|
256 | + // Don't send emails to things |
|
257 | + continue; |
|
258 | + } |
|
259 | + |
|
260 | + $partstat = $this->getPartstatOfAttendee($attendee); |
|
261 | + if ($partstat === 'DECLINED') { |
|
262 | + // Don't send out emails to people who declined |
|
263 | + continue; |
|
264 | + } |
|
265 | + if ($partstat === 'DELEGATED') { |
|
266 | + $delegates = $attendee->offsetGet('DELEGATED-TO'); |
|
267 | + if (!($delegates instanceof VObject\Parameter)) { |
|
268 | + continue; |
|
269 | + } |
|
270 | + |
|
271 | + $emailAddressesOfDelegates = $delegates->getParts(); |
|
272 | + foreach ($emailAddressesOfDelegates as $addressesOfDelegate) { |
|
273 | + if (strcasecmp($addressesOfDelegate, 'mailto:') === 0) { |
|
274 | + $delegateEmail = substr($addressesOfDelegate, 7); |
|
275 | + if ($this->mailer->validateMailAddress($delegateEmail)) { |
|
276 | + $emailAddresses[$delegateEmail] = []; |
|
277 | + } |
|
278 | + } |
|
279 | + } |
|
280 | + |
|
281 | + continue; |
|
282 | + } |
|
283 | + |
|
284 | + $emailAddressOfAttendee = $this->getEMailAddressOfAttendee($attendee); |
|
285 | + if ($emailAddressOfAttendee !== null) { |
|
286 | + $properties = []; |
|
287 | + |
|
288 | + $langProp = $attendee->offsetGet('LANG'); |
|
289 | + if ($langProp instanceof VObject\Parameter && $langProp->getValue() !== null) { |
|
290 | + $properties['LANG'] = $langProp->getValue(); |
|
291 | + } |
|
292 | + |
|
293 | + $emailAddresses[$emailAddressOfAttendee] = $properties; |
|
294 | + } |
|
295 | + } |
|
296 | + } |
|
297 | + |
|
298 | + if (isset($vevent->ORGANIZER) && $this->hasAttendeeMailURI($vevent->ORGANIZER)) { |
|
299 | + $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER); |
|
300 | + if ($organizerEmailAddress !== null) { |
|
301 | + $emailAddresses[$organizerEmailAddress] = []; |
|
302 | + } |
|
303 | + } |
|
304 | + |
|
305 | + return $emailAddresses; |
|
306 | + } |
|
307 | + |
|
308 | + private function getCUTypeOfAttendee(VObject\Property $attendee):string { |
|
309 | + $cuType = $attendee->offsetGet('CUTYPE'); |
|
310 | + if ($cuType instanceof VObject\Parameter) { |
|
311 | + return strtoupper($cuType->getValue()); |
|
312 | + } |
|
313 | + |
|
314 | + return 'INDIVIDUAL'; |
|
315 | + } |
|
316 | + |
|
317 | + private function getPartstatOfAttendee(VObject\Property $attendee):string { |
|
318 | + $partstat = $attendee->offsetGet('PARTSTAT'); |
|
319 | + if ($partstat instanceof VObject\Parameter) { |
|
320 | + return strtoupper($partstat->getValue()); |
|
321 | + } |
|
322 | + |
|
323 | + return 'NEEDS-ACTION'; |
|
324 | + } |
|
325 | + |
|
326 | + private function hasAttendeeMailURI(VObject\Property $attendee): bool { |
|
327 | + return stripos($attendee->getValue(), 'mailto:') === 0; |
|
328 | + } |
|
329 | + |
|
330 | + private function getEMailAddressOfAttendee(VObject\Property $attendee): ?string { |
|
331 | + if (!$this->hasAttendeeMailURI($attendee)) { |
|
332 | + return null; |
|
333 | + } |
|
334 | + $attendeeEMail = substr($attendee->getValue(), 7); |
|
335 | + if (!$this->mailer->validateMailAddress($attendeeEMail)) { |
|
336 | + return null; |
|
337 | + } |
|
338 | + |
|
339 | + return $attendeeEMail; |
|
340 | + } |
|
341 | + |
|
342 | + /** |
|
343 | + * @param IUser[] $users |
|
344 | + * @return array<string, array{LANG?: string}> |
|
345 | + */ |
|
346 | + private function getEMailAddressesOfAllUsersWithWriteAccessToCalendar(array $users):array { |
|
347 | + $emailAddresses = []; |
|
348 | + |
|
349 | + foreach ($users as $user) { |
|
350 | + $emailAddress = $user->getEMailAddress(); |
|
351 | + if ($emailAddress) { |
|
352 | + $lang = $this->l10nFactory->getUserLanguage($user); |
|
353 | + if ($lang) { |
|
354 | + $emailAddresses[$emailAddress] = [ |
|
355 | + 'LANG' => $lang, |
|
356 | + ]; |
|
357 | + } else { |
|
358 | + $emailAddresses[$emailAddress] = []; |
|
359 | + } |
|
360 | + } |
|
361 | + } |
|
362 | + |
|
363 | + return $emailAddresses; |
|
364 | + } |
|
365 | + |
|
366 | + /** |
|
367 | + * @throws \Exception |
|
368 | + */ |
|
369 | + private function generateDateString(IL10N $l10n, VEvent $vevent): string { |
|
370 | + $isAllDay = $vevent->DTSTART instanceof Property\ICalendar\Date; |
|
371 | + |
|
372 | + /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */ |
|
373 | + /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */ |
|
374 | + /** @var \DateTimeImmutable $dtstartDt */ |
|
375 | + $dtstartDt = $vevent->DTSTART->getDateTime(); |
|
376 | + /** @var \DateTimeImmutable $dtendDt */ |
|
377 | + $dtendDt = $this->getDTEndFromEvent($vevent)->getDateTime(); |
|
378 | + |
|
379 | + $diff = $dtstartDt->diff($dtendDt); |
|
380 | + |
|
381 | + $dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM)); |
|
382 | + $dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM)); |
|
383 | + |
|
384 | + if ($isAllDay) { |
|
385 | + // One day event |
|
386 | + if ($diff->days === 1) { |
|
387 | + return $this->getDateString($l10n, $dtstartDt); |
|
388 | + } |
|
389 | + |
|
390 | + return implode(' - ', [ |
|
391 | + $this->getDateString($l10n, $dtstartDt), |
|
392 | + $this->getDateString($l10n, $dtendDt), |
|
393 | + ]); |
|
394 | + } |
|
395 | + |
|
396 | + $startTimezone = $endTimezone = null; |
|
397 | + if (!$vevent->DTSTART->isFloating()) { |
|
398 | + $startTimezone = $vevent->DTSTART->getDateTime()->getTimezone()->getName(); |
|
399 | + $endTimezone = $this->getDTEndFromEvent($vevent)->getDateTime()->getTimezone()->getName(); |
|
400 | + } |
|
401 | + |
|
402 | + $localeStart = implode(', ', [ |
|
403 | + $this->getWeekDayName($l10n, $dtstartDt), |
|
404 | + $this->getDateTimeString($l10n, $dtstartDt) |
|
405 | + ]); |
|
406 | + |
|
407 | + // always show full date with timezone if timezones are different |
|
408 | + if ($startTimezone !== $endTimezone) { |
|
409 | + $localeEnd = implode(', ', [ |
|
410 | + $this->getWeekDayName($l10n, $dtendDt), |
|
411 | + $this->getDateTimeString($l10n, $dtendDt) |
|
412 | + ]); |
|
413 | + |
|
414 | + return $localeStart |
|
415 | + . ' (' . $startTimezone . ') ' |
|
416 | + . ' - ' |
|
417 | + . $localeEnd |
|
418 | + . ' (' . $endTimezone . ')'; |
|
419 | + } |
|
420 | + |
|
421 | + // Show only the time if the day is the same |
|
422 | + $localeEnd = $this->isDayEqual($dtstartDt, $dtendDt) |
|
423 | + ? $this->getTimeString($l10n, $dtendDt) |
|
424 | + : implode(', ', [ |
|
425 | + $this->getWeekDayName($l10n, $dtendDt), |
|
426 | + $this->getDateTimeString($l10n, $dtendDt) |
|
427 | + ]); |
|
428 | + |
|
429 | + return $localeStart |
|
430 | + . ' - ' |
|
431 | + . $localeEnd |
|
432 | + . ' (' . $startTimezone . ')'; |
|
433 | + } |
|
434 | + |
|
435 | + private function isDayEqual(DateTime $dtStart, |
|
436 | + DateTime $dtEnd):bool { |
|
437 | + return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); |
|
438 | + } |
|
439 | + |
|
440 | + private function getWeekDayName(IL10N $l10n, DateTime $dt):string { |
|
441 | + return (string)$l10n->l('weekdayName', $dt, ['width' => 'abbreviated']); |
|
442 | + } |
|
443 | + |
|
444 | + private function getDateString(IL10N $l10n, DateTime $dt):string { |
|
445 | + return (string)$l10n->l('date', $dt, ['width' => 'medium']); |
|
446 | + } |
|
447 | + |
|
448 | + private function getDateTimeString(IL10N $l10n, DateTime $dt):string { |
|
449 | + return (string)$l10n->l('datetime', $dt, ['width' => 'medium|short']); |
|
450 | + } |
|
451 | + |
|
452 | + private function getTimeString(IL10N $l10n, DateTime $dt):string { |
|
453 | + return (string)$l10n->l('time', $dt, ['width' => 'short']); |
|
454 | + } |
|
455 | + |
|
456 | + private function getTitleFromVEvent(VEvent $vevent, IL10N $l10n):string { |
|
457 | + if (isset($vevent->SUMMARY)) { |
|
458 | + return (string)$vevent->SUMMARY; |
|
459 | + } |
|
460 | + |
|
461 | + return $l10n->t('Untitled event'); |
|
462 | + } |
|
463 | 463 | } |
@@ -71,171 +71,171 @@ discard block |
||
71 | 71 | * @license http://sabre.io/license/ Modified BSD License |
72 | 72 | */ |
73 | 73 | class IMipPlugin extends SabreIMipPlugin { |
74 | - /** @var string */ |
|
75 | - private $userId; |
|
76 | - |
|
77 | - /** @var IConfig */ |
|
78 | - private $config; |
|
79 | - |
|
80 | - /** @var IMailer */ |
|
81 | - private $mailer; |
|
82 | - |
|
83 | - private LoggerInterface $logger; |
|
84 | - |
|
85 | - /** @var ITimeFactory */ |
|
86 | - private $timeFactory; |
|
87 | - |
|
88 | - /** @var L10NFactory */ |
|
89 | - private $l10nFactory; |
|
90 | - |
|
91 | - /** @var IURLGenerator */ |
|
92 | - private $urlGenerator; |
|
93 | - |
|
94 | - /** @var ISecureRandom */ |
|
95 | - private $random; |
|
96 | - |
|
97 | - /** @var IDBConnection */ |
|
98 | - private $db; |
|
99 | - |
|
100 | - /** @var Defaults */ |
|
101 | - private $defaults; |
|
102 | - |
|
103 | - /** @var IUserManager */ |
|
104 | - private $userManager; |
|
105 | - |
|
106 | - public const MAX_DATE = '2038-01-01'; |
|
107 | - |
|
108 | - public const METHOD_REQUEST = 'request'; |
|
109 | - public const METHOD_REPLY = 'reply'; |
|
110 | - public const METHOD_CANCEL = 'cancel'; |
|
111 | - public const IMIP_INDENT = 15; // Enough for the length of all body bullet items, in all languages |
|
112 | - |
|
113 | - public function __construct(IConfig $config, IMailer $mailer, |
|
114 | - LoggerInterface $logger, |
|
115 | - ITimeFactory $timeFactory, L10NFactory $l10nFactory, |
|
116 | - IURLGenerator $urlGenerator, Defaults $defaults, |
|
117 | - ISecureRandom $random, IDBConnection $db, IUserManager $userManager, |
|
118 | - $userId) { |
|
119 | - parent::__construct(''); |
|
120 | - $this->userId = $userId; |
|
121 | - $this->config = $config; |
|
122 | - $this->mailer = $mailer; |
|
123 | - $this->logger = $logger; |
|
124 | - $this->timeFactory = $timeFactory; |
|
125 | - $this->l10nFactory = $l10nFactory; |
|
126 | - $this->urlGenerator = $urlGenerator; |
|
127 | - $this->random = $random; |
|
128 | - $this->db = $db; |
|
129 | - $this->defaults = $defaults; |
|
130 | - $this->userManager = $userManager; |
|
131 | - } |
|
132 | - |
|
133 | - /** |
|
134 | - * Event handler for the 'schedule' event. |
|
135 | - * |
|
136 | - * @param Message $iTipMessage |
|
137 | - * @return void |
|
138 | - */ |
|
139 | - public function schedule(Message $iTipMessage) { |
|
140 | - // Not sending any emails if the system considers the update |
|
141 | - // insignificant. |
|
142 | - if (!$iTipMessage->significantChange) { |
|
143 | - if (!$iTipMessage->scheduleStatus) { |
|
144 | - $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; |
|
145 | - } |
|
146 | - return; |
|
147 | - } |
|
148 | - |
|
149 | - $summary = $iTipMessage->message->VEVENT->SUMMARY; |
|
150 | - |
|
151 | - if (parse_url($iTipMessage->sender, PHP_URL_SCHEME) !== 'mailto') { |
|
152 | - return; |
|
153 | - } |
|
154 | - |
|
155 | - if (parse_url($iTipMessage->recipient, PHP_URL_SCHEME) !== 'mailto') { |
|
156 | - return; |
|
157 | - } |
|
158 | - |
|
159 | - // don't send out mails for events that already took place |
|
160 | - $lastOccurrence = $this->getLastOccurrence($iTipMessage->message); |
|
161 | - $currentTime = $this->timeFactory->getTime(); |
|
162 | - if ($lastOccurrence < $currentTime) { |
|
163 | - return; |
|
164 | - } |
|
165 | - |
|
166 | - // Strip off mailto: |
|
167 | - $sender = substr($iTipMessage->sender, 7); |
|
168 | - $recipient = substr($iTipMessage->recipient, 7); |
|
169 | - if (!$this->mailer->validateMailAddress($recipient)) { |
|
170 | - // Nothing to send if the recipient doesn't have a valid email address |
|
171 | - $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; |
|
172 | - return; |
|
173 | - } |
|
174 | - |
|
175 | - $senderName = $iTipMessage->senderName ?: null; |
|
176 | - $recipientName = $iTipMessage->recipientName ?: null; |
|
177 | - |
|
178 | - if ($senderName === null || empty(trim($senderName))) { |
|
179 | - $senderName = $this->userManager->getDisplayName($this->userId); |
|
180 | - } |
|
181 | - |
|
182 | - /** @var VEvent $vevent */ |
|
183 | - $vevent = $iTipMessage->message->VEVENT; |
|
184 | - |
|
185 | - $attendee = $this->getCurrentAttendee($iTipMessage); |
|
186 | - $defaultLang = $this->l10nFactory->findGenericLanguage(); |
|
187 | - $lang = $this->getAttendeeLangOrDefault($defaultLang, $attendee); |
|
188 | - $l10n = $this->l10nFactory->get('dav', $lang); |
|
189 | - |
|
190 | - $meetingAttendeeName = $recipientName ?: $recipient; |
|
191 | - $meetingInviteeName = $senderName ?: $sender; |
|
192 | - |
|
193 | - $meetingTitle = $vevent->SUMMARY; |
|
194 | - $meetingDescription = $vevent->DESCRIPTION; |
|
195 | - |
|
196 | - |
|
197 | - $meetingUrl = $vevent->URL; |
|
198 | - $meetingLocation = $vevent->LOCATION; |
|
199 | - |
|
200 | - $defaultVal = '--'; |
|
201 | - |
|
202 | - $method = self::METHOD_REQUEST; |
|
203 | - switch (strtolower($iTipMessage->method)) { |
|
204 | - case self::METHOD_REPLY: |
|
205 | - $method = self::METHOD_REPLY; |
|
206 | - break; |
|
207 | - case self::METHOD_CANCEL: |
|
208 | - $method = self::METHOD_CANCEL; |
|
209 | - break; |
|
210 | - } |
|
211 | - |
|
212 | - $data = [ |
|
213 | - 'attendee_name' => (string)$meetingAttendeeName ?: $defaultVal, |
|
214 | - 'invitee_name' => (string)$meetingInviteeName ?: $defaultVal, |
|
215 | - 'meeting_title' => (string)$meetingTitle ?: $defaultVal, |
|
216 | - 'meeting_description' => (string)$meetingDescription ?: $defaultVal, |
|
217 | - 'meeting_url' => (string)$meetingUrl ?: $defaultVal, |
|
218 | - ]; |
|
219 | - |
|
220 | - $fromEMail = Util::getDefaultEmailAddress('invitations-noreply'); |
|
221 | - $fromName = $l10n->t('%1$s via %2$s', [$senderName ?? $this->userId, $this->defaults->getName()]); |
|
222 | - |
|
223 | - $message = $this->mailer->createMessage() |
|
224 | - ->setFrom([$fromEMail => $fromName]) |
|
225 | - ->setTo([$recipient => $recipientName]) |
|
226 | - ->setReplyTo([$sender => $senderName]); |
|
227 | - |
|
228 | - $template = $this->mailer->createEMailTemplate('dav.calendarInvite.' . $method, $data); |
|
229 | - $template->addHeader(); |
|
230 | - |
|
231 | - $summary = ((string) $summary !== '') ? (string) $summary : $l10n->t('Untitled event'); |
|
232 | - |
|
233 | - $this->addSubjectAndHeading($template, $l10n, $method, $summary); |
|
234 | - $this->addBulletList($template, $l10n, $vevent); |
|
235 | - |
|
236 | - // Only add response buttons to invitation requests: Fix Issue #11230 |
|
237 | - if (($method == self::METHOD_REQUEST) && $this->getAttendeeRsvpOrReqForParticipant($attendee)) { |
|
238 | - /* |
|
74 | + /** @var string */ |
|
75 | + private $userId; |
|
76 | + |
|
77 | + /** @var IConfig */ |
|
78 | + private $config; |
|
79 | + |
|
80 | + /** @var IMailer */ |
|
81 | + private $mailer; |
|
82 | + |
|
83 | + private LoggerInterface $logger; |
|
84 | + |
|
85 | + /** @var ITimeFactory */ |
|
86 | + private $timeFactory; |
|
87 | + |
|
88 | + /** @var L10NFactory */ |
|
89 | + private $l10nFactory; |
|
90 | + |
|
91 | + /** @var IURLGenerator */ |
|
92 | + private $urlGenerator; |
|
93 | + |
|
94 | + /** @var ISecureRandom */ |
|
95 | + private $random; |
|
96 | + |
|
97 | + /** @var IDBConnection */ |
|
98 | + private $db; |
|
99 | + |
|
100 | + /** @var Defaults */ |
|
101 | + private $defaults; |
|
102 | + |
|
103 | + /** @var IUserManager */ |
|
104 | + private $userManager; |
|
105 | + |
|
106 | + public const MAX_DATE = '2038-01-01'; |
|
107 | + |
|
108 | + public const METHOD_REQUEST = 'request'; |
|
109 | + public const METHOD_REPLY = 'reply'; |
|
110 | + public const METHOD_CANCEL = 'cancel'; |
|
111 | + public const IMIP_INDENT = 15; // Enough for the length of all body bullet items, in all languages |
|
112 | + |
|
113 | + public function __construct(IConfig $config, IMailer $mailer, |
|
114 | + LoggerInterface $logger, |
|
115 | + ITimeFactory $timeFactory, L10NFactory $l10nFactory, |
|
116 | + IURLGenerator $urlGenerator, Defaults $defaults, |
|
117 | + ISecureRandom $random, IDBConnection $db, IUserManager $userManager, |
|
118 | + $userId) { |
|
119 | + parent::__construct(''); |
|
120 | + $this->userId = $userId; |
|
121 | + $this->config = $config; |
|
122 | + $this->mailer = $mailer; |
|
123 | + $this->logger = $logger; |
|
124 | + $this->timeFactory = $timeFactory; |
|
125 | + $this->l10nFactory = $l10nFactory; |
|
126 | + $this->urlGenerator = $urlGenerator; |
|
127 | + $this->random = $random; |
|
128 | + $this->db = $db; |
|
129 | + $this->defaults = $defaults; |
|
130 | + $this->userManager = $userManager; |
|
131 | + } |
|
132 | + |
|
133 | + /** |
|
134 | + * Event handler for the 'schedule' event. |
|
135 | + * |
|
136 | + * @param Message $iTipMessage |
|
137 | + * @return void |
|
138 | + */ |
|
139 | + public function schedule(Message $iTipMessage) { |
|
140 | + // Not sending any emails if the system considers the update |
|
141 | + // insignificant. |
|
142 | + if (!$iTipMessage->significantChange) { |
|
143 | + if (!$iTipMessage->scheduleStatus) { |
|
144 | + $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; |
|
145 | + } |
|
146 | + return; |
|
147 | + } |
|
148 | + |
|
149 | + $summary = $iTipMessage->message->VEVENT->SUMMARY; |
|
150 | + |
|
151 | + if (parse_url($iTipMessage->sender, PHP_URL_SCHEME) !== 'mailto') { |
|
152 | + return; |
|
153 | + } |
|
154 | + |
|
155 | + if (parse_url($iTipMessage->recipient, PHP_URL_SCHEME) !== 'mailto') { |
|
156 | + return; |
|
157 | + } |
|
158 | + |
|
159 | + // don't send out mails for events that already took place |
|
160 | + $lastOccurrence = $this->getLastOccurrence($iTipMessage->message); |
|
161 | + $currentTime = $this->timeFactory->getTime(); |
|
162 | + if ($lastOccurrence < $currentTime) { |
|
163 | + return; |
|
164 | + } |
|
165 | + |
|
166 | + // Strip off mailto: |
|
167 | + $sender = substr($iTipMessage->sender, 7); |
|
168 | + $recipient = substr($iTipMessage->recipient, 7); |
|
169 | + if (!$this->mailer->validateMailAddress($recipient)) { |
|
170 | + // Nothing to send if the recipient doesn't have a valid email address |
|
171 | + $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; |
|
172 | + return; |
|
173 | + } |
|
174 | + |
|
175 | + $senderName = $iTipMessage->senderName ?: null; |
|
176 | + $recipientName = $iTipMessage->recipientName ?: null; |
|
177 | + |
|
178 | + if ($senderName === null || empty(trim($senderName))) { |
|
179 | + $senderName = $this->userManager->getDisplayName($this->userId); |
|
180 | + } |
|
181 | + |
|
182 | + /** @var VEvent $vevent */ |
|
183 | + $vevent = $iTipMessage->message->VEVENT; |
|
184 | + |
|
185 | + $attendee = $this->getCurrentAttendee($iTipMessage); |
|
186 | + $defaultLang = $this->l10nFactory->findGenericLanguage(); |
|
187 | + $lang = $this->getAttendeeLangOrDefault($defaultLang, $attendee); |
|
188 | + $l10n = $this->l10nFactory->get('dav', $lang); |
|
189 | + |
|
190 | + $meetingAttendeeName = $recipientName ?: $recipient; |
|
191 | + $meetingInviteeName = $senderName ?: $sender; |
|
192 | + |
|
193 | + $meetingTitle = $vevent->SUMMARY; |
|
194 | + $meetingDescription = $vevent->DESCRIPTION; |
|
195 | + |
|
196 | + |
|
197 | + $meetingUrl = $vevent->URL; |
|
198 | + $meetingLocation = $vevent->LOCATION; |
|
199 | + |
|
200 | + $defaultVal = '--'; |
|
201 | + |
|
202 | + $method = self::METHOD_REQUEST; |
|
203 | + switch (strtolower($iTipMessage->method)) { |
|
204 | + case self::METHOD_REPLY: |
|
205 | + $method = self::METHOD_REPLY; |
|
206 | + break; |
|
207 | + case self::METHOD_CANCEL: |
|
208 | + $method = self::METHOD_CANCEL; |
|
209 | + break; |
|
210 | + } |
|
211 | + |
|
212 | + $data = [ |
|
213 | + 'attendee_name' => (string)$meetingAttendeeName ?: $defaultVal, |
|
214 | + 'invitee_name' => (string)$meetingInviteeName ?: $defaultVal, |
|
215 | + 'meeting_title' => (string)$meetingTitle ?: $defaultVal, |
|
216 | + 'meeting_description' => (string)$meetingDescription ?: $defaultVal, |
|
217 | + 'meeting_url' => (string)$meetingUrl ?: $defaultVal, |
|
218 | + ]; |
|
219 | + |
|
220 | + $fromEMail = Util::getDefaultEmailAddress('invitations-noreply'); |
|
221 | + $fromName = $l10n->t('%1$s via %2$s', [$senderName ?? $this->userId, $this->defaults->getName()]); |
|
222 | + |
|
223 | + $message = $this->mailer->createMessage() |
|
224 | + ->setFrom([$fromEMail => $fromName]) |
|
225 | + ->setTo([$recipient => $recipientName]) |
|
226 | + ->setReplyTo([$sender => $senderName]); |
|
227 | + |
|
228 | + $template = $this->mailer->createEMailTemplate('dav.calendarInvite.' . $method, $data); |
|
229 | + $template->addHeader(); |
|
230 | + |
|
231 | + $summary = ((string) $summary !== '') ? (string) $summary : $l10n->t('Untitled event'); |
|
232 | + |
|
233 | + $this->addSubjectAndHeading($template, $l10n, $method, $summary); |
|
234 | + $this->addBulletList($template, $l10n, $vevent); |
|
235 | + |
|
236 | + // Only add response buttons to invitation requests: Fix Issue #11230 |
|
237 | + if (($method == self::METHOD_REQUEST) && $this->getAttendeeRsvpOrReqForParticipant($attendee)) { |
|
238 | + /* |
|
239 | 239 | ** Only offer invitation accept/reject buttons, which link back to the |
240 | 240 | ** nextcloud server, to recipients who can access the nextcloud server via |
241 | 241 | ** their internet/intranet. Issue #12156 |
@@ -254,453 +254,453 @@ discard block |
||
254 | 254 | ** To suppress URLs entirely, set invitation_link_recipients to boolean "no". |
255 | 255 | */ |
256 | 256 | |
257 | - $recipientDomain = substr(strrchr($recipient, "@"), 1); |
|
258 | - $invitationLinkRecipients = explode(',', preg_replace('/\s+/', '', strtolower($this->config->getAppValue('dav', 'invitation_link_recipients', 'yes')))); |
|
259 | - |
|
260 | - if (strcmp('yes', $invitationLinkRecipients[0]) === 0 |
|
261 | - || in_array(strtolower($recipient), $invitationLinkRecipients) |
|
262 | - || in_array(strtolower($recipientDomain), $invitationLinkRecipients)) { |
|
263 | - $this->addResponseButtons($template, $l10n, $iTipMessage, $lastOccurrence); |
|
264 | - } |
|
265 | - } |
|
266 | - |
|
267 | - $template->addFooter(); |
|
268 | - |
|
269 | - $message->useTemplate($template); |
|
270 | - |
|
271 | - $attachment = $this->mailer->createAttachment( |
|
272 | - $iTipMessage->message->serialize(), |
|
273 | - 'event.ics',// TODO(leon): Make file name unique, e.g. add event id |
|
274 | - 'text/calendar; method=' . $iTipMessage->method |
|
275 | - ); |
|
276 | - $message->attach($attachment); |
|
277 | - |
|
278 | - try { |
|
279 | - $failed = $this->mailer->send($message); |
|
280 | - $iTipMessage->scheduleStatus = '1.1; Scheduling message is sent via iMip'; |
|
281 | - if ($failed) { |
|
282 | - $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]); |
|
283 | - $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; |
|
284 | - } |
|
285 | - } catch (\Exception $ex) { |
|
286 | - $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]); |
|
287 | - $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; |
|
288 | - } |
|
289 | - } |
|
290 | - |
|
291 | - /** |
|
292 | - * check if event took place in the past already |
|
293 | - * @param VCalendar $vObject |
|
294 | - * @return int |
|
295 | - */ |
|
296 | - private function getLastOccurrence(VCalendar $vObject) { |
|
297 | - /** @var VEvent $component */ |
|
298 | - $component = $vObject->VEVENT; |
|
299 | - |
|
300 | - $firstOccurrence = $component->DTSTART->getDateTime()->getTimeStamp(); |
|
301 | - // Finding the last occurrence is a bit harder |
|
302 | - if (!isset($component->RRULE)) { |
|
303 | - if (isset($component->DTEND)) { |
|
304 | - $lastOccurrence = $component->DTEND->getDateTime()->getTimeStamp(); |
|
305 | - } elseif (isset($component->DURATION)) { |
|
306 | - /** @var \DateTime $endDate */ |
|
307 | - $endDate = clone $component->DTSTART->getDateTime(); |
|
308 | - // $component->DTEND->getDateTime() returns DateTimeImmutable |
|
309 | - $endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue())); |
|
310 | - $lastOccurrence = $endDate->getTimestamp(); |
|
311 | - } elseif (!$component->DTSTART->hasTime()) { |
|
312 | - /** @var \DateTime $endDate */ |
|
313 | - $endDate = clone $component->DTSTART->getDateTime(); |
|
314 | - // $component->DTSTART->getDateTime() returns DateTimeImmutable |
|
315 | - $endDate = $endDate->modify('+1 day'); |
|
316 | - $lastOccurrence = $endDate->getTimestamp(); |
|
317 | - } else { |
|
318 | - $lastOccurrence = $firstOccurrence; |
|
319 | - } |
|
320 | - } else { |
|
321 | - $it = new EventIterator($vObject, (string)$component->UID); |
|
322 | - $maxDate = new \DateTime(self::MAX_DATE); |
|
323 | - if ($it->isInfinite()) { |
|
324 | - $lastOccurrence = $maxDate->getTimestamp(); |
|
325 | - } else { |
|
326 | - $end = $it->getDtEnd(); |
|
327 | - while ($it->valid() && $end < $maxDate) { |
|
328 | - $end = $it->getDtEnd(); |
|
329 | - $it->next(); |
|
330 | - } |
|
331 | - $lastOccurrence = $end->getTimestamp(); |
|
332 | - } |
|
333 | - } |
|
334 | - |
|
335 | - return $lastOccurrence; |
|
336 | - } |
|
337 | - |
|
338 | - /** |
|
339 | - * @param Message $iTipMessage |
|
340 | - * @return null|Property |
|
341 | - */ |
|
342 | - private function getCurrentAttendee(Message $iTipMessage) { |
|
343 | - /** @var VEvent $vevent */ |
|
344 | - $vevent = $iTipMessage->message->VEVENT; |
|
345 | - $attendees = $vevent->select('ATTENDEE'); |
|
346 | - foreach ($attendees as $attendee) { |
|
347 | - /** @var Property $attendee */ |
|
348 | - if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) { |
|
349 | - return $attendee; |
|
350 | - } |
|
351 | - } |
|
352 | - return null; |
|
353 | - } |
|
354 | - |
|
355 | - /** |
|
356 | - * @param string $default |
|
357 | - * @param Property|null $attendee |
|
358 | - * @return string |
|
359 | - */ |
|
360 | - private function getAttendeeLangOrDefault($default, Property $attendee = null) { |
|
361 | - if ($attendee !== null) { |
|
362 | - $lang = $attendee->offsetGet('LANGUAGE'); |
|
363 | - if ($lang instanceof Parameter) { |
|
364 | - return $lang->getValue(); |
|
365 | - } |
|
366 | - } |
|
367 | - return $default; |
|
368 | - } |
|
369 | - |
|
370 | - /** |
|
371 | - * @param Property|null $attendee |
|
372 | - * @return bool |
|
373 | - */ |
|
374 | - private function getAttendeeRsvpOrReqForParticipant(Property $attendee = null) { |
|
375 | - if ($attendee !== null) { |
|
376 | - $rsvp = $attendee->offsetGet('RSVP'); |
|
377 | - if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) { |
|
378 | - return true; |
|
379 | - } |
|
380 | - $role = $attendee->offsetGet('ROLE'); |
|
381 | - // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16 |
|
382 | - // Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set |
|
383 | - if ($role === null |
|
384 | - || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0)) |
|
385 | - || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0)) |
|
386 | - ) { |
|
387 | - return true; |
|
388 | - } |
|
389 | - } |
|
390 | - // RFC 5545 3.2.17: default RSVP is false |
|
391 | - return false; |
|
392 | - } |
|
393 | - |
|
394 | - /** |
|
395 | - * @param IL10N $l10n |
|
396 | - * @param VEvent $vevent |
|
397 | - */ |
|
398 | - private function generateWhenString(IL10N $l10n, VEvent $vevent) { |
|
399 | - $dtstart = $vevent->DTSTART; |
|
400 | - if (isset($vevent->DTEND)) { |
|
401 | - $dtend = $vevent->DTEND; |
|
402 | - } elseif (isset($vevent->DURATION)) { |
|
403 | - $isFloating = $vevent->DTSTART->isFloating(); |
|
404 | - $dtend = clone $vevent->DTSTART; |
|
405 | - $endDateTime = $dtend->getDateTime(); |
|
406 | - $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue())); |
|
407 | - $dtend->setDateTime($endDateTime, $isFloating); |
|
408 | - } elseif (!$vevent->DTSTART->hasTime()) { |
|
409 | - $isFloating = $vevent->DTSTART->isFloating(); |
|
410 | - $dtend = clone $vevent->DTSTART; |
|
411 | - $endDateTime = $dtend->getDateTime(); |
|
412 | - $endDateTime = $endDateTime->modify('+1 day'); |
|
413 | - $dtend->setDateTime($endDateTime, $isFloating); |
|
414 | - } else { |
|
415 | - $dtend = clone $vevent->DTSTART; |
|
416 | - } |
|
417 | - |
|
418 | - $isAllDay = $dtstart instanceof Property\ICalendar\Date; |
|
419 | - |
|
420 | - /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */ |
|
421 | - /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */ |
|
422 | - /** @var \DateTimeImmutable $dtstartDt */ |
|
423 | - $dtstartDt = $dtstart->getDateTime(); |
|
424 | - /** @var \DateTimeImmutable $dtendDt */ |
|
425 | - $dtendDt = $dtend->getDateTime(); |
|
426 | - |
|
427 | - $diff = $dtstartDt->diff($dtendDt); |
|
428 | - |
|
429 | - $dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM)); |
|
430 | - $dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM)); |
|
431 | - |
|
432 | - if ($isAllDay) { |
|
433 | - // One day event |
|
434 | - if ($diff->days === 1) { |
|
435 | - return $l10n->l('date', $dtstartDt, ['width' => 'medium']); |
|
436 | - } |
|
437 | - |
|
438 | - // DTEND is exclusive, so if the ics data says 2020-01-01 to 2020-01-05, |
|
439 | - // the email should show 2020-01-01 to 2020-01-04. |
|
440 | - $dtendDt->modify('-1 day'); |
|
441 | - |
|
442 | - //event that spans over multiple days |
|
443 | - $localeStart = $l10n->l('date', $dtstartDt, ['width' => 'medium']); |
|
444 | - $localeEnd = $l10n->l('date', $dtendDt, ['width' => 'medium']); |
|
445 | - |
|
446 | - return $localeStart . ' - ' . $localeEnd; |
|
447 | - } |
|
448 | - |
|
449 | - /** @var Property\ICalendar\DateTime $dtstart */ |
|
450 | - /** @var Property\ICalendar\DateTime $dtend */ |
|
451 | - $isFloating = $dtstart->isFloating(); |
|
452 | - $startTimezone = $endTimezone = null; |
|
453 | - if (!$isFloating) { |
|
454 | - $prop = $dtstart->offsetGet('TZID'); |
|
455 | - if ($prop instanceof Parameter) { |
|
456 | - $startTimezone = $prop->getValue(); |
|
457 | - } |
|
458 | - |
|
459 | - $prop = $dtend->offsetGet('TZID'); |
|
460 | - if ($prop instanceof Parameter) { |
|
461 | - $endTimezone = $prop->getValue(); |
|
462 | - } |
|
463 | - } |
|
464 | - |
|
465 | - $localeStart = $l10n->l('weekdayName', $dtstartDt, ['width' => 'abbreviated']) . ', ' . |
|
466 | - $l10n->l('datetime', $dtstartDt, ['width' => 'medium|short']); |
|
467 | - |
|
468 | - // always show full date with timezone if timezones are different |
|
469 | - if ($startTimezone !== $endTimezone) { |
|
470 | - $localeEnd = $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); |
|
471 | - |
|
472 | - return $localeStart . ' (' . $startTimezone . ') - ' . |
|
473 | - $localeEnd . ' (' . $endTimezone . ')'; |
|
474 | - } |
|
475 | - |
|
476 | - // show only end time if date is the same |
|
477 | - if ($this->isDayEqual($dtstartDt, $dtendDt)) { |
|
478 | - $localeEnd = $l10n->l('time', $dtendDt, ['width' => 'short']); |
|
479 | - } else { |
|
480 | - $localeEnd = $l10n->l('weekdayName', $dtendDt, ['width' => 'abbreviated']) . ', ' . |
|
481 | - $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); |
|
482 | - } |
|
483 | - |
|
484 | - return $localeStart . ' - ' . $localeEnd . ' (' . $startTimezone . ')'; |
|
485 | - } |
|
486 | - |
|
487 | - /** |
|
488 | - * @param \DateTime $dtStart |
|
489 | - * @param \DateTime $dtEnd |
|
490 | - * @return bool |
|
491 | - */ |
|
492 | - private function isDayEqual(\DateTime $dtStart, \DateTime $dtEnd) { |
|
493 | - return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); |
|
494 | - } |
|
495 | - |
|
496 | - /** |
|
497 | - * @param IEMailTemplate $template |
|
498 | - * @param IL10N $l10n |
|
499 | - * @param string $method |
|
500 | - * @param string $summary |
|
501 | - */ |
|
502 | - private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, |
|
503 | - $method, $summary) { |
|
504 | - if ($method === self::METHOD_CANCEL) { |
|
505 | - // TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}" |
|
506 | - $template->setSubject($l10n->t('Cancelled: %1$s', [$summary])); |
|
507 | - $template->addHeading($l10n->t('Invitation canceled')); |
|
508 | - } elseif ($method === self::METHOD_REPLY) { |
|
509 | - // TRANSLATORS Subject for email, when an invitation is updated. Ex: "Re: {{Event Name}}" |
|
510 | - $template->setSubject($l10n->t('Re: %1$s', [$summary])); |
|
511 | - $template->addHeading($l10n->t('Invitation updated')); |
|
512 | - } else { |
|
513 | - // TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}" |
|
514 | - $template->setSubject($l10n->t('Invitation: %1$s', [$summary])); |
|
515 | - $template->addHeading($l10n->t('Invitation')); |
|
516 | - } |
|
517 | - } |
|
518 | - |
|
519 | - /** |
|
520 | - * @param IEMailTemplate $template |
|
521 | - * @param IL10N $l10n |
|
522 | - * @param VEVENT $vevent |
|
523 | - */ |
|
524 | - private function addBulletList(IEMailTemplate $template, IL10N $l10n, $vevent) { |
|
525 | - if ($vevent->SUMMARY) { |
|
526 | - $template->addBodyListItem($vevent->SUMMARY, $l10n->t('Title:'), |
|
527 | - $this->getAbsoluteImagePath('caldav/title.png'), '', '', self::IMIP_INDENT); |
|
528 | - } |
|
529 | - $meetingWhen = $this->generateWhenString($l10n, $vevent); |
|
530 | - if ($meetingWhen) { |
|
531 | - $template->addBodyListItem($meetingWhen, $l10n->t('Time:'), |
|
532 | - $this->getAbsoluteImagePath('caldav/time.png'), '', '', self::IMIP_INDENT); |
|
533 | - } |
|
534 | - if ($vevent->LOCATION) { |
|
535 | - $template->addBodyListItem($vevent->LOCATION, $l10n->t('Location:'), |
|
536 | - $this->getAbsoluteImagePath('caldav/location.png'), '', '', self::IMIP_INDENT); |
|
537 | - } |
|
538 | - if ($vevent->URL) { |
|
539 | - $url = $vevent->URL->getValue(); |
|
540 | - $template->addBodyListItem(sprintf('<a href="%s">%s</a>', |
|
541 | - htmlspecialchars($url), |
|
542 | - htmlspecialchars($url)), |
|
543 | - $l10n->t('Link:'), |
|
544 | - $this->getAbsoluteImagePath('caldav/link.png'), |
|
545 | - $url, '', self::IMIP_INDENT); |
|
546 | - } |
|
547 | - |
|
548 | - $this->addAttendees($template, $l10n, $vevent); |
|
549 | - |
|
550 | - /* Put description last, like an email body, since it can be arbitrarily long */ |
|
551 | - if ($vevent->DESCRIPTION) { |
|
552 | - $template->addBodyListItem($vevent->DESCRIPTION->getValue(), $l10n->t('Description:'), |
|
553 | - $this->getAbsoluteImagePath('caldav/description.png'), '', '', self::IMIP_INDENT); |
|
554 | - } |
|
555 | - } |
|
556 | - |
|
557 | - /** |
|
558 | - * addAttendees: add organizer and attendee names/emails to iMip mail. |
|
559 | - * |
|
560 | - * Enable with DAV setting: invitation_list_attendees (default: no) |
|
561 | - * |
|
562 | - * The default is 'no', which matches old behavior, and is privacy preserving. |
|
563 | - * |
|
564 | - * To enable including attendees in invitation emails: |
|
565 | - * % php occ config:app:set dav invitation_list_attendees --value yes |
|
566 | - * |
|
567 | - * @param IEMailTemplate $template |
|
568 | - * @param IL10N $l10n |
|
569 | - * @param Message $iTipMessage |
|
570 | - * @param int $lastOccurrence |
|
571 | - * @author brad2014 on github.com |
|
572 | - */ |
|
573 | - |
|
574 | - private function addAttendees(IEMailTemplate $template, IL10N $l10n, VEvent $vevent) { |
|
575 | - if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') { |
|
576 | - return; |
|
577 | - } |
|
578 | - |
|
579 | - if (isset($vevent->ORGANIZER)) { |
|
580 | - /** @var Property\ICalendar\CalAddress $organizer */ |
|
581 | - $organizer = $vevent->ORGANIZER; |
|
582 | - $organizerURI = $organizer->getNormalizedValue(); |
|
583 | - [$scheme,$organizerEmail] = explode(':', $organizerURI, 2); # strip off scheme mailto: |
|
584 | - /** @var string|null $organizerName */ |
|
585 | - $organizerName = isset($organizer['CN']) ? $organizer['CN'] : null; |
|
586 | - $organizerHTML = sprintf('<a href="%s">%s</a>', |
|
587 | - htmlspecialchars($organizerURI), |
|
588 | - htmlspecialchars($organizerName ?: $organizerEmail)); |
|
589 | - $organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail); |
|
590 | - if (isset($organizer['PARTSTAT'])) { |
|
591 | - /** @var Parameter $partstat */ |
|
592 | - $partstat = $organizer['PARTSTAT']; |
|
593 | - if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { |
|
594 | - $organizerHTML .= ' ✔︎'; |
|
595 | - $organizerText .= ' ✔︎'; |
|
596 | - } |
|
597 | - } |
|
598 | - $template->addBodyListItem($organizerHTML, $l10n->t('Organizer:'), |
|
599 | - $this->getAbsoluteImagePath('caldav/organizer.png'), |
|
600 | - $organizerText, '', self::IMIP_INDENT); |
|
601 | - } |
|
602 | - |
|
603 | - $attendees = $vevent->select('ATTENDEE'); |
|
604 | - if (count($attendees) === 0) { |
|
605 | - return; |
|
606 | - } |
|
607 | - |
|
608 | - $attendeesHTML = []; |
|
609 | - $attendeesText = []; |
|
610 | - foreach ($attendees as $attendee) { |
|
611 | - $attendeeURI = $attendee->getNormalizedValue(); |
|
612 | - [$scheme,$attendeeEmail] = explode(':', $attendeeURI, 2); # strip off scheme mailto: |
|
613 | - $attendeeName = isset($attendee['CN']) ? $attendee['CN'] : null; |
|
614 | - $attendeeHTML = sprintf('<a href="%s">%s</a>', |
|
615 | - htmlspecialchars($attendeeURI), |
|
616 | - htmlspecialchars($attendeeName ?: $attendeeEmail)); |
|
617 | - $attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail); |
|
618 | - if (isset($attendee['PARTSTAT']) |
|
619 | - && strcasecmp($attendee['PARTSTAT'], 'ACCEPTED') === 0) { |
|
620 | - $attendeeHTML .= ' ✔︎'; |
|
621 | - $attendeeText .= ' ✔︎'; |
|
622 | - } |
|
623 | - array_push($attendeesHTML, $attendeeHTML); |
|
624 | - array_push($attendeesText, $attendeeText); |
|
625 | - } |
|
626 | - |
|
627 | - $template->addBodyListItem(implode('<br/>', $attendeesHTML), $l10n->t('Attendees:'), |
|
628 | - $this->getAbsoluteImagePath('caldav/attendees.png'), |
|
629 | - implode("\n", $attendeesText), '', self::IMIP_INDENT); |
|
630 | - } |
|
631 | - |
|
632 | - /** |
|
633 | - * @param IEMailTemplate $template |
|
634 | - * @param IL10N $l10n |
|
635 | - * @param Message $iTipMessage |
|
636 | - * @param int $lastOccurrence |
|
637 | - */ |
|
638 | - private function addResponseButtons(IEMailTemplate $template, IL10N $l10n, |
|
639 | - Message $iTipMessage, $lastOccurrence) { |
|
640 | - $token = $this->createInvitationToken($iTipMessage, $lastOccurrence); |
|
641 | - |
|
642 | - $template->addBodyButtonGroup( |
|
643 | - $l10n->t('Accept'), |
|
644 | - $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [ |
|
645 | - 'token' => $token, |
|
646 | - ]), |
|
647 | - $l10n->t('Decline'), |
|
648 | - $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [ |
|
649 | - 'token' => $token, |
|
650 | - ]) |
|
651 | - ); |
|
652 | - |
|
653 | - $moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [ |
|
654 | - 'token' => $token, |
|
655 | - ]); |
|
656 | - $html = vsprintf('<small><a href="%s">%s</a></small>', [ |
|
657 | - $moreOptionsURL, $l10n->t('More options …') |
|
658 | - ]); |
|
659 | - $text = $l10n->t('More options at %s', [$moreOptionsURL]); |
|
660 | - |
|
661 | - $template->addBodyText($html, $text); |
|
662 | - } |
|
663 | - |
|
664 | - /** |
|
665 | - * @param string $path |
|
666 | - * @return string |
|
667 | - */ |
|
668 | - private function getAbsoluteImagePath($path) { |
|
669 | - return $this->urlGenerator->getAbsoluteURL( |
|
670 | - $this->urlGenerator->imagePath('core', $path) |
|
671 | - ); |
|
672 | - } |
|
673 | - |
|
674 | - /** |
|
675 | - * @param Message $iTipMessage |
|
676 | - * @param int $lastOccurrence |
|
677 | - * @return string |
|
678 | - */ |
|
679 | - private function createInvitationToken(Message $iTipMessage, $lastOccurrence):string { |
|
680 | - $token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC); |
|
681 | - |
|
682 | - /** @var VEvent $vevent */ |
|
683 | - $vevent = $iTipMessage->message->VEVENT; |
|
684 | - $attendee = $iTipMessage->recipient; |
|
685 | - $organizer = $iTipMessage->sender; |
|
686 | - $sequence = $iTipMessage->sequence; |
|
687 | - $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? |
|
688 | - $vevent->{'RECURRENCE-ID'}->serialize() : null; |
|
689 | - $uid = $vevent->{'UID'}; |
|
690 | - |
|
691 | - $query = $this->db->getQueryBuilder(); |
|
692 | - $query->insert('calendar_invitations') |
|
693 | - ->values([ |
|
694 | - 'token' => $query->createNamedParameter($token), |
|
695 | - 'attendee' => $query->createNamedParameter($attendee), |
|
696 | - 'organizer' => $query->createNamedParameter($organizer), |
|
697 | - 'sequence' => $query->createNamedParameter($sequence), |
|
698 | - 'recurrenceid' => $query->createNamedParameter($recurrenceId), |
|
699 | - 'expiration' => $query->createNamedParameter($lastOccurrence), |
|
700 | - 'uid' => $query->createNamedParameter($uid) |
|
701 | - ]) |
|
702 | - ->execute(); |
|
703 | - |
|
704 | - return $token; |
|
705 | - } |
|
257 | + $recipientDomain = substr(strrchr($recipient, "@"), 1); |
|
258 | + $invitationLinkRecipients = explode(',', preg_replace('/\s+/', '', strtolower($this->config->getAppValue('dav', 'invitation_link_recipients', 'yes')))); |
|
259 | + |
|
260 | + if (strcmp('yes', $invitationLinkRecipients[0]) === 0 |
|
261 | + || in_array(strtolower($recipient), $invitationLinkRecipients) |
|
262 | + || in_array(strtolower($recipientDomain), $invitationLinkRecipients)) { |
|
263 | + $this->addResponseButtons($template, $l10n, $iTipMessage, $lastOccurrence); |
|
264 | + } |
|
265 | + } |
|
266 | + |
|
267 | + $template->addFooter(); |
|
268 | + |
|
269 | + $message->useTemplate($template); |
|
270 | + |
|
271 | + $attachment = $this->mailer->createAttachment( |
|
272 | + $iTipMessage->message->serialize(), |
|
273 | + 'event.ics',// TODO(leon): Make file name unique, e.g. add event id |
|
274 | + 'text/calendar; method=' . $iTipMessage->method |
|
275 | + ); |
|
276 | + $message->attach($attachment); |
|
277 | + |
|
278 | + try { |
|
279 | + $failed = $this->mailer->send($message); |
|
280 | + $iTipMessage->scheduleStatus = '1.1; Scheduling message is sent via iMip'; |
|
281 | + if ($failed) { |
|
282 | + $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]); |
|
283 | + $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; |
|
284 | + } |
|
285 | + } catch (\Exception $ex) { |
|
286 | + $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]); |
|
287 | + $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; |
|
288 | + } |
|
289 | + } |
|
290 | + |
|
291 | + /** |
|
292 | + * check if event took place in the past already |
|
293 | + * @param VCalendar $vObject |
|
294 | + * @return int |
|
295 | + */ |
|
296 | + private function getLastOccurrence(VCalendar $vObject) { |
|
297 | + /** @var VEvent $component */ |
|
298 | + $component = $vObject->VEVENT; |
|
299 | + |
|
300 | + $firstOccurrence = $component->DTSTART->getDateTime()->getTimeStamp(); |
|
301 | + // Finding the last occurrence is a bit harder |
|
302 | + if (!isset($component->RRULE)) { |
|
303 | + if (isset($component->DTEND)) { |
|
304 | + $lastOccurrence = $component->DTEND->getDateTime()->getTimeStamp(); |
|
305 | + } elseif (isset($component->DURATION)) { |
|
306 | + /** @var \DateTime $endDate */ |
|
307 | + $endDate = clone $component->DTSTART->getDateTime(); |
|
308 | + // $component->DTEND->getDateTime() returns DateTimeImmutable |
|
309 | + $endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue())); |
|
310 | + $lastOccurrence = $endDate->getTimestamp(); |
|
311 | + } elseif (!$component->DTSTART->hasTime()) { |
|
312 | + /** @var \DateTime $endDate */ |
|
313 | + $endDate = clone $component->DTSTART->getDateTime(); |
|
314 | + // $component->DTSTART->getDateTime() returns DateTimeImmutable |
|
315 | + $endDate = $endDate->modify('+1 day'); |
|
316 | + $lastOccurrence = $endDate->getTimestamp(); |
|
317 | + } else { |
|
318 | + $lastOccurrence = $firstOccurrence; |
|
319 | + } |
|
320 | + } else { |
|
321 | + $it = new EventIterator($vObject, (string)$component->UID); |
|
322 | + $maxDate = new \DateTime(self::MAX_DATE); |
|
323 | + if ($it->isInfinite()) { |
|
324 | + $lastOccurrence = $maxDate->getTimestamp(); |
|
325 | + } else { |
|
326 | + $end = $it->getDtEnd(); |
|
327 | + while ($it->valid() && $end < $maxDate) { |
|
328 | + $end = $it->getDtEnd(); |
|
329 | + $it->next(); |
|
330 | + } |
|
331 | + $lastOccurrence = $end->getTimestamp(); |
|
332 | + } |
|
333 | + } |
|
334 | + |
|
335 | + return $lastOccurrence; |
|
336 | + } |
|
337 | + |
|
338 | + /** |
|
339 | + * @param Message $iTipMessage |
|
340 | + * @return null|Property |
|
341 | + */ |
|
342 | + private function getCurrentAttendee(Message $iTipMessage) { |
|
343 | + /** @var VEvent $vevent */ |
|
344 | + $vevent = $iTipMessage->message->VEVENT; |
|
345 | + $attendees = $vevent->select('ATTENDEE'); |
|
346 | + foreach ($attendees as $attendee) { |
|
347 | + /** @var Property $attendee */ |
|
348 | + if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) { |
|
349 | + return $attendee; |
|
350 | + } |
|
351 | + } |
|
352 | + return null; |
|
353 | + } |
|
354 | + |
|
355 | + /** |
|
356 | + * @param string $default |
|
357 | + * @param Property|null $attendee |
|
358 | + * @return string |
|
359 | + */ |
|
360 | + private function getAttendeeLangOrDefault($default, Property $attendee = null) { |
|
361 | + if ($attendee !== null) { |
|
362 | + $lang = $attendee->offsetGet('LANGUAGE'); |
|
363 | + if ($lang instanceof Parameter) { |
|
364 | + return $lang->getValue(); |
|
365 | + } |
|
366 | + } |
|
367 | + return $default; |
|
368 | + } |
|
369 | + |
|
370 | + /** |
|
371 | + * @param Property|null $attendee |
|
372 | + * @return bool |
|
373 | + */ |
|
374 | + private function getAttendeeRsvpOrReqForParticipant(Property $attendee = null) { |
|
375 | + if ($attendee !== null) { |
|
376 | + $rsvp = $attendee->offsetGet('RSVP'); |
|
377 | + if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) { |
|
378 | + return true; |
|
379 | + } |
|
380 | + $role = $attendee->offsetGet('ROLE'); |
|
381 | + // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16 |
|
382 | + // Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set |
|
383 | + if ($role === null |
|
384 | + || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0)) |
|
385 | + || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0)) |
|
386 | + ) { |
|
387 | + return true; |
|
388 | + } |
|
389 | + } |
|
390 | + // RFC 5545 3.2.17: default RSVP is false |
|
391 | + return false; |
|
392 | + } |
|
393 | + |
|
394 | + /** |
|
395 | + * @param IL10N $l10n |
|
396 | + * @param VEvent $vevent |
|
397 | + */ |
|
398 | + private function generateWhenString(IL10N $l10n, VEvent $vevent) { |
|
399 | + $dtstart = $vevent->DTSTART; |
|
400 | + if (isset($vevent->DTEND)) { |
|
401 | + $dtend = $vevent->DTEND; |
|
402 | + } elseif (isset($vevent->DURATION)) { |
|
403 | + $isFloating = $vevent->DTSTART->isFloating(); |
|
404 | + $dtend = clone $vevent->DTSTART; |
|
405 | + $endDateTime = $dtend->getDateTime(); |
|
406 | + $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue())); |
|
407 | + $dtend->setDateTime($endDateTime, $isFloating); |
|
408 | + } elseif (!$vevent->DTSTART->hasTime()) { |
|
409 | + $isFloating = $vevent->DTSTART->isFloating(); |
|
410 | + $dtend = clone $vevent->DTSTART; |
|
411 | + $endDateTime = $dtend->getDateTime(); |
|
412 | + $endDateTime = $endDateTime->modify('+1 day'); |
|
413 | + $dtend->setDateTime($endDateTime, $isFloating); |
|
414 | + } else { |
|
415 | + $dtend = clone $vevent->DTSTART; |
|
416 | + } |
|
417 | + |
|
418 | + $isAllDay = $dtstart instanceof Property\ICalendar\Date; |
|
419 | + |
|
420 | + /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */ |
|
421 | + /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */ |
|
422 | + /** @var \DateTimeImmutable $dtstartDt */ |
|
423 | + $dtstartDt = $dtstart->getDateTime(); |
|
424 | + /** @var \DateTimeImmutable $dtendDt */ |
|
425 | + $dtendDt = $dtend->getDateTime(); |
|
426 | + |
|
427 | + $diff = $dtstartDt->diff($dtendDt); |
|
428 | + |
|
429 | + $dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM)); |
|
430 | + $dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM)); |
|
431 | + |
|
432 | + if ($isAllDay) { |
|
433 | + // One day event |
|
434 | + if ($diff->days === 1) { |
|
435 | + return $l10n->l('date', $dtstartDt, ['width' => 'medium']); |
|
436 | + } |
|
437 | + |
|
438 | + // DTEND is exclusive, so if the ics data says 2020-01-01 to 2020-01-05, |
|
439 | + // the email should show 2020-01-01 to 2020-01-04. |
|
440 | + $dtendDt->modify('-1 day'); |
|
441 | + |
|
442 | + //event that spans over multiple days |
|
443 | + $localeStart = $l10n->l('date', $dtstartDt, ['width' => 'medium']); |
|
444 | + $localeEnd = $l10n->l('date', $dtendDt, ['width' => 'medium']); |
|
445 | + |
|
446 | + return $localeStart . ' - ' . $localeEnd; |
|
447 | + } |
|
448 | + |
|
449 | + /** @var Property\ICalendar\DateTime $dtstart */ |
|
450 | + /** @var Property\ICalendar\DateTime $dtend */ |
|
451 | + $isFloating = $dtstart->isFloating(); |
|
452 | + $startTimezone = $endTimezone = null; |
|
453 | + if (!$isFloating) { |
|
454 | + $prop = $dtstart->offsetGet('TZID'); |
|
455 | + if ($prop instanceof Parameter) { |
|
456 | + $startTimezone = $prop->getValue(); |
|
457 | + } |
|
458 | + |
|
459 | + $prop = $dtend->offsetGet('TZID'); |
|
460 | + if ($prop instanceof Parameter) { |
|
461 | + $endTimezone = $prop->getValue(); |
|
462 | + } |
|
463 | + } |
|
464 | + |
|
465 | + $localeStart = $l10n->l('weekdayName', $dtstartDt, ['width' => 'abbreviated']) . ', ' . |
|
466 | + $l10n->l('datetime', $dtstartDt, ['width' => 'medium|short']); |
|
467 | + |
|
468 | + // always show full date with timezone if timezones are different |
|
469 | + if ($startTimezone !== $endTimezone) { |
|
470 | + $localeEnd = $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); |
|
471 | + |
|
472 | + return $localeStart . ' (' . $startTimezone . ') - ' . |
|
473 | + $localeEnd . ' (' . $endTimezone . ')'; |
|
474 | + } |
|
475 | + |
|
476 | + // show only end time if date is the same |
|
477 | + if ($this->isDayEqual($dtstartDt, $dtendDt)) { |
|
478 | + $localeEnd = $l10n->l('time', $dtendDt, ['width' => 'short']); |
|
479 | + } else { |
|
480 | + $localeEnd = $l10n->l('weekdayName', $dtendDt, ['width' => 'abbreviated']) . ', ' . |
|
481 | + $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); |
|
482 | + } |
|
483 | + |
|
484 | + return $localeStart . ' - ' . $localeEnd . ' (' . $startTimezone . ')'; |
|
485 | + } |
|
486 | + |
|
487 | + /** |
|
488 | + * @param \DateTime $dtStart |
|
489 | + * @param \DateTime $dtEnd |
|
490 | + * @return bool |
|
491 | + */ |
|
492 | + private function isDayEqual(\DateTime $dtStart, \DateTime $dtEnd) { |
|
493 | + return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); |
|
494 | + } |
|
495 | + |
|
496 | + /** |
|
497 | + * @param IEMailTemplate $template |
|
498 | + * @param IL10N $l10n |
|
499 | + * @param string $method |
|
500 | + * @param string $summary |
|
501 | + */ |
|
502 | + private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, |
|
503 | + $method, $summary) { |
|
504 | + if ($method === self::METHOD_CANCEL) { |
|
505 | + // TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}" |
|
506 | + $template->setSubject($l10n->t('Cancelled: %1$s', [$summary])); |
|
507 | + $template->addHeading($l10n->t('Invitation canceled')); |
|
508 | + } elseif ($method === self::METHOD_REPLY) { |
|
509 | + // TRANSLATORS Subject for email, when an invitation is updated. Ex: "Re: {{Event Name}}" |
|
510 | + $template->setSubject($l10n->t('Re: %1$s', [$summary])); |
|
511 | + $template->addHeading($l10n->t('Invitation updated')); |
|
512 | + } else { |
|
513 | + // TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}" |
|
514 | + $template->setSubject($l10n->t('Invitation: %1$s', [$summary])); |
|
515 | + $template->addHeading($l10n->t('Invitation')); |
|
516 | + } |
|
517 | + } |
|
518 | + |
|
519 | + /** |
|
520 | + * @param IEMailTemplate $template |
|
521 | + * @param IL10N $l10n |
|
522 | + * @param VEVENT $vevent |
|
523 | + */ |
|
524 | + private function addBulletList(IEMailTemplate $template, IL10N $l10n, $vevent) { |
|
525 | + if ($vevent->SUMMARY) { |
|
526 | + $template->addBodyListItem($vevent->SUMMARY, $l10n->t('Title:'), |
|
527 | + $this->getAbsoluteImagePath('caldav/title.png'), '', '', self::IMIP_INDENT); |
|
528 | + } |
|
529 | + $meetingWhen = $this->generateWhenString($l10n, $vevent); |
|
530 | + if ($meetingWhen) { |
|
531 | + $template->addBodyListItem($meetingWhen, $l10n->t('Time:'), |
|
532 | + $this->getAbsoluteImagePath('caldav/time.png'), '', '', self::IMIP_INDENT); |
|
533 | + } |
|
534 | + if ($vevent->LOCATION) { |
|
535 | + $template->addBodyListItem($vevent->LOCATION, $l10n->t('Location:'), |
|
536 | + $this->getAbsoluteImagePath('caldav/location.png'), '', '', self::IMIP_INDENT); |
|
537 | + } |
|
538 | + if ($vevent->URL) { |
|
539 | + $url = $vevent->URL->getValue(); |
|
540 | + $template->addBodyListItem(sprintf('<a href="%s">%s</a>', |
|
541 | + htmlspecialchars($url), |
|
542 | + htmlspecialchars($url)), |
|
543 | + $l10n->t('Link:'), |
|
544 | + $this->getAbsoluteImagePath('caldav/link.png'), |
|
545 | + $url, '', self::IMIP_INDENT); |
|
546 | + } |
|
547 | + |
|
548 | + $this->addAttendees($template, $l10n, $vevent); |
|
549 | + |
|
550 | + /* Put description last, like an email body, since it can be arbitrarily long */ |
|
551 | + if ($vevent->DESCRIPTION) { |
|
552 | + $template->addBodyListItem($vevent->DESCRIPTION->getValue(), $l10n->t('Description:'), |
|
553 | + $this->getAbsoluteImagePath('caldav/description.png'), '', '', self::IMIP_INDENT); |
|
554 | + } |
|
555 | + } |
|
556 | + |
|
557 | + /** |
|
558 | + * addAttendees: add organizer and attendee names/emails to iMip mail. |
|
559 | + * |
|
560 | + * Enable with DAV setting: invitation_list_attendees (default: no) |
|
561 | + * |
|
562 | + * The default is 'no', which matches old behavior, and is privacy preserving. |
|
563 | + * |
|
564 | + * To enable including attendees in invitation emails: |
|
565 | + * % php occ config:app:set dav invitation_list_attendees --value yes |
|
566 | + * |
|
567 | + * @param IEMailTemplate $template |
|
568 | + * @param IL10N $l10n |
|
569 | + * @param Message $iTipMessage |
|
570 | + * @param int $lastOccurrence |
|
571 | + * @author brad2014 on github.com |
|
572 | + */ |
|
573 | + |
|
574 | + private function addAttendees(IEMailTemplate $template, IL10N $l10n, VEvent $vevent) { |
|
575 | + if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') { |
|
576 | + return; |
|
577 | + } |
|
578 | + |
|
579 | + if (isset($vevent->ORGANIZER)) { |
|
580 | + /** @var Property\ICalendar\CalAddress $organizer */ |
|
581 | + $organizer = $vevent->ORGANIZER; |
|
582 | + $organizerURI = $organizer->getNormalizedValue(); |
|
583 | + [$scheme,$organizerEmail] = explode(':', $organizerURI, 2); # strip off scheme mailto: |
|
584 | + /** @var string|null $organizerName */ |
|
585 | + $organizerName = isset($organizer['CN']) ? $organizer['CN'] : null; |
|
586 | + $organizerHTML = sprintf('<a href="%s">%s</a>', |
|
587 | + htmlspecialchars($organizerURI), |
|
588 | + htmlspecialchars($organizerName ?: $organizerEmail)); |
|
589 | + $organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail); |
|
590 | + if (isset($organizer['PARTSTAT'])) { |
|
591 | + /** @var Parameter $partstat */ |
|
592 | + $partstat = $organizer['PARTSTAT']; |
|
593 | + if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { |
|
594 | + $organizerHTML .= ' ✔︎'; |
|
595 | + $organizerText .= ' ✔︎'; |
|
596 | + } |
|
597 | + } |
|
598 | + $template->addBodyListItem($organizerHTML, $l10n->t('Organizer:'), |
|
599 | + $this->getAbsoluteImagePath('caldav/organizer.png'), |
|
600 | + $organizerText, '', self::IMIP_INDENT); |
|
601 | + } |
|
602 | + |
|
603 | + $attendees = $vevent->select('ATTENDEE'); |
|
604 | + if (count($attendees) === 0) { |
|
605 | + return; |
|
606 | + } |
|
607 | + |
|
608 | + $attendeesHTML = []; |
|
609 | + $attendeesText = []; |
|
610 | + foreach ($attendees as $attendee) { |
|
611 | + $attendeeURI = $attendee->getNormalizedValue(); |
|
612 | + [$scheme,$attendeeEmail] = explode(':', $attendeeURI, 2); # strip off scheme mailto: |
|
613 | + $attendeeName = isset($attendee['CN']) ? $attendee['CN'] : null; |
|
614 | + $attendeeHTML = sprintf('<a href="%s">%s</a>', |
|
615 | + htmlspecialchars($attendeeURI), |
|
616 | + htmlspecialchars($attendeeName ?: $attendeeEmail)); |
|
617 | + $attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail); |
|
618 | + if (isset($attendee['PARTSTAT']) |
|
619 | + && strcasecmp($attendee['PARTSTAT'], 'ACCEPTED') === 0) { |
|
620 | + $attendeeHTML .= ' ✔︎'; |
|
621 | + $attendeeText .= ' ✔︎'; |
|
622 | + } |
|
623 | + array_push($attendeesHTML, $attendeeHTML); |
|
624 | + array_push($attendeesText, $attendeeText); |
|
625 | + } |
|
626 | + |
|
627 | + $template->addBodyListItem(implode('<br/>', $attendeesHTML), $l10n->t('Attendees:'), |
|
628 | + $this->getAbsoluteImagePath('caldav/attendees.png'), |
|
629 | + implode("\n", $attendeesText), '', self::IMIP_INDENT); |
|
630 | + } |
|
631 | + |
|
632 | + /** |
|
633 | + * @param IEMailTemplate $template |
|
634 | + * @param IL10N $l10n |
|
635 | + * @param Message $iTipMessage |
|
636 | + * @param int $lastOccurrence |
|
637 | + */ |
|
638 | + private function addResponseButtons(IEMailTemplate $template, IL10N $l10n, |
|
639 | + Message $iTipMessage, $lastOccurrence) { |
|
640 | + $token = $this->createInvitationToken($iTipMessage, $lastOccurrence); |
|
641 | + |
|
642 | + $template->addBodyButtonGroup( |
|
643 | + $l10n->t('Accept'), |
|
644 | + $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [ |
|
645 | + 'token' => $token, |
|
646 | + ]), |
|
647 | + $l10n->t('Decline'), |
|
648 | + $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [ |
|
649 | + 'token' => $token, |
|
650 | + ]) |
|
651 | + ); |
|
652 | + |
|
653 | + $moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [ |
|
654 | + 'token' => $token, |
|
655 | + ]); |
|
656 | + $html = vsprintf('<small><a href="%s">%s</a></small>', [ |
|
657 | + $moreOptionsURL, $l10n->t('More options …') |
|
658 | + ]); |
|
659 | + $text = $l10n->t('More options at %s', [$moreOptionsURL]); |
|
660 | + |
|
661 | + $template->addBodyText($html, $text); |
|
662 | + } |
|
663 | + |
|
664 | + /** |
|
665 | + * @param string $path |
|
666 | + * @return string |
|
667 | + */ |
|
668 | + private function getAbsoluteImagePath($path) { |
|
669 | + return $this->urlGenerator->getAbsoluteURL( |
|
670 | + $this->urlGenerator->imagePath('core', $path) |
|
671 | + ); |
|
672 | + } |
|
673 | + |
|
674 | + /** |
|
675 | + * @param Message $iTipMessage |
|
676 | + * @param int $lastOccurrence |
|
677 | + * @return string |
|
678 | + */ |
|
679 | + private function createInvitationToken(Message $iTipMessage, $lastOccurrence):string { |
|
680 | + $token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC); |
|
681 | + |
|
682 | + /** @var VEvent $vevent */ |
|
683 | + $vevent = $iTipMessage->message->VEVENT; |
|
684 | + $attendee = $iTipMessage->recipient; |
|
685 | + $organizer = $iTipMessage->sender; |
|
686 | + $sequence = $iTipMessage->sequence; |
|
687 | + $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? |
|
688 | + $vevent->{'RECURRENCE-ID'}->serialize() : null; |
|
689 | + $uid = $vevent->{'UID'}; |
|
690 | + |
|
691 | + $query = $this->db->getQueryBuilder(); |
|
692 | + $query->insert('calendar_invitations') |
|
693 | + ->values([ |
|
694 | + 'token' => $query->createNamedParameter($token), |
|
695 | + 'attendee' => $query->createNamedParameter($attendee), |
|
696 | + 'organizer' => $query->createNamedParameter($organizer), |
|
697 | + 'sequence' => $query->createNamedParameter($sequence), |
|
698 | + 'recurrenceid' => $query->createNamedParameter($recurrenceId), |
|
699 | + 'expiration' => $query->createNamedParameter($lastOccurrence), |
|
700 | + 'uid' => $query->createNamedParameter($uid) |
|
701 | + ]) |
|
702 | + ->execute(); |
|
703 | + |
|
704 | + return $token; |
|
705 | + } |
|
706 | 706 | } |
@@ -210,11 +210,11 @@ discard block |
||
210 | 210 | } |
211 | 211 | |
212 | 212 | $data = [ |
213 | - 'attendee_name' => (string)$meetingAttendeeName ?: $defaultVal, |
|
214 | - 'invitee_name' => (string)$meetingInviteeName ?: $defaultVal, |
|
215 | - 'meeting_title' => (string)$meetingTitle ?: $defaultVal, |
|
216 | - 'meeting_description' => (string)$meetingDescription ?: $defaultVal, |
|
217 | - 'meeting_url' => (string)$meetingUrl ?: $defaultVal, |
|
213 | + 'attendee_name' => (string) $meetingAttendeeName ?: $defaultVal, |
|
214 | + 'invitee_name' => (string) $meetingInviteeName ?: $defaultVal, |
|
215 | + 'meeting_title' => (string) $meetingTitle ?: $defaultVal, |
|
216 | + 'meeting_description' => (string) $meetingDescription ?: $defaultVal, |
|
217 | + 'meeting_url' => (string) $meetingUrl ?: $defaultVal, |
|
218 | 218 | ]; |
219 | 219 | |
220 | 220 | $fromEMail = Util::getDefaultEmailAddress('invitations-noreply'); |
@@ -225,7 +225,7 @@ discard block |
||
225 | 225 | ->setTo([$recipient => $recipientName]) |
226 | 226 | ->setReplyTo([$sender => $senderName]); |
227 | 227 | |
228 | - $template = $this->mailer->createEMailTemplate('dav.calendarInvite.' . $method, $data); |
|
228 | + $template = $this->mailer->createEMailTemplate('dav.calendarInvite.'.$method, $data); |
|
229 | 229 | $template->addHeader(); |
230 | 230 | |
231 | 231 | $summary = ((string) $summary !== '') ? (string) $summary : $l10n->t('Untitled event'); |
@@ -270,8 +270,8 @@ discard block |
||
270 | 270 | |
271 | 271 | $attachment = $this->mailer->createAttachment( |
272 | 272 | $iTipMessage->message->serialize(), |
273 | - 'event.ics',// TODO(leon): Make file name unique, e.g. add event id |
|
274 | - 'text/calendar; method=' . $iTipMessage->method |
|
273 | + 'event.ics', // TODO(leon): Make file name unique, e.g. add event id |
|
274 | + 'text/calendar; method='.$iTipMessage->method |
|
275 | 275 | ); |
276 | 276 | $message->attach($attachment); |
277 | 277 | |
@@ -318,7 +318,7 @@ discard block |
||
318 | 318 | $lastOccurrence = $firstOccurrence; |
319 | 319 | } |
320 | 320 | } else { |
321 | - $it = new EventIterator($vObject, (string)$component->UID); |
|
321 | + $it = new EventIterator($vObject, (string) $component->UID); |
|
322 | 322 | $maxDate = new \DateTime(self::MAX_DATE); |
323 | 323 | if ($it->isInfinite()) { |
324 | 324 | $lastOccurrence = $maxDate->getTimestamp(); |
@@ -443,7 +443,7 @@ discard block |
||
443 | 443 | $localeStart = $l10n->l('date', $dtstartDt, ['width' => 'medium']); |
444 | 444 | $localeEnd = $l10n->l('date', $dtendDt, ['width' => 'medium']); |
445 | 445 | |
446 | - return $localeStart . ' - ' . $localeEnd; |
|
446 | + return $localeStart.' - '.$localeEnd; |
|
447 | 447 | } |
448 | 448 | |
449 | 449 | /** @var Property\ICalendar\DateTime $dtstart */ |
@@ -462,26 +462,26 @@ discard block |
||
462 | 462 | } |
463 | 463 | } |
464 | 464 | |
465 | - $localeStart = $l10n->l('weekdayName', $dtstartDt, ['width' => 'abbreviated']) . ', ' . |
|
465 | + $localeStart = $l10n->l('weekdayName', $dtstartDt, ['width' => 'abbreviated']).', '. |
|
466 | 466 | $l10n->l('datetime', $dtstartDt, ['width' => 'medium|short']); |
467 | 467 | |
468 | 468 | // always show full date with timezone if timezones are different |
469 | 469 | if ($startTimezone !== $endTimezone) { |
470 | 470 | $localeEnd = $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); |
471 | 471 | |
472 | - return $localeStart . ' (' . $startTimezone . ') - ' . |
|
473 | - $localeEnd . ' (' . $endTimezone . ')'; |
|
472 | + return $localeStart.' ('.$startTimezone.') - '. |
|
473 | + $localeEnd.' ('.$endTimezone.')'; |
|
474 | 474 | } |
475 | 475 | |
476 | 476 | // show only end time if date is the same |
477 | 477 | if ($this->isDayEqual($dtstartDt, $dtendDt)) { |
478 | 478 | $localeEnd = $l10n->l('time', $dtendDt, ['width' => 'short']); |
479 | 479 | } else { |
480 | - $localeEnd = $l10n->l('weekdayName', $dtendDt, ['width' => 'abbreviated']) . ', ' . |
|
480 | + $localeEnd = $l10n->l('weekdayName', $dtendDt, ['width' => 'abbreviated']).', '. |
|
481 | 481 | $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); |
482 | 482 | } |
483 | 483 | |
484 | - return $localeStart . ' - ' . $localeEnd . ' (' . $startTimezone . ')'; |
|
484 | + return $localeStart.' - '.$localeEnd.' ('.$startTimezone.')'; |
|
485 | 485 | } |
486 | 486 | |
487 | 487 | /** |
@@ -580,7 +580,7 @@ discard block |
||
580 | 580 | /** @var Property\ICalendar\CalAddress $organizer */ |
581 | 581 | $organizer = $vevent->ORGANIZER; |
582 | 582 | $organizerURI = $organizer->getNormalizedValue(); |
583 | - [$scheme,$organizerEmail] = explode(':', $organizerURI, 2); # strip off scheme mailto: |
|
583 | + [$scheme, $organizerEmail] = explode(':', $organizerURI, 2); # strip off scheme mailto: |
|
584 | 584 | /** @var string|null $organizerName */ |
585 | 585 | $organizerName = isset($organizer['CN']) ? $organizer['CN'] : null; |
586 | 586 | $organizerHTML = sprintf('<a href="%s">%s</a>', |
@@ -609,7 +609,7 @@ discard block |
||
609 | 609 | $attendeesText = []; |
610 | 610 | foreach ($attendees as $attendee) { |
611 | 611 | $attendeeURI = $attendee->getNormalizedValue(); |
612 | - [$scheme,$attendeeEmail] = explode(':', $attendeeURI, 2); # strip off scheme mailto: |
|
612 | + [$scheme, $attendeeEmail] = explode(':', $attendeeURI, 2); # strip off scheme mailto: |
|
613 | 613 | $attendeeName = isset($attendee['CN']) ? $attendee['CN'] : null; |
614 | 614 | $attendeeHTML = sprintf('<a href="%s">%s</a>', |
615 | 615 | htmlspecialchars($attendeeURI), |
@@ -56,701 +56,701 @@ |
||
56 | 56 | * @package OCA\Encryption\Crypto |
57 | 57 | */ |
58 | 58 | class Crypt { |
59 | - public const SUPPORTED_CIPHERS_AND_KEY_SIZE = [ |
|
60 | - 'AES-256-CTR' => 32, |
|
61 | - 'AES-128-CTR' => 16, |
|
62 | - 'AES-256-CFB' => 32, |
|
63 | - 'AES-128-CFB' => 16, |
|
64 | - ]; |
|
65 | - // one out of SUPPORTED_CIPHERS_AND_KEY_SIZE |
|
66 | - public const DEFAULT_CIPHER = 'AES-256-CTR'; |
|
67 | - // default cipher from old Nextcloud versions |
|
68 | - public const LEGACY_CIPHER = 'AES-128-CFB'; |
|
69 | - |
|
70 | - public const SUPPORTED_KEY_FORMATS = ['hash', 'password']; |
|
71 | - // one out of SUPPORTED_KEY_FORMATS |
|
72 | - public const DEFAULT_KEY_FORMAT = 'hash'; |
|
73 | - // default key format, old Nextcloud version encrypted the private key directly |
|
74 | - // with the user password |
|
75 | - public const LEGACY_KEY_FORMAT = 'password'; |
|
76 | - |
|
77 | - public const HEADER_START = 'HBEGIN'; |
|
78 | - public const HEADER_END = 'HEND'; |
|
79 | - |
|
80 | - // default encoding format, old Nextcloud versions used base64 |
|
81 | - public const BINARY_ENCODING_FORMAT = 'binary'; |
|
82 | - |
|
83 | - /** @var ILogger */ |
|
84 | - private $logger; |
|
85 | - |
|
86 | - /** @var string */ |
|
87 | - private $user; |
|
88 | - |
|
89 | - /** @var IConfig */ |
|
90 | - private $config; |
|
91 | - |
|
92 | - /** @var IL10N */ |
|
93 | - private $l; |
|
94 | - |
|
95 | - /** @var string|null */ |
|
96 | - private $currentCipher; |
|
97 | - |
|
98 | - /** @var bool */ |
|
99 | - private $supportLegacy; |
|
100 | - |
|
101 | - /** |
|
102 | - * Use the legacy base64 encoding instead of the more space-efficient binary encoding. |
|
103 | - */ |
|
104 | - private bool $useLegacyBase64Encoding; |
|
105 | - |
|
106 | - /** |
|
107 | - * @param ILogger $logger |
|
108 | - * @param IUserSession $userSession |
|
109 | - * @param IConfig $config |
|
110 | - * @param IL10N $l |
|
111 | - */ |
|
112 | - public function __construct(ILogger $logger, IUserSession $userSession, IConfig $config, IL10N $l) { |
|
113 | - $this->logger = $logger; |
|
114 | - $this->user = $userSession && $userSession->isLoggedIn() ? $userSession->getUser()->getUID() : '"no user given"'; |
|
115 | - $this->config = $config; |
|
116 | - $this->l = $l; |
|
117 | - $this->supportLegacy = $this->config->getSystemValueBool('encryption.legacy_format_support', false); |
|
118 | - $this->useLegacyBase64Encoding = $this->config->getSystemValueBool('encryption.use_legacy_base64_encoding', false); |
|
119 | - } |
|
120 | - |
|
121 | - /** |
|
122 | - * create new private/public key-pair for user |
|
123 | - * |
|
124 | - * @return array|bool |
|
125 | - */ |
|
126 | - public function createKeyPair() { |
|
127 | - $log = $this->logger; |
|
128 | - $res = $this->getOpenSSLPKey(); |
|
129 | - |
|
130 | - if (!$res) { |
|
131 | - $log->error("Encryption Library couldn't generate users key-pair for {$this->user}", |
|
132 | - ['app' => 'encryption']); |
|
133 | - |
|
134 | - if (openssl_error_string()) { |
|
135 | - $log->error('Encryption library openssl_pkey_new() fails: ' . openssl_error_string(), |
|
136 | - ['app' => 'encryption']); |
|
137 | - } |
|
138 | - } elseif (openssl_pkey_export($res, |
|
139 | - $privateKey, |
|
140 | - null, |
|
141 | - $this->getOpenSSLConfig())) { |
|
142 | - $keyDetails = openssl_pkey_get_details($res); |
|
143 | - $publicKey = $keyDetails['key']; |
|
144 | - |
|
145 | - return [ |
|
146 | - 'publicKey' => $publicKey, |
|
147 | - 'privateKey' => $privateKey |
|
148 | - ]; |
|
149 | - } |
|
150 | - $log->error('Encryption library couldn\'t export users private key, please check your servers OpenSSL configuration.' . $this->user, |
|
151 | - ['app' => 'encryption']); |
|
152 | - if (openssl_error_string()) { |
|
153 | - $log->error('Encryption Library:' . openssl_error_string(), |
|
154 | - ['app' => 'encryption']); |
|
155 | - } |
|
156 | - |
|
157 | - return false; |
|
158 | - } |
|
159 | - |
|
160 | - /** |
|
161 | - * Generates a new private key |
|
162 | - * |
|
163 | - * @return \OpenSSLAsymmetricKey|false |
|
164 | - */ |
|
165 | - public function getOpenSSLPKey() { |
|
166 | - $config = $this->getOpenSSLConfig(); |
|
167 | - return openssl_pkey_new($config); |
|
168 | - } |
|
169 | - |
|
170 | - /** |
|
171 | - * get openSSL Config |
|
172 | - * |
|
173 | - * @return array |
|
174 | - */ |
|
175 | - private function getOpenSSLConfig() { |
|
176 | - $config = ['private_key_bits' => 4096]; |
|
177 | - $config = array_merge( |
|
178 | - $config, |
|
179 | - $this->config->getSystemValue('openssl', []) |
|
180 | - ); |
|
181 | - return $config; |
|
182 | - } |
|
183 | - |
|
184 | - /** |
|
185 | - * @param string $plainContent |
|
186 | - * @param string $passPhrase |
|
187 | - * @param int $version |
|
188 | - * @param int $position |
|
189 | - * @return false|string |
|
190 | - * @throws EncryptionFailedException |
|
191 | - */ |
|
192 | - public function symmetricEncryptFileContent($plainContent, $passPhrase, $version, $position) { |
|
193 | - if (!$plainContent) { |
|
194 | - $this->logger->error('Encryption Library, symmetrical encryption failed no content given', |
|
195 | - ['app' => 'encryption']); |
|
196 | - return false; |
|
197 | - } |
|
198 | - |
|
199 | - $iv = $this->generateIv(); |
|
200 | - |
|
201 | - $encryptedContent = $this->encrypt($plainContent, |
|
202 | - $iv, |
|
203 | - $passPhrase, |
|
204 | - $this->getCipher()); |
|
205 | - |
|
206 | - // Create a signature based on the key as well as the current version |
|
207 | - $sig = $this->createSignature($encryptedContent, $passPhrase.'_'.$version.'_'.$position); |
|
208 | - |
|
209 | - // combine content to encrypt the IV identifier and actual IV |
|
210 | - $catFile = $this->concatIV($encryptedContent, $iv); |
|
211 | - $catFile = $this->concatSig($catFile, $sig); |
|
212 | - return $this->addPadding($catFile); |
|
213 | - } |
|
214 | - |
|
215 | - /** |
|
216 | - * generate header for encrypted file |
|
217 | - * |
|
218 | - * @param string $keyFormat see SUPPORTED_KEY_FORMATS |
|
219 | - * @return string |
|
220 | - * @throws \InvalidArgumentException |
|
221 | - */ |
|
222 | - public function generateHeader($keyFormat = self::DEFAULT_KEY_FORMAT) { |
|
223 | - if (in_array($keyFormat, self::SUPPORTED_KEY_FORMATS, true) === false) { |
|
224 | - throw new \InvalidArgumentException('key format "' . $keyFormat . '" is not supported'); |
|
225 | - } |
|
226 | - |
|
227 | - $header = self::HEADER_START |
|
228 | - . ':cipher:' . $this->getCipher() |
|
229 | - . ':keyFormat:' . $keyFormat; |
|
230 | - |
|
231 | - if ($this->useLegacyBase64Encoding !== true) { |
|
232 | - $header .= ':encoding:' . self::BINARY_ENCODING_FORMAT; |
|
233 | - } |
|
234 | - |
|
235 | - $header .= ':' . self::HEADER_END; |
|
236 | - |
|
237 | - return $header; |
|
238 | - } |
|
239 | - |
|
240 | - /** |
|
241 | - * @param string $plainContent |
|
242 | - * @param string $iv |
|
243 | - * @param string $passPhrase |
|
244 | - * @param string $cipher |
|
245 | - * @return string |
|
246 | - * @throws EncryptionFailedException |
|
247 | - */ |
|
248 | - private function encrypt($plainContent, $iv, $passPhrase = '', $cipher = self::DEFAULT_CIPHER) { |
|
249 | - $options = $this->useLegacyBase64Encoding ? 0 : OPENSSL_RAW_DATA; |
|
250 | - $encryptedContent = openssl_encrypt($plainContent, |
|
251 | - $cipher, |
|
252 | - $passPhrase, |
|
253 | - $options, |
|
254 | - $iv); |
|
255 | - |
|
256 | - if (!$encryptedContent) { |
|
257 | - $error = 'Encryption (symmetric) of content failed'; |
|
258 | - $this->logger->error($error . openssl_error_string(), |
|
259 | - ['app' => 'encryption']); |
|
260 | - throw new EncryptionFailedException($error); |
|
261 | - } |
|
262 | - |
|
263 | - return $encryptedContent; |
|
264 | - } |
|
265 | - |
|
266 | - /** |
|
267 | - * return cipher either from config.php or the default cipher defined in |
|
268 | - * this class |
|
269 | - * |
|
270 | - * @return string |
|
271 | - */ |
|
272 | - private function getCachedCipher() { |
|
273 | - if (isset($this->currentCipher)) { |
|
274 | - return $this->currentCipher; |
|
275 | - } |
|
276 | - |
|
277 | - // Get cipher either from config.php or the default cipher defined in this class |
|
278 | - $cipher = $this->config->getSystemValueString('cipher', self::DEFAULT_CIPHER); |
|
279 | - if (!isset(self::SUPPORTED_CIPHERS_AND_KEY_SIZE[$cipher])) { |
|
280 | - $this->logger->warning( |
|
281 | - sprintf( |
|
282 | - 'Unsupported cipher (%s) defined in config.php supported. Falling back to %s', |
|
283 | - $cipher, |
|
284 | - self::DEFAULT_CIPHER |
|
285 | - ), |
|
286 | - ['app' => 'encryption'] |
|
287 | - ); |
|
288 | - $cipher = self::DEFAULT_CIPHER; |
|
289 | - } |
|
290 | - |
|
291 | - // Remember current cipher to avoid frequent lookups |
|
292 | - $this->currentCipher = $cipher; |
|
293 | - return $this->currentCipher; |
|
294 | - } |
|
295 | - |
|
296 | - /** |
|
297 | - * return current encryption cipher |
|
298 | - * |
|
299 | - * @return string |
|
300 | - */ |
|
301 | - public function getCipher() { |
|
302 | - return $this->getCachedCipher(); |
|
303 | - } |
|
304 | - |
|
305 | - /** |
|
306 | - * get key size depending on the cipher |
|
307 | - * |
|
308 | - * @param string $cipher |
|
309 | - * @return int |
|
310 | - * @throws \InvalidArgumentException |
|
311 | - */ |
|
312 | - protected function getKeySize($cipher) { |
|
313 | - if (isset(self::SUPPORTED_CIPHERS_AND_KEY_SIZE[$cipher])) { |
|
314 | - return self::SUPPORTED_CIPHERS_AND_KEY_SIZE[$cipher]; |
|
315 | - } |
|
316 | - |
|
317 | - throw new \InvalidArgumentException( |
|
318 | - sprintf( |
|
319 | - 'Unsupported cipher (%s) defined.', |
|
320 | - $cipher |
|
321 | - ) |
|
322 | - ); |
|
323 | - } |
|
324 | - |
|
325 | - /** |
|
326 | - * get legacy cipher |
|
327 | - * |
|
328 | - * @return string |
|
329 | - */ |
|
330 | - public function getLegacyCipher() { |
|
331 | - if (!$this->supportLegacy) { |
|
332 | - throw new ServerNotAvailableException('Legacy cipher is no longer supported!'); |
|
333 | - } |
|
334 | - |
|
335 | - return self::LEGACY_CIPHER; |
|
336 | - } |
|
337 | - |
|
338 | - /** |
|
339 | - * @param string $encryptedContent |
|
340 | - * @param string $iv |
|
341 | - * @return string |
|
342 | - */ |
|
343 | - private function concatIV($encryptedContent, $iv) { |
|
344 | - return $encryptedContent . '00iv00' . $iv; |
|
345 | - } |
|
346 | - |
|
347 | - /** |
|
348 | - * @param string $encryptedContent |
|
349 | - * @param string $signature |
|
350 | - * @return string |
|
351 | - */ |
|
352 | - private function concatSig($encryptedContent, $signature) { |
|
353 | - return $encryptedContent . '00sig00' . $signature; |
|
354 | - } |
|
355 | - |
|
356 | - /** |
|
357 | - * Note: This is _NOT_ a padding used for encryption purposes. It is solely |
|
358 | - * used to achieve the PHP stream size. It has _NOTHING_ to do with the |
|
359 | - * encrypted content and is not used in any crypto primitive. |
|
360 | - * |
|
361 | - * @param string $data |
|
362 | - * @return string |
|
363 | - */ |
|
364 | - private function addPadding($data) { |
|
365 | - return $data . 'xxx'; |
|
366 | - } |
|
367 | - |
|
368 | - /** |
|
369 | - * generate password hash used to encrypt the users private key |
|
370 | - * |
|
371 | - * @param string $password |
|
372 | - * @param string $cipher |
|
373 | - * @param string $uid only used for user keys |
|
374 | - * @return string |
|
375 | - */ |
|
376 | - protected function generatePasswordHash($password, $cipher, $uid = '') { |
|
377 | - $instanceId = $this->config->getSystemValue('instanceid'); |
|
378 | - $instanceSecret = $this->config->getSystemValue('secret'); |
|
379 | - $salt = hash('sha256', $uid . $instanceId . $instanceSecret, true); |
|
380 | - $keySize = $this->getKeySize($cipher); |
|
381 | - |
|
382 | - $hash = hash_pbkdf2( |
|
383 | - 'sha256', |
|
384 | - $password, |
|
385 | - $salt, |
|
386 | - 100000, |
|
387 | - $keySize, |
|
388 | - true |
|
389 | - ); |
|
390 | - |
|
391 | - return $hash; |
|
392 | - } |
|
393 | - |
|
394 | - /** |
|
395 | - * encrypt private key |
|
396 | - * |
|
397 | - * @param string $privateKey |
|
398 | - * @param string $password |
|
399 | - * @param string $uid for regular users, empty for system keys |
|
400 | - * @return false|string |
|
401 | - */ |
|
402 | - public function encryptPrivateKey($privateKey, $password, $uid = '') { |
|
403 | - $cipher = $this->getCipher(); |
|
404 | - $hash = $this->generatePasswordHash($password, $cipher, $uid); |
|
405 | - $encryptedKey = $this->symmetricEncryptFileContent( |
|
406 | - $privateKey, |
|
407 | - $hash, |
|
408 | - 0, |
|
409 | - 0 |
|
410 | - ); |
|
411 | - |
|
412 | - return $encryptedKey; |
|
413 | - } |
|
414 | - |
|
415 | - /** |
|
416 | - * @param string $privateKey |
|
417 | - * @param string $password |
|
418 | - * @param string $uid for regular users, empty for system keys |
|
419 | - * @return false|string |
|
420 | - */ |
|
421 | - public function decryptPrivateKey($privateKey, $password = '', $uid = '') { |
|
422 | - $header = $this->parseHeader($privateKey); |
|
423 | - |
|
424 | - if (isset($header['cipher'])) { |
|
425 | - $cipher = $header['cipher']; |
|
426 | - } else { |
|
427 | - $cipher = $this->getLegacyCipher(); |
|
428 | - } |
|
429 | - |
|
430 | - if (isset($header['keyFormat'])) { |
|
431 | - $keyFormat = $header['keyFormat']; |
|
432 | - } else { |
|
433 | - $keyFormat = self::LEGACY_KEY_FORMAT; |
|
434 | - } |
|
435 | - |
|
436 | - if ($keyFormat === self::DEFAULT_KEY_FORMAT) { |
|
437 | - $password = $this->generatePasswordHash($password, $cipher, $uid); |
|
438 | - } |
|
439 | - |
|
440 | - $binaryEncoding = isset($header['encoding']) && $header['encoding'] === self::BINARY_ENCODING_FORMAT; |
|
441 | - |
|
442 | - // If we found a header we need to remove it from the key we want to decrypt |
|
443 | - if (!empty($header)) { |
|
444 | - $privateKey = substr($privateKey, |
|
445 | - strpos($privateKey, |
|
446 | - self::HEADER_END) + strlen(self::HEADER_END)); |
|
447 | - } |
|
448 | - |
|
449 | - $plainKey = $this->symmetricDecryptFileContent( |
|
450 | - $privateKey, |
|
451 | - $password, |
|
452 | - $cipher, |
|
453 | - 0, |
|
454 | - 0, |
|
455 | - $binaryEncoding |
|
456 | - ); |
|
457 | - |
|
458 | - if ($this->isValidPrivateKey($plainKey) === false) { |
|
459 | - return false; |
|
460 | - } |
|
461 | - |
|
462 | - return $plainKey; |
|
463 | - } |
|
464 | - |
|
465 | - /** |
|
466 | - * check if it is a valid private key |
|
467 | - * |
|
468 | - * @param string $plainKey |
|
469 | - * @return bool |
|
470 | - */ |
|
471 | - protected function isValidPrivateKey($plainKey) { |
|
472 | - $res = openssl_get_privatekey($plainKey); |
|
473 | - // TODO: remove resource check one php7.4 is not longer supported |
|
474 | - if (is_resource($res) || (is_object($res) && get_class($res) === 'OpenSSLAsymmetricKey')) { |
|
475 | - $sslInfo = openssl_pkey_get_details($res); |
|
476 | - if (isset($sslInfo['key'])) { |
|
477 | - return true; |
|
478 | - } |
|
479 | - } |
|
480 | - |
|
481 | - return false; |
|
482 | - } |
|
483 | - |
|
484 | - /** |
|
485 | - * @param string $keyFileContents |
|
486 | - * @param string $passPhrase |
|
487 | - * @param string $cipher |
|
488 | - * @param int $version |
|
489 | - * @param int|string $position |
|
490 | - * @param boolean $binaryEncoding |
|
491 | - * @return string |
|
492 | - * @throws DecryptionFailedException |
|
493 | - */ |
|
494 | - public function symmetricDecryptFileContent($keyFileContents, $passPhrase, $cipher = self::DEFAULT_CIPHER, $version = 0, $position = 0, bool $binaryEncoding = false) { |
|
495 | - if ($keyFileContents == '') { |
|
496 | - return ''; |
|
497 | - } |
|
498 | - |
|
499 | - $catFile = $this->splitMetaData($keyFileContents, $cipher); |
|
500 | - |
|
501 | - if ($catFile['signature'] !== false) { |
|
502 | - try { |
|
503 | - // First try the new format |
|
504 | - $this->checkSignature($catFile['encrypted'], $passPhrase . '_' . $version . '_' . $position, $catFile['signature']); |
|
505 | - } catch (GenericEncryptionException $e) { |
|
506 | - // For compatibility with old files check the version without _ |
|
507 | - $this->checkSignature($catFile['encrypted'], $passPhrase . $version . $position, $catFile['signature']); |
|
508 | - } |
|
509 | - } |
|
510 | - |
|
511 | - return $this->decrypt($catFile['encrypted'], |
|
512 | - $catFile['iv'], |
|
513 | - $passPhrase, |
|
514 | - $cipher, |
|
515 | - $binaryEncoding); |
|
516 | - } |
|
517 | - |
|
518 | - /** |
|
519 | - * check for valid signature |
|
520 | - * |
|
521 | - * @param string $data |
|
522 | - * @param string $passPhrase |
|
523 | - * @param string $expectedSignature |
|
524 | - * @throws GenericEncryptionException |
|
525 | - */ |
|
526 | - private function checkSignature($data, $passPhrase, $expectedSignature) { |
|
527 | - $enforceSignature = !$this->config->getSystemValueBool('encryption_skip_signature_check', false); |
|
528 | - |
|
529 | - $signature = $this->createSignature($data, $passPhrase); |
|
530 | - $isCorrectHash = hash_equals($expectedSignature, $signature); |
|
531 | - |
|
532 | - if (!$isCorrectHash && $enforceSignature) { |
|
533 | - throw new GenericEncryptionException('Bad Signature', $this->l->t('Bad Signature')); |
|
534 | - } elseif (!$isCorrectHash && !$enforceSignature) { |
|
535 | - $this->logger->info("Signature check skipped", ['app' => 'encryption']); |
|
536 | - } |
|
537 | - } |
|
538 | - |
|
539 | - /** |
|
540 | - * create signature |
|
541 | - * |
|
542 | - * @param string $data |
|
543 | - * @param string $passPhrase |
|
544 | - * @return string |
|
545 | - */ |
|
546 | - private function createSignature($data, $passPhrase) { |
|
547 | - $passPhrase = hash('sha512', $passPhrase . 'a', true); |
|
548 | - return hash_hmac('sha256', $data, $passPhrase); |
|
549 | - } |
|
550 | - |
|
551 | - |
|
552 | - /** |
|
553 | - * remove padding |
|
554 | - * |
|
555 | - * @param string $padded |
|
556 | - * @param bool $hasSignature did the block contain a signature, in this case we use a different padding |
|
557 | - * @return string|false |
|
558 | - */ |
|
559 | - private function removePadding($padded, $hasSignature = false) { |
|
560 | - if ($hasSignature === false && substr($padded, -2) === 'xx') { |
|
561 | - return substr($padded, 0, -2); |
|
562 | - } elseif ($hasSignature === true && substr($padded, -3) === 'xxx') { |
|
563 | - return substr($padded, 0, -3); |
|
564 | - } |
|
565 | - return false; |
|
566 | - } |
|
567 | - |
|
568 | - /** |
|
569 | - * split meta data from encrypted file |
|
570 | - * Note: for now, we assume that the meta data always start with the iv |
|
571 | - * followed by the signature, if available |
|
572 | - * |
|
573 | - * @param string $catFile |
|
574 | - * @param string $cipher |
|
575 | - * @return array |
|
576 | - */ |
|
577 | - private function splitMetaData($catFile, $cipher) { |
|
578 | - if ($this->hasSignature($catFile, $cipher)) { |
|
579 | - $catFile = $this->removePadding($catFile, true); |
|
580 | - $meta = substr($catFile, -93); |
|
581 | - $iv = substr($meta, strlen('00iv00'), 16); |
|
582 | - $sig = substr($meta, 22 + strlen('00sig00')); |
|
583 | - $encrypted = substr($catFile, 0, -93); |
|
584 | - } else { |
|
585 | - $catFile = $this->removePadding($catFile); |
|
586 | - $meta = substr($catFile, -22); |
|
587 | - $iv = substr($meta, -16); |
|
588 | - $sig = false; |
|
589 | - $encrypted = substr($catFile, 0, -22); |
|
590 | - } |
|
591 | - |
|
592 | - return [ |
|
593 | - 'encrypted' => $encrypted, |
|
594 | - 'iv' => $iv, |
|
595 | - 'signature' => $sig |
|
596 | - ]; |
|
597 | - } |
|
598 | - |
|
599 | - /** |
|
600 | - * check if encrypted block is signed |
|
601 | - * |
|
602 | - * @param string $catFile |
|
603 | - * @param string $cipher |
|
604 | - * @return bool |
|
605 | - * @throws GenericEncryptionException |
|
606 | - */ |
|
607 | - private function hasSignature($catFile, $cipher) { |
|
608 | - $skipSignatureCheck = $this->config->getSystemValueBool('encryption_skip_signature_check', false); |
|
609 | - |
|
610 | - $meta = substr($catFile, -93); |
|
611 | - $signaturePosition = strpos($meta, '00sig00'); |
|
612 | - |
|
613 | - // If we no longer support the legacy format then everything needs a signature |
|
614 | - if (!$skipSignatureCheck && !$this->supportLegacy && $signaturePosition === false) { |
|
615 | - throw new GenericEncryptionException('Missing Signature', $this->l->t('Missing Signature')); |
|
616 | - } |
|
617 | - |
|
618 | - // Enforce signature for the new 'CTR' ciphers |
|
619 | - if (!$skipSignatureCheck && $signaturePosition === false && stripos($cipher, 'ctr') !== false) { |
|
620 | - throw new GenericEncryptionException('Missing Signature', $this->l->t('Missing Signature')); |
|
621 | - } |
|
622 | - |
|
623 | - return ($signaturePosition !== false); |
|
624 | - } |
|
625 | - |
|
626 | - |
|
627 | - /** |
|
628 | - * @param string $encryptedContent |
|
629 | - * @param string $iv |
|
630 | - * @param string $passPhrase |
|
631 | - * @param string $cipher |
|
632 | - * @param boolean $binaryEncoding |
|
633 | - * @return string |
|
634 | - * @throws DecryptionFailedException |
|
635 | - */ |
|
636 | - private function decrypt(string $encryptedContent, string $iv, string $passPhrase = '', string $cipher = self::DEFAULT_CIPHER, bool $binaryEncoding = false): string { |
|
637 | - $options = $binaryEncoding === true ? OPENSSL_RAW_DATA : 0; |
|
638 | - $plainContent = openssl_decrypt($encryptedContent, |
|
639 | - $cipher, |
|
640 | - $passPhrase, |
|
641 | - $options, |
|
642 | - $iv); |
|
643 | - |
|
644 | - if ($plainContent) { |
|
645 | - return $plainContent; |
|
646 | - } else { |
|
647 | - throw new DecryptionFailedException('Encryption library: Decryption (symmetric) of content failed: ' . openssl_error_string()); |
|
648 | - } |
|
649 | - } |
|
650 | - |
|
651 | - /** |
|
652 | - * @param string $data |
|
653 | - * @return array |
|
654 | - */ |
|
655 | - protected function parseHeader($data) { |
|
656 | - $result = []; |
|
657 | - |
|
658 | - if (substr($data, 0, strlen(self::HEADER_START)) === self::HEADER_START) { |
|
659 | - $endAt = strpos($data, self::HEADER_END); |
|
660 | - $header = substr($data, 0, $endAt + strlen(self::HEADER_END)); |
|
661 | - |
|
662 | - // +1 not to start with an ':' which would result in empty element at the beginning |
|
663 | - $exploded = explode(':', |
|
664 | - substr($header, strlen(self::HEADER_START) + 1)); |
|
665 | - |
|
666 | - $element = array_shift($exploded); |
|
667 | - |
|
668 | - while ($element !== self::HEADER_END) { |
|
669 | - $result[$element] = array_shift($exploded); |
|
670 | - $element = array_shift($exploded); |
|
671 | - } |
|
672 | - } |
|
673 | - |
|
674 | - return $result; |
|
675 | - } |
|
676 | - |
|
677 | - /** |
|
678 | - * generate initialization vector |
|
679 | - * |
|
680 | - * @return string |
|
681 | - * @throws GenericEncryptionException |
|
682 | - */ |
|
683 | - private function generateIv() { |
|
684 | - return random_bytes(16); |
|
685 | - } |
|
686 | - |
|
687 | - /** |
|
688 | - * Generate a cryptographically secure pseudo-random 256-bit ASCII key, used |
|
689 | - * as file key |
|
690 | - * |
|
691 | - * @return string |
|
692 | - * @throws \Exception |
|
693 | - */ |
|
694 | - public function generateFileKey() { |
|
695 | - return random_bytes(32); |
|
696 | - } |
|
697 | - |
|
698 | - /** |
|
699 | - * @param $encKeyFile |
|
700 | - * @param $shareKey |
|
701 | - * @param $privateKey |
|
702 | - * @return string |
|
703 | - * @throws MultiKeyDecryptException |
|
704 | - */ |
|
705 | - public function multiKeyDecrypt($encKeyFile, $shareKey, $privateKey) { |
|
706 | - if (!$encKeyFile) { |
|
707 | - throw new MultiKeyDecryptException('Cannot multikey decrypt empty plain content'); |
|
708 | - } |
|
709 | - |
|
710 | - if (openssl_open($encKeyFile, $plainContent, $shareKey, $privateKey, 'RC4')) { |
|
711 | - return $plainContent; |
|
712 | - } else { |
|
713 | - throw new MultiKeyDecryptException('multikeydecrypt with share key failed:' . openssl_error_string()); |
|
714 | - } |
|
715 | - } |
|
716 | - |
|
717 | - /** |
|
718 | - * @param string $plainContent |
|
719 | - * @param array $keyFiles |
|
720 | - * @return array |
|
721 | - * @throws MultiKeyEncryptException |
|
722 | - */ |
|
723 | - public function multiKeyEncrypt($plainContent, array $keyFiles) { |
|
724 | - // openssl_seal returns false without errors if plaincontent is empty |
|
725 | - // so trigger our own error |
|
726 | - if (empty($plainContent)) { |
|
727 | - throw new MultiKeyEncryptException('Cannot multikeyencrypt empty plain content'); |
|
728 | - } |
|
729 | - |
|
730 | - // Set empty vars to be set by openssl by reference |
|
731 | - $sealed = ''; |
|
732 | - $shareKeys = []; |
|
733 | - $mappedShareKeys = []; |
|
734 | - |
|
735 | - if (openssl_seal($plainContent, $sealed, $shareKeys, $keyFiles, 'RC4')) { |
|
736 | - $i = 0; |
|
737 | - |
|
738 | - // Ensure each shareKey is labelled with its corresponding key id |
|
739 | - foreach ($keyFiles as $userId => $publicKey) { |
|
740 | - $mappedShareKeys[$userId] = $shareKeys[$i]; |
|
741 | - $i++; |
|
742 | - } |
|
743 | - |
|
744 | - return [ |
|
745 | - 'keys' => $mappedShareKeys, |
|
746 | - 'data' => $sealed |
|
747 | - ]; |
|
748 | - } else { |
|
749 | - throw new MultiKeyEncryptException('multikeyencryption failed ' . openssl_error_string()); |
|
750 | - } |
|
751 | - } |
|
752 | - |
|
753 | - public function useLegacyBase64Encoding(): bool { |
|
754 | - return $this->useLegacyBase64Encoding; |
|
755 | - } |
|
59 | + public const SUPPORTED_CIPHERS_AND_KEY_SIZE = [ |
|
60 | + 'AES-256-CTR' => 32, |
|
61 | + 'AES-128-CTR' => 16, |
|
62 | + 'AES-256-CFB' => 32, |
|
63 | + 'AES-128-CFB' => 16, |
|
64 | + ]; |
|
65 | + // one out of SUPPORTED_CIPHERS_AND_KEY_SIZE |
|
66 | + public const DEFAULT_CIPHER = 'AES-256-CTR'; |
|
67 | + // default cipher from old Nextcloud versions |
|
68 | + public const LEGACY_CIPHER = 'AES-128-CFB'; |
|
69 | + |
|
70 | + public const SUPPORTED_KEY_FORMATS = ['hash', 'password']; |
|
71 | + // one out of SUPPORTED_KEY_FORMATS |
|
72 | + public const DEFAULT_KEY_FORMAT = 'hash'; |
|
73 | + // default key format, old Nextcloud version encrypted the private key directly |
|
74 | + // with the user password |
|
75 | + public const LEGACY_KEY_FORMAT = 'password'; |
|
76 | + |
|
77 | + public const HEADER_START = 'HBEGIN'; |
|
78 | + public const HEADER_END = 'HEND'; |
|
79 | + |
|
80 | + // default encoding format, old Nextcloud versions used base64 |
|
81 | + public const BINARY_ENCODING_FORMAT = 'binary'; |
|
82 | + |
|
83 | + /** @var ILogger */ |
|
84 | + private $logger; |
|
85 | + |
|
86 | + /** @var string */ |
|
87 | + private $user; |
|
88 | + |
|
89 | + /** @var IConfig */ |
|
90 | + private $config; |
|
91 | + |
|
92 | + /** @var IL10N */ |
|
93 | + private $l; |
|
94 | + |
|
95 | + /** @var string|null */ |
|
96 | + private $currentCipher; |
|
97 | + |
|
98 | + /** @var bool */ |
|
99 | + private $supportLegacy; |
|
100 | + |
|
101 | + /** |
|
102 | + * Use the legacy base64 encoding instead of the more space-efficient binary encoding. |
|
103 | + */ |
|
104 | + private bool $useLegacyBase64Encoding; |
|
105 | + |
|
106 | + /** |
|
107 | + * @param ILogger $logger |
|
108 | + * @param IUserSession $userSession |
|
109 | + * @param IConfig $config |
|
110 | + * @param IL10N $l |
|
111 | + */ |
|
112 | + public function __construct(ILogger $logger, IUserSession $userSession, IConfig $config, IL10N $l) { |
|
113 | + $this->logger = $logger; |
|
114 | + $this->user = $userSession && $userSession->isLoggedIn() ? $userSession->getUser()->getUID() : '"no user given"'; |
|
115 | + $this->config = $config; |
|
116 | + $this->l = $l; |
|
117 | + $this->supportLegacy = $this->config->getSystemValueBool('encryption.legacy_format_support', false); |
|
118 | + $this->useLegacyBase64Encoding = $this->config->getSystemValueBool('encryption.use_legacy_base64_encoding', false); |
|
119 | + } |
|
120 | + |
|
121 | + /** |
|
122 | + * create new private/public key-pair for user |
|
123 | + * |
|
124 | + * @return array|bool |
|
125 | + */ |
|
126 | + public function createKeyPair() { |
|
127 | + $log = $this->logger; |
|
128 | + $res = $this->getOpenSSLPKey(); |
|
129 | + |
|
130 | + if (!$res) { |
|
131 | + $log->error("Encryption Library couldn't generate users key-pair for {$this->user}", |
|
132 | + ['app' => 'encryption']); |
|
133 | + |
|
134 | + if (openssl_error_string()) { |
|
135 | + $log->error('Encryption library openssl_pkey_new() fails: ' . openssl_error_string(), |
|
136 | + ['app' => 'encryption']); |
|
137 | + } |
|
138 | + } elseif (openssl_pkey_export($res, |
|
139 | + $privateKey, |
|
140 | + null, |
|
141 | + $this->getOpenSSLConfig())) { |
|
142 | + $keyDetails = openssl_pkey_get_details($res); |
|
143 | + $publicKey = $keyDetails['key']; |
|
144 | + |
|
145 | + return [ |
|
146 | + 'publicKey' => $publicKey, |
|
147 | + 'privateKey' => $privateKey |
|
148 | + ]; |
|
149 | + } |
|
150 | + $log->error('Encryption library couldn\'t export users private key, please check your servers OpenSSL configuration.' . $this->user, |
|
151 | + ['app' => 'encryption']); |
|
152 | + if (openssl_error_string()) { |
|
153 | + $log->error('Encryption Library:' . openssl_error_string(), |
|
154 | + ['app' => 'encryption']); |
|
155 | + } |
|
156 | + |
|
157 | + return false; |
|
158 | + } |
|
159 | + |
|
160 | + /** |
|
161 | + * Generates a new private key |
|
162 | + * |
|
163 | + * @return \OpenSSLAsymmetricKey|false |
|
164 | + */ |
|
165 | + public function getOpenSSLPKey() { |
|
166 | + $config = $this->getOpenSSLConfig(); |
|
167 | + return openssl_pkey_new($config); |
|
168 | + } |
|
169 | + |
|
170 | + /** |
|
171 | + * get openSSL Config |
|
172 | + * |
|
173 | + * @return array |
|
174 | + */ |
|
175 | + private function getOpenSSLConfig() { |
|
176 | + $config = ['private_key_bits' => 4096]; |
|
177 | + $config = array_merge( |
|
178 | + $config, |
|
179 | + $this->config->getSystemValue('openssl', []) |
|
180 | + ); |
|
181 | + return $config; |
|
182 | + } |
|
183 | + |
|
184 | + /** |
|
185 | + * @param string $plainContent |
|
186 | + * @param string $passPhrase |
|
187 | + * @param int $version |
|
188 | + * @param int $position |
|
189 | + * @return false|string |
|
190 | + * @throws EncryptionFailedException |
|
191 | + */ |
|
192 | + public function symmetricEncryptFileContent($plainContent, $passPhrase, $version, $position) { |
|
193 | + if (!$plainContent) { |
|
194 | + $this->logger->error('Encryption Library, symmetrical encryption failed no content given', |
|
195 | + ['app' => 'encryption']); |
|
196 | + return false; |
|
197 | + } |
|
198 | + |
|
199 | + $iv = $this->generateIv(); |
|
200 | + |
|
201 | + $encryptedContent = $this->encrypt($plainContent, |
|
202 | + $iv, |
|
203 | + $passPhrase, |
|
204 | + $this->getCipher()); |
|
205 | + |
|
206 | + // Create a signature based on the key as well as the current version |
|
207 | + $sig = $this->createSignature($encryptedContent, $passPhrase.'_'.$version.'_'.$position); |
|
208 | + |
|
209 | + // combine content to encrypt the IV identifier and actual IV |
|
210 | + $catFile = $this->concatIV($encryptedContent, $iv); |
|
211 | + $catFile = $this->concatSig($catFile, $sig); |
|
212 | + return $this->addPadding($catFile); |
|
213 | + } |
|
214 | + |
|
215 | + /** |
|
216 | + * generate header for encrypted file |
|
217 | + * |
|
218 | + * @param string $keyFormat see SUPPORTED_KEY_FORMATS |
|
219 | + * @return string |
|
220 | + * @throws \InvalidArgumentException |
|
221 | + */ |
|
222 | + public function generateHeader($keyFormat = self::DEFAULT_KEY_FORMAT) { |
|
223 | + if (in_array($keyFormat, self::SUPPORTED_KEY_FORMATS, true) === false) { |
|
224 | + throw new \InvalidArgumentException('key format "' . $keyFormat . '" is not supported'); |
|
225 | + } |
|
226 | + |
|
227 | + $header = self::HEADER_START |
|
228 | + . ':cipher:' . $this->getCipher() |
|
229 | + . ':keyFormat:' . $keyFormat; |
|
230 | + |
|
231 | + if ($this->useLegacyBase64Encoding !== true) { |
|
232 | + $header .= ':encoding:' . self::BINARY_ENCODING_FORMAT; |
|
233 | + } |
|
234 | + |
|
235 | + $header .= ':' . self::HEADER_END; |
|
236 | + |
|
237 | + return $header; |
|
238 | + } |
|
239 | + |
|
240 | + /** |
|
241 | + * @param string $plainContent |
|
242 | + * @param string $iv |
|
243 | + * @param string $passPhrase |
|
244 | + * @param string $cipher |
|
245 | + * @return string |
|
246 | + * @throws EncryptionFailedException |
|
247 | + */ |
|
248 | + private function encrypt($plainContent, $iv, $passPhrase = '', $cipher = self::DEFAULT_CIPHER) { |
|
249 | + $options = $this->useLegacyBase64Encoding ? 0 : OPENSSL_RAW_DATA; |
|
250 | + $encryptedContent = openssl_encrypt($plainContent, |
|
251 | + $cipher, |
|
252 | + $passPhrase, |
|
253 | + $options, |
|
254 | + $iv); |
|
255 | + |
|
256 | + if (!$encryptedContent) { |
|
257 | + $error = 'Encryption (symmetric) of content failed'; |
|
258 | + $this->logger->error($error . openssl_error_string(), |
|
259 | + ['app' => 'encryption']); |
|
260 | + throw new EncryptionFailedException($error); |
|
261 | + } |
|
262 | + |
|
263 | + return $encryptedContent; |
|
264 | + } |
|
265 | + |
|
266 | + /** |
|
267 | + * return cipher either from config.php or the default cipher defined in |
|
268 | + * this class |
|
269 | + * |
|
270 | + * @return string |
|
271 | + */ |
|
272 | + private function getCachedCipher() { |
|
273 | + if (isset($this->currentCipher)) { |
|
274 | + return $this->currentCipher; |
|
275 | + } |
|
276 | + |
|
277 | + // Get cipher either from config.php or the default cipher defined in this class |
|
278 | + $cipher = $this->config->getSystemValueString('cipher', self::DEFAULT_CIPHER); |
|
279 | + if (!isset(self::SUPPORTED_CIPHERS_AND_KEY_SIZE[$cipher])) { |
|
280 | + $this->logger->warning( |
|
281 | + sprintf( |
|
282 | + 'Unsupported cipher (%s) defined in config.php supported. Falling back to %s', |
|
283 | + $cipher, |
|
284 | + self::DEFAULT_CIPHER |
|
285 | + ), |
|
286 | + ['app' => 'encryption'] |
|
287 | + ); |
|
288 | + $cipher = self::DEFAULT_CIPHER; |
|
289 | + } |
|
290 | + |
|
291 | + // Remember current cipher to avoid frequent lookups |
|
292 | + $this->currentCipher = $cipher; |
|
293 | + return $this->currentCipher; |
|
294 | + } |
|
295 | + |
|
296 | + /** |
|
297 | + * return current encryption cipher |
|
298 | + * |
|
299 | + * @return string |
|
300 | + */ |
|
301 | + public function getCipher() { |
|
302 | + return $this->getCachedCipher(); |
|
303 | + } |
|
304 | + |
|
305 | + /** |
|
306 | + * get key size depending on the cipher |
|
307 | + * |
|
308 | + * @param string $cipher |
|
309 | + * @return int |
|
310 | + * @throws \InvalidArgumentException |
|
311 | + */ |
|
312 | + protected function getKeySize($cipher) { |
|
313 | + if (isset(self::SUPPORTED_CIPHERS_AND_KEY_SIZE[$cipher])) { |
|
314 | + return self::SUPPORTED_CIPHERS_AND_KEY_SIZE[$cipher]; |
|
315 | + } |
|
316 | + |
|
317 | + throw new \InvalidArgumentException( |
|
318 | + sprintf( |
|
319 | + 'Unsupported cipher (%s) defined.', |
|
320 | + $cipher |
|
321 | + ) |
|
322 | + ); |
|
323 | + } |
|
324 | + |
|
325 | + /** |
|
326 | + * get legacy cipher |
|
327 | + * |
|
328 | + * @return string |
|
329 | + */ |
|
330 | + public function getLegacyCipher() { |
|
331 | + if (!$this->supportLegacy) { |
|
332 | + throw new ServerNotAvailableException('Legacy cipher is no longer supported!'); |
|
333 | + } |
|
334 | + |
|
335 | + return self::LEGACY_CIPHER; |
|
336 | + } |
|
337 | + |
|
338 | + /** |
|
339 | + * @param string $encryptedContent |
|
340 | + * @param string $iv |
|
341 | + * @return string |
|
342 | + */ |
|
343 | + private function concatIV($encryptedContent, $iv) { |
|
344 | + return $encryptedContent . '00iv00' . $iv; |
|
345 | + } |
|
346 | + |
|
347 | + /** |
|
348 | + * @param string $encryptedContent |
|
349 | + * @param string $signature |
|
350 | + * @return string |
|
351 | + */ |
|
352 | + private function concatSig($encryptedContent, $signature) { |
|
353 | + return $encryptedContent . '00sig00' . $signature; |
|
354 | + } |
|
355 | + |
|
356 | + /** |
|
357 | + * Note: This is _NOT_ a padding used for encryption purposes. It is solely |
|
358 | + * used to achieve the PHP stream size. It has _NOTHING_ to do with the |
|
359 | + * encrypted content and is not used in any crypto primitive. |
|
360 | + * |
|
361 | + * @param string $data |
|
362 | + * @return string |
|
363 | + */ |
|
364 | + private function addPadding($data) { |
|
365 | + return $data . 'xxx'; |
|
366 | + } |
|
367 | + |
|
368 | + /** |
|
369 | + * generate password hash used to encrypt the users private key |
|
370 | + * |
|
371 | + * @param string $password |
|
372 | + * @param string $cipher |
|
373 | + * @param string $uid only used for user keys |
|
374 | + * @return string |
|
375 | + */ |
|
376 | + protected function generatePasswordHash($password, $cipher, $uid = '') { |
|
377 | + $instanceId = $this->config->getSystemValue('instanceid'); |
|
378 | + $instanceSecret = $this->config->getSystemValue('secret'); |
|
379 | + $salt = hash('sha256', $uid . $instanceId . $instanceSecret, true); |
|
380 | + $keySize = $this->getKeySize($cipher); |
|
381 | + |
|
382 | + $hash = hash_pbkdf2( |
|
383 | + 'sha256', |
|
384 | + $password, |
|
385 | + $salt, |
|
386 | + 100000, |
|
387 | + $keySize, |
|
388 | + true |
|
389 | + ); |
|
390 | + |
|
391 | + return $hash; |
|
392 | + } |
|
393 | + |
|
394 | + /** |
|
395 | + * encrypt private key |
|
396 | + * |
|
397 | + * @param string $privateKey |
|
398 | + * @param string $password |
|
399 | + * @param string $uid for regular users, empty for system keys |
|
400 | + * @return false|string |
|
401 | + */ |
|
402 | + public function encryptPrivateKey($privateKey, $password, $uid = '') { |
|
403 | + $cipher = $this->getCipher(); |
|
404 | + $hash = $this->generatePasswordHash($password, $cipher, $uid); |
|
405 | + $encryptedKey = $this->symmetricEncryptFileContent( |
|
406 | + $privateKey, |
|
407 | + $hash, |
|
408 | + 0, |
|
409 | + 0 |
|
410 | + ); |
|
411 | + |
|
412 | + return $encryptedKey; |
|
413 | + } |
|
414 | + |
|
415 | + /** |
|
416 | + * @param string $privateKey |
|
417 | + * @param string $password |
|
418 | + * @param string $uid for regular users, empty for system keys |
|
419 | + * @return false|string |
|
420 | + */ |
|
421 | + public function decryptPrivateKey($privateKey, $password = '', $uid = '') { |
|
422 | + $header = $this->parseHeader($privateKey); |
|
423 | + |
|
424 | + if (isset($header['cipher'])) { |
|
425 | + $cipher = $header['cipher']; |
|
426 | + } else { |
|
427 | + $cipher = $this->getLegacyCipher(); |
|
428 | + } |
|
429 | + |
|
430 | + if (isset($header['keyFormat'])) { |
|
431 | + $keyFormat = $header['keyFormat']; |
|
432 | + } else { |
|
433 | + $keyFormat = self::LEGACY_KEY_FORMAT; |
|
434 | + } |
|
435 | + |
|
436 | + if ($keyFormat === self::DEFAULT_KEY_FORMAT) { |
|
437 | + $password = $this->generatePasswordHash($password, $cipher, $uid); |
|
438 | + } |
|
439 | + |
|
440 | + $binaryEncoding = isset($header['encoding']) && $header['encoding'] === self::BINARY_ENCODING_FORMAT; |
|
441 | + |
|
442 | + // If we found a header we need to remove it from the key we want to decrypt |
|
443 | + if (!empty($header)) { |
|
444 | + $privateKey = substr($privateKey, |
|
445 | + strpos($privateKey, |
|
446 | + self::HEADER_END) + strlen(self::HEADER_END)); |
|
447 | + } |
|
448 | + |
|
449 | + $plainKey = $this->symmetricDecryptFileContent( |
|
450 | + $privateKey, |
|
451 | + $password, |
|
452 | + $cipher, |
|
453 | + 0, |
|
454 | + 0, |
|
455 | + $binaryEncoding |
|
456 | + ); |
|
457 | + |
|
458 | + if ($this->isValidPrivateKey($plainKey) === false) { |
|
459 | + return false; |
|
460 | + } |
|
461 | + |
|
462 | + return $plainKey; |
|
463 | + } |
|
464 | + |
|
465 | + /** |
|
466 | + * check if it is a valid private key |
|
467 | + * |
|
468 | + * @param string $plainKey |
|
469 | + * @return bool |
|
470 | + */ |
|
471 | + protected function isValidPrivateKey($plainKey) { |
|
472 | + $res = openssl_get_privatekey($plainKey); |
|
473 | + // TODO: remove resource check one php7.4 is not longer supported |
|
474 | + if (is_resource($res) || (is_object($res) && get_class($res) === 'OpenSSLAsymmetricKey')) { |
|
475 | + $sslInfo = openssl_pkey_get_details($res); |
|
476 | + if (isset($sslInfo['key'])) { |
|
477 | + return true; |
|
478 | + } |
|
479 | + } |
|
480 | + |
|
481 | + return false; |
|
482 | + } |
|
483 | + |
|
484 | + /** |
|
485 | + * @param string $keyFileContents |
|
486 | + * @param string $passPhrase |
|
487 | + * @param string $cipher |
|
488 | + * @param int $version |
|
489 | + * @param int|string $position |
|
490 | + * @param boolean $binaryEncoding |
|
491 | + * @return string |
|
492 | + * @throws DecryptionFailedException |
|
493 | + */ |
|
494 | + public function symmetricDecryptFileContent($keyFileContents, $passPhrase, $cipher = self::DEFAULT_CIPHER, $version = 0, $position = 0, bool $binaryEncoding = false) { |
|
495 | + if ($keyFileContents == '') { |
|
496 | + return ''; |
|
497 | + } |
|
498 | + |
|
499 | + $catFile = $this->splitMetaData($keyFileContents, $cipher); |
|
500 | + |
|
501 | + if ($catFile['signature'] !== false) { |
|
502 | + try { |
|
503 | + // First try the new format |
|
504 | + $this->checkSignature($catFile['encrypted'], $passPhrase . '_' . $version . '_' . $position, $catFile['signature']); |
|
505 | + } catch (GenericEncryptionException $e) { |
|
506 | + // For compatibility with old files check the version without _ |
|
507 | + $this->checkSignature($catFile['encrypted'], $passPhrase . $version . $position, $catFile['signature']); |
|
508 | + } |
|
509 | + } |
|
510 | + |
|
511 | + return $this->decrypt($catFile['encrypted'], |
|
512 | + $catFile['iv'], |
|
513 | + $passPhrase, |
|
514 | + $cipher, |
|
515 | + $binaryEncoding); |
|
516 | + } |
|
517 | + |
|
518 | + /** |
|
519 | + * check for valid signature |
|
520 | + * |
|
521 | + * @param string $data |
|
522 | + * @param string $passPhrase |
|
523 | + * @param string $expectedSignature |
|
524 | + * @throws GenericEncryptionException |
|
525 | + */ |
|
526 | + private function checkSignature($data, $passPhrase, $expectedSignature) { |
|
527 | + $enforceSignature = !$this->config->getSystemValueBool('encryption_skip_signature_check', false); |
|
528 | + |
|
529 | + $signature = $this->createSignature($data, $passPhrase); |
|
530 | + $isCorrectHash = hash_equals($expectedSignature, $signature); |
|
531 | + |
|
532 | + if (!$isCorrectHash && $enforceSignature) { |
|
533 | + throw new GenericEncryptionException('Bad Signature', $this->l->t('Bad Signature')); |
|
534 | + } elseif (!$isCorrectHash && !$enforceSignature) { |
|
535 | + $this->logger->info("Signature check skipped", ['app' => 'encryption']); |
|
536 | + } |
|
537 | + } |
|
538 | + |
|
539 | + /** |
|
540 | + * create signature |
|
541 | + * |
|
542 | + * @param string $data |
|
543 | + * @param string $passPhrase |
|
544 | + * @return string |
|
545 | + */ |
|
546 | + private function createSignature($data, $passPhrase) { |
|
547 | + $passPhrase = hash('sha512', $passPhrase . 'a', true); |
|
548 | + return hash_hmac('sha256', $data, $passPhrase); |
|
549 | + } |
|
550 | + |
|
551 | + |
|
552 | + /** |
|
553 | + * remove padding |
|
554 | + * |
|
555 | + * @param string $padded |
|
556 | + * @param bool $hasSignature did the block contain a signature, in this case we use a different padding |
|
557 | + * @return string|false |
|
558 | + */ |
|
559 | + private function removePadding($padded, $hasSignature = false) { |
|
560 | + if ($hasSignature === false && substr($padded, -2) === 'xx') { |
|
561 | + return substr($padded, 0, -2); |
|
562 | + } elseif ($hasSignature === true && substr($padded, -3) === 'xxx') { |
|
563 | + return substr($padded, 0, -3); |
|
564 | + } |
|
565 | + return false; |
|
566 | + } |
|
567 | + |
|
568 | + /** |
|
569 | + * split meta data from encrypted file |
|
570 | + * Note: for now, we assume that the meta data always start with the iv |
|
571 | + * followed by the signature, if available |
|
572 | + * |
|
573 | + * @param string $catFile |
|
574 | + * @param string $cipher |
|
575 | + * @return array |
|
576 | + */ |
|
577 | + private function splitMetaData($catFile, $cipher) { |
|
578 | + if ($this->hasSignature($catFile, $cipher)) { |
|
579 | + $catFile = $this->removePadding($catFile, true); |
|
580 | + $meta = substr($catFile, -93); |
|
581 | + $iv = substr($meta, strlen('00iv00'), 16); |
|
582 | + $sig = substr($meta, 22 + strlen('00sig00')); |
|
583 | + $encrypted = substr($catFile, 0, -93); |
|
584 | + } else { |
|
585 | + $catFile = $this->removePadding($catFile); |
|
586 | + $meta = substr($catFile, -22); |
|
587 | + $iv = substr($meta, -16); |
|
588 | + $sig = false; |
|
589 | + $encrypted = substr($catFile, 0, -22); |
|
590 | + } |
|
591 | + |
|
592 | + return [ |
|
593 | + 'encrypted' => $encrypted, |
|
594 | + 'iv' => $iv, |
|
595 | + 'signature' => $sig |
|
596 | + ]; |
|
597 | + } |
|
598 | + |
|
599 | + /** |
|
600 | + * check if encrypted block is signed |
|
601 | + * |
|
602 | + * @param string $catFile |
|
603 | + * @param string $cipher |
|
604 | + * @return bool |
|
605 | + * @throws GenericEncryptionException |
|
606 | + */ |
|
607 | + private function hasSignature($catFile, $cipher) { |
|
608 | + $skipSignatureCheck = $this->config->getSystemValueBool('encryption_skip_signature_check', false); |
|
609 | + |
|
610 | + $meta = substr($catFile, -93); |
|
611 | + $signaturePosition = strpos($meta, '00sig00'); |
|
612 | + |
|
613 | + // If we no longer support the legacy format then everything needs a signature |
|
614 | + if (!$skipSignatureCheck && !$this->supportLegacy && $signaturePosition === false) { |
|
615 | + throw new GenericEncryptionException('Missing Signature', $this->l->t('Missing Signature')); |
|
616 | + } |
|
617 | + |
|
618 | + // Enforce signature for the new 'CTR' ciphers |
|
619 | + if (!$skipSignatureCheck && $signaturePosition === false && stripos($cipher, 'ctr') !== false) { |
|
620 | + throw new GenericEncryptionException('Missing Signature', $this->l->t('Missing Signature')); |
|
621 | + } |
|
622 | + |
|
623 | + return ($signaturePosition !== false); |
|
624 | + } |
|
625 | + |
|
626 | + |
|
627 | + /** |
|
628 | + * @param string $encryptedContent |
|
629 | + * @param string $iv |
|
630 | + * @param string $passPhrase |
|
631 | + * @param string $cipher |
|
632 | + * @param boolean $binaryEncoding |
|
633 | + * @return string |
|
634 | + * @throws DecryptionFailedException |
|
635 | + */ |
|
636 | + private function decrypt(string $encryptedContent, string $iv, string $passPhrase = '', string $cipher = self::DEFAULT_CIPHER, bool $binaryEncoding = false): string { |
|
637 | + $options = $binaryEncoding === true ? OPENSSL_RAW_DATA : 0; |
|
638 | + $plainContent = openssl_decrypt($encryptedContent, |
|
639 | + $cipher, |
|
640 | + $passPhrase, |
|
641 | + $options, |
|
642 | + $iv); |
|
643 | + |
|
644 | + if ($plainContent) { |
|
645 | + return $plainContent; |
|
646 | + } else { |
|
647 | + throw new DecryptionFailedException('Encryption library: Decryption (symmetric) of content failed: ' . openssl_error_string()); |
|
648 | + } |
|
649 | + } |
|
650 | + |
|
651 | + /** |
|
652 | + * @param string $data |
|
653 | + * @return array |
|
654 | + */ |
|
655 | + protected function parseHeader($data) { |
|
656 | + $result = []; |
|
657 | + |
|
658 | + if (substr($data, 0, strlen(self::HEADER_START)) === self::HEADER_START) { |
|
659 | + $endAt = strpos($data, self::HEADER_END); |
|
660 | + $header = substr($data, 0, $endAt + strlen(self::HEADER_END)); |
|
661 | + |
|
662 | + // +1 not to start with an ':' which would result in empty element at the beginning |
|
663 | + $exploded = explode(':', |
|
664 | + substr($header, strlen(self::HEADER_START) + 1)); |
|
665 | + |
|
666 | + $element = array_shift($exploded); |
|
667 | + |
|
668 | + while ($element !== self::HEADER_END) { |
|
669 | + $result[$element] = array_shift($exploded); |
|
670 | + $element = array_shift($exploded); |
|
671 | + } |
|
672 | + } |
|
673 | + |
|
674 | + return $result; |
|
675 | + } |
|
676 | + |
|
677 | + /** |
|
678 | + * generate initialization vector |
|
679 | + * |
|
680 | + * @return string |
|
681 | + * @throws GenericEncryptionException |
|
682 | + */ |
|
683 | + private function generateIv() { |
|
684 | + return random_bytes(16); |
|
685 | + } |
|
686 | + |
|
687 | + /** |
|
688 | + * Generate a cryptographically secure pseudo-random 256-bit ASCII key, used |
|
689 | + * as file key |
|
690 | + * |
|
691 | + * @return string |
|
692 | + * @throws \Exception |
|
693 | + */ |
|
694 | + public function generateFileKey() { |
|
695 | + return random_bytes(32); |
|
696 | + } |
|
697 | + |
|
698 | + /** |
|
699 | + * @param $encKeyFile |
|
700 | + * @param $shareKey |
|
701 | + * @param $privateKey |
|
702 | + * @return string |
|
703 | + * @throws MultiKeyDecryptException |
|
704 | + */ |
|
705 | + public function multiKeyDecrypt($encKeyFile, $shareKey, $privateKey) { |
|
706 | + if (!$encKeyFile) { |
|
707 | + throw new MultiKeyDecryptException('Cannot multikey decrypt empty plain content'); |
|
708 | + } |
|
709 | + |
|
710 | + if (openssl_open($encKeyFile, $plainContent, $shareKey, $privateKey, 'RC4')) { |
|
711 | + return $plainContent; |
|
712 | + } else { |
|
713 | + throw new MultiKeyDecryptException('multikeydecrypt with share key failed:' . openssl_error_string()); |
|
714 | + } |
|
715 | + } |
|
716 | + |
|
717 | + /** |
|
718 | + * @param string $plainContent |
|
719 | + * @param array $keyFiles |
|
720 | + * @return array |
|
721 | + * @throws MultiKeyEncryptException |
|
722 | + */ |
|
723 | + public function multiKeyEncrypt($plainContent, array $keyFiles) { |
|
724 | + // openssl_seal returns false without errors if plaincontent is empty |
|
725 | + // so trigger our own error |
|
726 | + if (empty($plainContent)) { |
|
727 | + throw new MultiKeyEncryptException('Cannot multikeyencrypt empty plain content'); |
|
728 | + } |
|
729 | + |
|
730 | + // Set empty vars to be set by openssl by reference |
|
731 | + $sealed = ''; |
|
732 | + $shareKeys = []; |
|
733 | + $mappedShareKeys = []; |
|
734 | + |
|
735 | + if (openssl_seal($plainContent, $sealed, $shareKeys, $keyFiles, 'RC4')) { |
|
736 | + $i = 0; |
|
737 | + |
|
738 | + // Ensure each shareKey is labelled with its corresponding key id |
|
739 | + foreach ($keyFiles as $userId => $publicKey) { |
|
740 | + $mappedShareKeys[$userId] = $shareKeys[$i]; |
|
741 | + $i++; |
|
742 | + } |
|
743 | + |
|
744 | + return [ |
|
745 | + 'keys' => $mappedShareKeys, |
|
746 | + 'data' => $sealed |
|
747 | + ]; |
|
748 | + } else { |
|
749 | + throw new MultiKeyEncryptException('multikeyencryption failed ' . openssl_error_string()); |
|
750 | + } |
|
751 | + } |
|
752 | + |
|
753 | + public function useLegacyBase64Encoding(): bool { |
|
754 | + return $this->useLegacyBase64Encoding; |
|
755 | + } |
|
756 | 756 | } |
@@ -30,174 +30,174 @@ |
||
30 | 30 | use phpseclib\Net\SSH2; |
31 | 31 | |
32 | 32 | class SFTPReadStream implements File { |
33 | - /** @var resource */ |
|
34 | - public $context; |
|
35 | - |
|
36 | - /** @var \phpseclib\Net\SFTP */ |
|
37 | - private $sftp; |
|
38 | - |
|
39 | - /** @var string */ |
|
40 | - private $handle; |
|
41 | - |
|
42 | - /** @var int */ |
|
43 | - private $internalPosition = 0; |
|
44 | - |
|
45 | - /** @var int */ |
|
46 | - private $readPosition = 0; |
|
47 | - |
|
48 | - /** @var bool */ |
|
49 | - private $eof = false; |
|
50 | - |
|
51 | - private $buffer = ''; |
|
52 | - |
|
53 | - public static function register($protocol = 'sftpread') { |
|
54 | - if (in_array($protocol, stream_get_wrappers(), true)) { |
|
55 | - return false; |
|
56 | - } |
|
57 | - return stream_wrapper_register($protocol, get_called_class()); |
|
58 | - } |
|
59 | - |
|
60 | - /** |
|
61 | - * Load the source from the stream context and return the context options |
|
62 | - * |
|
63 | - * @param string $name |
|
64 | - * @throws \BadMethodCallException |
|
65 | - */ |
|
66 | - protected function loadContext($name) { |
|
67 | - $context = stream_context_get_options($this->context); |
|
68 | - if (isset($context[$name])) { |
|
69 | - $context = $context[$name]; |
|
70 | - } else { |
|
71 | - throw new \BadMethodCallException('Invalid context, "' . $name . '" options not set'); |
|
72 | - } |
|
73 | - if (isset($context['session']) and $context['session'] instanceof \phpseclib\Net\SFTP) { |
|
74 | - $this->sftp = $context['session']; |
|
75 | - } else { |
|
76 | - throw new \BadMethodCallException('Invalid context, session not set'); |
|
77 | - } |
|
78 | - return $context; |
|
79 | - } |
|
80 | - |
|
81 | - public function stream_open($path, $mode, $options, &$opened_path) { |
|
82 | - [, $path] = explode('://', $path); |
|
83 | - $path = '/' . ltrim($path); |
|
84 | - $path = str_replace('//', '/', $path); |
|
85 | - |
|
86 | - $this->loadContext('sftp'); |
|
87 | - |
|
88 | - if (!($this->sftp->bitmap & SSH2::MASK_LOGIN)) { |
|
89 | - return false; |
|
90 | - } |
|
91 | - |
|
92 | - $remote_file = $this->sftp->_realpath($path); |
|
93 | - if ($remote_file === false) { |
|
94 | - return false; |
|
95 | - } |
|
96 | - |
|
97 | - $packet = pack('Na*N2', strlen($remote_file), $remote_file, NET_SFTP_OPEN_READ, 0); |
|
98 | - if (!$this->sftp->_send_sftp_packet(NET_SFTP_OPEN, $packet)) { |
|
99 | - return false; |
|
100 | - } |
|
101 | - |
|
102 | - $response = $this->sftp->_get_sftp_packet(); |
|
103 | - switch ($this->sftp->packet_type) { |
|
104 | - case NET_SFTP_HANDLE: |
|
105 | - $this->handle = substr($response, 4); |
|
106 | - break; |
|
107 | - case NET_SFTP_STATUS: // presumably SSH_FX_NO_SUCH_FILE or SSH_FX_PERMISSION_DENIED |
|
108 | - $this->sftp->_logError($response); |
|
109 | - return false; |
|
110 | - default: |
|
111 | - user_error('Expected SSH_FXP_HANDLE or SSH_FXP_STATUS'); |
|
112 | - return false; |
|
113 | - } |
|
114 | - |
|
115 | - $this->request_chunk(256 * 1024); |
|
116 | - |
|
117 | - return true; |
|
118 | - } |
|
119 | - |
|
120 | - public function stream_seek($offset, $whence = SEEK_SET) { |
|
121 | - return false; |
|
122 | - } |
|
123 | - |
|
124 | - public function stream_tell() { |
|
125 | - return $this->readPosition; |
|
126 | - } |
|
127 | - |
|
128 | - public function stream_read($count) { |
|
129 | - if (!$this->eof && strlen($this->buffer) < $count) { |
|
130 | - $chunk = $this->read_chunk(); |
|
131 | - $this->buffer .= $chunk; |
|
132 | - if (!$this->eof) { |
|
133 | - $this->request_chunk(256 * 1024); |
|
134 | - } |
|
135 | - } |
|
136 | - |
|
137 | - $data = substr($this->buffer, 0, $count); |
|
138 | - $this->buffer = substr($this->buffer, $count); |
|
139 | - $this->readPosition += strlen($data); |
|
140 | - |
|
141 | - return $data; |
|
142 | - } |
|
143 | - |
|
144 | - private function request_chunk($size) { |
|
145 | - $packet = pack('Na*N3', strlen($this->handle), $this->handle, $this->internalPosition / 4294967296, $this->internalPosition, $size); |
|
146 | - return $this->sftp->_send_sftp_packet(NET_SFTP_READ, $packet); |
|
147 | - } |
|
148 | - |
|
149 | - private function read_chunk() { |
|
150 | - $response = $this->sftp->_get_sftp_packet(); |
|
151 | - |
|
152 | - switch ($this->sftp->packet_type) { |
|
153 | - case NET_SFTP_DATA: |
|
154 | - $temp = substr($response, 4); |
|
155 | - $len = strlen($temp); |
|
156 | - $this->internalPosition += $len; |
|
157 | - return $temp; |
|
158 | - case NET_SFTP_STATUS: |
|
159 | - [1 => $status] = unpack('N', substr($response, 0, 4)); |
|
160 | - if ($status == NET_SFTP_STATUS_EOF) { |
|
161 | - $this->eof = true; |
|
162 | - } |
|
163 | - return ''; |
|
164 | - default: |
|
165 | - return ''; |
|
166 | - } |
|
167 | - } |
|
168 | - |
|
169 | - public function stream_write($data) { |
|
170 | - return false; |
|
171 | - } |
|
172 | - |
|
173 | - public function stream_set_option($option, $arg1, $arg2) { |
|
174 | - return false; |
|
175 | - } |
|
176 | - |
|
177 | - public function stream_truncate($size) { |
|
178 | - return false; |
|
179 | - } |
|
180 | - |
|
181 | - public function stream_stat() { |
|
182 | - return false; |
|
183 | - } |
|
184 | - |
|
185 | - public function stream_lock($operation) { |
|
186 | - return false; |
|
187 | - } |
|
188 | - |
|
189 | - public function stream_flush() { |
|
190 | - return false; |
|
191 | - } |
|
192 | - |
|
193 | - public function stream_eof() { |
|
194 | - return $this->eof; |
|
195 | - } |
|
196 | - |
|
197 | - public function stream_close() { |
|
198 | - if (!$this->sftp->_close_handle($this->handle)) { |
|
199 | - return false; |
|
200 | - } |
|
201 | - return true; |
|
202 | - } |
|
33 | + /** @var resource */ |
|
34 | + public $context; |
|
35 | + |
|
36 | + /** @var \phpseclib\Net\SFTP */ |
|
37 | + private $sftp; |
|
38 | + |
|
39 | + /** @var string */ |
|
40 | + private $handle; |
|
41 | + |
|
42 | + /** @var int */ |
|
43 | + private $internalPosition = 0; |
|
44 | + |
|
45 | + /** @var int */ |
|
46 | + private $readPosition = 0; |
|
47 | + |
|
48 | + /** @var bool */ |
|
49 | + private $eof = false; |
|
50 | + |
|
51 | + private $buffer = ''; |
|
52 | + |
|
53 | + public static function register($protocol = 'sftpread') { |
|
54 | + if (in_array($protocol, stream_get_wrappers(), true)) { |
|
55 | + return false; |
|
56 | + } |
|
57 | + return stream_wrapper_register($protocol, get_called_class()); |
|
58 | + } |
|
59 | + |
|
60 | + /** |
|
61 | + * Load the source from the stream context and return the context options |
|
62 | + * |
|
63 | + * @param string $name |
|
64 | + * @throws \BadMethodCallException |
|
65 | + */ |
|
66 | + protected function loadContext($name) { |
|
67 | + $context = stream_context_get_options($this->context); |
|
68 | + if (isset($context[$name])) { |
|
69 | + $context = $context[$name]; |
|
70 | + } else { |
|
71 | + throw new \BadMethodCallException('Invalid context, "' . $name . '" options not set'); |
|
72 | + } |
|
73 | + if (isset($context['session']) and $context['session'] instanceof \phpseclib\Net\SFTP) { |
|
74 | + $this->sftp = $context['session']; |
|
75 | + } else { |
|
76 | + throw new \BadMethodCallException('Invalid context, session not set'); |
|
77 | + } |
|
78 | + return $context; |
|
79 | + } |
|
80 | + |
|
81 | + public function stream_open($path, $mode, $options, &$opened_path) { |
|
82 | + [, $path] = explode('://', $path); |
|
83 | + $path = '/' . ltrim($path); |
|
84 | + $path = str_replace('//', '/', $path); |
|
85 | + |
|
86 | + $this->loadContext('sftp'); |
|
87 | + |
|
88 | + if (!($this->sftp->bitmap & SSH2::MASK_LOGIN)) { |
|
89 | + return false; |
|
90 | + } |
|
91 | + |
|
92 | + $remote_file = $this->sftp->_realpath($path); |
|
93 | + if ($remote_file === false) { |
|
94 | + return false; |
|
95 | + } |
|
96 | + |
|
97 | + $packet = pack('Na*N2', strlen($remote_file), $remote_file, NET_SFTP_OPEN_READ, 0); |
|
98 | + if (!$this->sftp->_send_sftp_packet(NET_SFTP_OPEN, $packet)) { |
|
99 | + return false; |
|
100 | + } |
|
101 | + |
|
102 | + $response = $this->sftp->_get_sftp_packet(); |
|
103 | + switch ($this->sftp->packet_type) { |
|
104 | + case NET_SFTP_HANDLE: |
|
105 | + $this->handle = substr($response, 4); |
|
106 | + break; |
|
107 | + case NET_SFTP_STATUS: // presumably SSH_FX_NO_SUCH_FILE or SSH_FX_PERMISSION_DENIED |
|
108 | + $this->sftp->_logError($response); |
|
109 | + return false; |
|
110 | + default: |
|
111 | + user_error('Expected SSH_FXP_HANDLE or SSH_FXP_STATUS'); |
|
112 | + return false; |
|
113 | + } |
|
114 | + |
|
115 | + $this->request_chunk(256 * 1024); |
|
116 | + |
|
117 | + return true; |
|
118 | + } |
|
119 | + |
|
120 | + public function stream_seek($offset, $whence = SEEK_SET) { |
|
121 | + return false; |
|
122 | + } |
|
123 | + |
|
124 | + public function stream_tell() { |
|
125 | + return $this->readPosition; |
|
126 | + } |
|
127 | + |
|
128 | + public function stream_read($count) { |
|
129 | + if (!$this->eof && strlen($this->buffer) < $count) { |
|
130 | + $chunk = $this->read_chunk(); |
|
131 | + $this->buffer .= $chunk; |
|
132 | + if (!$this->eof) { |
|
133 | + $this->request_chunk(256 * 1024); |
|
134 | + } |
|
135 | + } |
|
136 | + |
|
137 | + $data = substr($this->buffer, 0, $count); |
|
138 | + $this->buffer = substr($this->buffer, $count); |
|
139 | + $this->readPosition += strlen($data); |
|
140 | + |
|
141 | + return $data; |
|
142 | + } |
|
143 | + |
|
144 | + private function request_chunk($size) { |
|
145 | + $packet = pack('Na*N3', strlen($this->handle), $this->handle, $this->internalPosition / 4294967296, $this->internalPosition, $size); |
|
146 | + return $this->sftp->_send_sftp_packet(NET_SFTP_READ, $packet); |
|
147 | + } |
|
148 | + |
|
149 | + private function read_chunk() { |
|
150 | + $response = $this->sftp->_get_sftp_packet(); |
|
151 | + |
|
152 | + switch ($this->sftp->packet_type) { |
|
153 | + case NET_SFTP_DATA: |
|
154 | + $temp = substr($response, 4); |
|
155 | + $len = strlen($temp); |
|
156 | + $this->internalPosition += $len; |
|
157 | + return $temp; |
|
158 | + case NET_SFTP_STATUS: |
|
159 | + [1 => $status] = unpack('N', substr($response, 0, 4)); |
|
160 | + if ($status == NET_SFTP_STATUS_EOF) { |
|
161 | + $this->eof = true; |
|
162 | + } |
|
163 | + return ''; |
|
164 | + default: |
|
165 | + return ''; |
|
166 | + } |
|
167 | + } |
|
168 | + |
|
169 | + public function stream_write($data) { |
|
170 | + return false; |
|
171 | + } |
|
172 | + |
|
173 | + public function stream_set_option($option, $arg1, $arg2) { |
|
174 | + return false; |
|
175 | + } |
|
176 | + |
|
177 | + public function stream_truncate($size) { |
|
178 | + return false; |
|
179 | + } |
|
180 | + |
|
181 | + public function stream_stat() { |
|
182 | + return false; |
|
183 | + } |
|
184 | + |
|
185 | + public function stream_lock($operation) { |
|
186 | + return false; |
|
187 | + } |
|
188 | + |
|
189 | + public function stream_flush() { |
|
190 | + return false; |
|
191 | + } |
|
192 | + |
|
193 | + public function stream_eof() { |
|
194 | + return $this->eof; |
|
195 | + } |
|
196 | + |
|
197 | + public function stream_close() { |
|
198 | + if (!$this->sftp->_close_handle($this->handle)) { |
|
199 | + return false; |
|
200 | + } |
|
201 | + return true; |
|
202 | + } |
|
203 | 203 | } |
@@ -35,171 +35,171 @@ |
||
35 | 35 | use Symfony\Component\Console\Output\OutputInterface; |
36 | 36 | |
37 | 37 | class Manage extends Command implements CompletionAwareInterface { |
38 | - public const DEFAULT_BACKEND = 'file'; |
|
39 | - public const DEFAULT_LOG_LEVEL = 2; |
|
40 | - public const DEFAULT_TIMEZONE = 'UTC'; |
|
41 | - |
|
42 | - protected IConfig $config; |
|
43 | - |
|
44 | - public function __construct(IConfig $config) { |
|
45 | - $this->config = $config; |
|
46 | - parent::__construct(); |
|
47 | - } |
|
48 | - |
|
49 | - protected function configure() { |
|
50 | - $this |
|
51 | - ->setName('log:manage') |
|
52 | - ->setDescription('manage logging configuration') |
|
53 | - ->addOption( |
|
54 | - 'backend', |
|
55 | - null, |
|
56 | - InputOption::VALUE_REQUIRED, |
|
57 | - 'set the logging backend [file, syslog, errorlog, systemd]' |
|
58 | - ) |
|
59 | - ->addOption( |
|
60 | - 'level', |
|
61 | - null, |
|
62 | - InputOption::VALUE_REQUIRED, |
|
63 | - 'set the log level [debug, info, warning, error, fatal]' |
|
64 | - ) |
|
65 | - ->addOption( |
|
66 | - 'timezone', |
|
67 | - null, |
|
68 | - InputOption::VALUE_REQUIRED, |
|
69 | - 'set the logging timezone' |
|
70 | - ) |
|
71 | - ; |
|
72 | - } |
|
73 | - |
|
74 | - protected function execute(InputInterface $input, OutputInterface $output): int { |
|
75 | - // collate config setting to the end, to avoid partial configuration |
|
76 | - $toBeSet = []; |
|
77 | - |
|
78 | - if ($backend = $input->getOption('backend')) { |
|
79 | - $this->validateBackend($backend); |
|
80 | - $toBeSet['log_type'] = $backend; |
|
81 | - } |
|
82 | - |
|
83 | - $level = $input->getOption('level'); |
|
84 | - if ($level !== null) { |
|
85 | - if (is_numeric($level)) { |
|
86 | - $levelNum = $level; |
|
87 | - // sanity check |
|
88 | - $this->convertLevelNumber($levelNum); |
|
89 | - } else { |
|
90 | - $levelNum = $this->convertLevelString($level); |
|
91 | - } |
|
92 | - $toBeSet['loglevel'] = $levelNum; |
|
93 | - } |
|
94 | - |
|
95 | - if ($timezone = $input->getOption('timezone')) { |
|
96 | - $this->validateTimezone($timezone); |
|
97 | - $toBeSet['logtimezone'] = $timezone; |
|
98 | - } |
|
99 | - |
|
100 | - // set config |
|
101 | - foreach ($toBeSet as $option => $value) { |
|
102 | - $this->config->setSystemValue($option, $value); |
|
103 | - } |
|
104 | - |
|
105 | - // display configuration |
|
106 | - $backend = $this->config->getSystemValue('log_type', self::DEFAULT_BACKEND); |
|
107 | - $output->writeln('Enabled logging backend: '.$backend); |
|
108 | - |
|
109 | - $levelNum = $this->config->getSystemValue('loglevel', self::DEFAULT_LOG_LEVEL); |
|
110 | - $level = $this->convertLevelNumber($levelNum); |
|
111 | - $output->writeln('Log level: '.$level.' ('.$levelNum.')'); |
|
112 | - |
|
113 | - $timezone = $this->config->getSystemValue('logtimezone', self::DEFAULT_TIMEZONE); |
|
114 | - $output->writeln('Log timezone: '.$timezone); |
|
115 | - return 0; |
|
116 | - } |
|
117 | - |
|
118 | - /** |
|
119 | - * @param string $backend |
|
120 | - * @throws \InvalidArgumentException |
|
121 | - */ |
|
122 | - protected function validateBackend($backend) { |
|
123 | - if (!class_exists('OC\\Log\\'.ucfirst($backend))) { |
|
124 | - throw new \InvalidArgumentException('Invalid backend'); |
|
125 | - } |
|
126 | - } |
|
127 | - |
|
128 | - /** |
|
129 | - * @param string $timezone |
|
130 | - * @throws \Exception |
|
131 | - */ |
|
132 | - protected function validateTimezone($timezone) { |
|
133 | - new \DateTimeZone($timezone); |
|
134 | - } |
|
135 | - |
|
136 | - /** |
|
137 | - * @param string $level |
|
138 | - * @return int |
|
139 | - * @throws \InvalidArgumentException |
|
140 | - */ |
|
141 | - protected function convertLevelString($level) { |
|
142 | - $level = strtolower($level); |
|
143 | - switch ($level) { |
|
144 | - case 'debug': |
|
145 | - return 0; |
|
146 | - case 'info': |
|
147 | - return 1; |
|
148 | - case 'warning': |
|
149 | - case 'warn': |
|
150 | - return 2; |
|
151 | - case 'error': |
|
152 | - case 'err': |
|
153 | - return 3; |
|
154 | - case 'fatal': |
|
155 | - return 4; |
|
156 | - } |
|
157 | - throw new \InvalidArgumentException('Invalid log level string'); |
|
158 | - } |
|
159 | - |
|
160 | - /** |
|
161 | - * @param int $levelNum |
|
162 | - * @return string |
|
163 | - * @throws \InvalidArgumentException |
|
164 | - */ |
|
165 | - protected function convertLevelNumber($levelNum) { |
|
166 | - switch ($levelNum) { |
|
167 | - case 0: |
|
168 | - return 'Debug'; |
|
169 | - case 1: |
|
170 | - return 'Info'; |
|
171 | - case 2: |
|
172 | - return 'Warning'; |
|
173 | - case 3: |
|
174 | - return 'Error'; |
|
175 | - case 4: |
|
176 | - return 'Fatal'; |
|
177 | - } |
|
178 | - throw new \InvalidArgumentException('Invalid log level number'); |
|
179 | - } |
|
180 | - |
|
181 | - /** |
|
182 | - * @param string $optionName |
|
183 | - * @param CompletionContext $context |
|
184 | - * @return string[] |
|
185 | - */ |
|
186 | - public function completeOptionValues($optionName, CompletionContext $context) { |
|
187 | - if ($optionName === 'backend') { |
|
188 | - return ['file', 'syslog', 'errorlog', 'systemd']; |
|
189 | - } elseif ($optionName === 'level') { |
|
190 | - return ['debug', 'info', 'warning', 'error', 'fatal']; |
|
191 | - } elseif ($optionName === 'timezone') { |
|
192 | - return \DateTimeZone::listIdentifiers(); |
|
193 | - } |
|
194 | - return []; |
|
195 | - } |
|
196 | - |
|
197 | - /** |
|
198 | - * @param string $argumentName |
|
199 | - * @param CompletionContext $context |
|
200 | - * @return string[] |
|
201 | - */ |
|
202 | - public function completeArgumentValues($argumentName, CompletionContext $context) { |
|
203 | - return []; |
|
204 | - } |
|
38 | + public const DEFAULT_BACKEND = 'file'; |
|
39 | + public const DEFAULT_LOG_LEVEL = 2; |
|
40 | + public const DEFAULT_TIMEZONE = 'UTC'; |
|
41 | + |
|
42 | + protected IConfig $config; |
|
43 | + |
|
44 | + public function __construct(IConfig $config) { |
|
45 | + $this->config = $config; |
|
46 | + parent::__construct(); |
|
47 | + } |
|
48 | + |
|
49 | + protected function configure() { |
|
50 | + $this |
|
51 | + ->setName('log:manage') |
|
52 | + ->setDescription('manage logging configuration') |
|
53 | + ->addOption( |
|
54 | + 'backend', |
|
55 | + null, |
|
56 | + InputOption::VALUE_REQUIRED, |
|
57 | + 'set the logging backend [file, syslog, errorlog, systemd]' |
|
58 | + ) |
|
59 | + ->addOption( |
|
60 | + 'level', |
|
61 | + null, |
|
62 | + InputOption::VALUE_REQUIRED, |
|
63 | + 'set the log level [debug, info, warning, error, fatal]' |
|
64 | + ) |
|
65 | + ->addOption( |
|
66 | + 'timezone', |
|
67 | + null, |
|
68 | + InputOption::VALUE_REQUIRED, |
|
69 | + 'set the logging timezone' |
|
70 | + ) |
|
71 | + ; |
|
72 | + } |
|
73 | + |
|
74 | + protected function execute(InputInterface $input, OutputInterface $output): int { |
|
75 | + // collate config setting to the end, to avoid partial configuration |
|
76 | + $toBeSet = []; |
|
77 | + |
|
78 | + if ($backend = $input->getOption('backend')) { |
|
79 | + $this->validateBackend($backend); |
|
80 | + $toBeSet['log_type'] = $backend; |
|
81 | + } |
|
82 | + |
|
83 | + $level = $input->getOption('level'); |
|
84 | + if ($level !== null) { |
|
85 | + if (is_numeric($level)) { |
|
86 | + $levelNum = $level; |
|
87 | + // sanity check |
|
88 | + $this->convertLevelNumber($levelNum); |
|
89 | + } else { |
|
90 | + $levelNum = $this->convertLevelString($level); |
|
91 | + } |
|
92 | + $toBeSet['loglevel'] = $levelNum; |
|
93 | + } |
|
94 | + |
|
95 | + if ($timezone = $input->getOption('timezone')) { |
|
96 | + $this->validateTimezone($timezone); |
|
97 | + $toBeSet['logtimezone'] = $timezone; |
|
98 | + } |
|
99 | + |
|
100 | + // set config |
|
101 | + foreach ($toBeSet as $option => $value) { |
|
102 | + $this->config->setSystemValue($option, $value); |
|
103 | + } |
|
104 | + |
|
105 | + // display configuration |
|
106 | + $backend = $this->config->getSystemValue('log_type', self::DEFAULT_BACKEND); |
|
107 | + $output->writeln('Enabled logging backend: '.$backend); |
|
108 | + |
|
109 | + $levelNum = $this->config->getSystemValue('loglevel', self::DEFAULT_LOG_LEVEL); |
|
110 | + $level = $this->convertLevelNumber($levelNum); |
|
111 | + $output->writeln('Log level: '.$level.' ('.$levelNum.')'); |
|
112 | + |
|
113 | + $timezone = $this->config->getSystemValue('logtimezone', self::DEFAULT_TIMEZONE); |
|
114 | + $output->writeln('Log timezone: '.$timezone); |
|
115 | + return 0; |
|
116 | + } |
|
117 | + |
|
118 | + /** |
|
119 | + * @param string $backend |
|
120 | + * @throws \InvalidArgumentException |
|
121 | + */ |
|
122 | + protected function validateBackend($backend) { |
|
123 | + if (!class_exists('OC\\Log\\'.ucfirst($backend))) { |
|
124 | + throw new \InvalidArgumentException('Invalid backend'); |
|
125 | + } |
|
126 | + } |
|
127 | + |
|
128 | + /** |
|
129 | + * @param string $timezone |
|
130 | + * @throws \Exception |
|
131 | + */ |
|
132 | + protected function validateTimezone($timezone) { |
|
133 | + new \DateTimeZone($timezone); |
|
134 | + } |
|
135 | + |
|
136 | + /** |
|
137 | + * @param string $level |
|
138 | + * @return int |
|
139 | + * @throws \InvalidArgumentException |
|
140 | + */ |
|
141 | + protected function convertLevelString($level) { |
|
142 | + $level = strtolower($level); |
|
143 | + switch ($level) { |
|
144 | + case 'debug': |
|
145 | + return 0; |
|
146 | + case 'info': |
|
147 | + return 1; |
|
148 | + case 'warning': |
|
149 | + case 'warn': |
|
150 | + return 2; |
|
151 | + case 'error': |
|
152 | + case 'err': |
|
153 | + return 3; |
|
154 | + case 'fatal': |
|
155 | + return 4; |
|
156 | + } |
|
157 | + throw new \InvalidArgumentException('Invalid log level string'); |
|
158 | + } |
|
159 | + |
|
160 | + /** |
|
161 | + * @param int $levelNum |
|
162 | + * @return string |
|
163 | + * @throws \InvalidArgumentException |
|
164 | + */ |
|
165 | + protected function convertLevelNumber($levelNum) { |
|
166 | + switch ($levelNum) { |
|
167 | + case 0: |
|
168 | + return 'Debug'; |
|
169 | + case 1: |
|
170 | + return 'Info'; |
|
171 | + case 2: |
|
172 | + return 'Warning'; |
|
173 | + case 3: |
|
174 | + return 'Error'; |
|
175 | + case 4: |
|
176 | + return 'Fatal'; |
|
177 | + } |
|
178 | + throw new \InvalidArgumentException('Invalid log level number'); |
|
179 | + } |
|
180 | + |
|
181 | + /** |
|
182 | + * @param string $optionName |
|
183 | + * @param CompletionContext $context |
|
184 | + * @return string[] |
|
185 | + */ |
|
186 | + public function completeOptionValues($optionName, CompletionContext $context) { |
|
187 | + if ($optionName === 'backend') { |
|
188 | + return ['file', 'syslog', 'errorlog', 'systemd']; |
|
189 | + } elseif ($optionName === 'level') { |
|
190 | + return ['debug', 'info', 'warning', 'error', 'fatal']; |
|
191 | + } elseif ($optionName === 'timezone') { |
|
192 | + return \DateTimeZone::listIdentifiers(); |
|
193 | + } |
|
194 | + return []; |
|
195 | + } |
|
196 | + |
|
197 | + /** |
|
198 | + * @param string $argumentName |
|
199 | + * @param CompletionContext $context |
|
200 | + * @return string[] |
|
201 | + */ |
|
202 | + public function completeArgumentValues($argumentName, CompletionContext $context) { |
|
203 | + return []; |
|
204 | + } |
|
205 | 205 | } |